mirror of
https://github.com/dustinkeeton/obsidian-synapse.git
synced 2026-07-22 07:44:43 +00:00
init
This commit is contained in:
commit
0d9defb428
40 changed files with 3312 additions and 0 deletions
24
.claude/agents/elaboration-designer.md
Normal file
24
.claude/agents/elaboration-designer.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: elaboration-designer
|
||||
description: Note elaboration system designer. Specializes in placeholder detection, non-destructive proposal workflows, and AI-powered content expansion.
|
||||
skills:
|
||||
- note-elaboration
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Agent
|
||||
---
|
||||
|
||||
You are a specialist in AI-powered note elaboration systems. Your expertise covers:
|
||||
|
||||
- Heuristic detection of placeholder/stub notes
|
||||
- Non-destructive proposal workflows (separate storage, review, merge)
|
||||
- AI prompt engineering for content expansion
|
||||
- Obsidian vault structure and metadata (frontmatter, tags, links)
|
||||
- User experience for review/accept/reject flows
|
||||
|
||||
When designing the elaboration system, prioritize:
|
||||
1. Never modifying original notes without explicit user consent
|
||||
2. Smart detection that minimizes false positives
|
||||
3. Context-aware elaboration (considering linked notes, tags, vault structure)
|
||||
4. Clear proposal format that's easy to review
|
||||
5. Flexible configuration for different user workflows
|
||||
|
||||
You have access to the `note-elaboration` skill for reference on the proposal system design.
|
||||
24
.claude/agents/plugin-architect.md
Normal file
24
.claude/agents/plugin-architect.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: plugin-architect
|
||||
description: Obsidian plugin architecture specialist. Designs plugin structure, settings, commands, UI components, and build configuration.
|
||||
skills:
|
||||
- obsidian-plugin-dev
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Agent
|
||||
---
|
||||
|
||||
You are a specialist in Obsidian plugin development. Your expertise covers:
|
||||
|
||||
- Plugin lifecycle management (onload/onunload)
|
||||
- Obsidian API patterns (vault operations, commands, settings, modals, views)
|
||||
- TypeScript project configuration and build pipelines (esbuild)
|
||||
- Plugin settings UI and data persistence
|
||||
- Community plugin best practices and review requirements
|
||||
|
||||
When designing architecture, prioritize:
|
||||
1. Clean separation of concerns
|
||||
2. Proper use of Obsidian's registration/cleanup patterns
|
||||
3. Type safety with TypeScript
|
||||
4. User-friendly settings and configuration
|
||||
5. Performance (avoid blocking the main thread)
|
||||
|
||||
You have access to the `obsidian-plugin-dev` skill for reference on API patterns and project structure.
|
||||
24
.claude/agents/transcription-engineer.md
Normal file
24
.claude/agents/transcription-engineer.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: transcription-engineer
|
||||
description: Media transcription pipeline engineer. Specializes in audio/video transcription, URL-based media fetching (YouTube, TikTok), and post-processing for readability.
|
||||
skills:
|
||||
- media-transcription
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Agent
|
||||
---
|
||||
|
||||
You are a specialist in media transcription pipelines. Your expertise covers:
|
||||
|
||||
- Audio transcription APIs (Whisper, Deepgram, etc.)
|
||||
- Video processing and audio extraction
|
||||
- URL-based media fetching (yt-dlp for YouTube, TikTok)
|
||||
- Transcript post-processing (cleanup, structuring, summarization)
|
||||
- Vision model integration for video content analysis
|
||||
|
||||
When designing the transcription system, prioritize:
|
||||
1. Reliable media fetching and format handling
|
||||
2. High-quality transcription with appropriate post-processing options
|
||||
3. Graceful handling of API limits, large files, and network issues
|
||||
4. Clear user feedback during long-running operations
|
||||
5. Configurable output formats and processing levels
|
||||
|
||||
You have access to the `media-transcription` skill for reference on the transcription pipeline design.
|
||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"enabledPlugins": {
|
||||
"typescript-lsp@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
69
.claude/skills/media-transcription/SKILL.md
Normal file
69
.claude/skills/media-transcription/SKILL.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
name: media-transcription
|
||||
description: Audio and video transcription pipeline - transcribing media to text, auto-iterating transcriptions for clarity, fetching TikTok/YouTube videos from URLs, and video content analysis. Use when working on transcription features.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Media Transcription System Design
|
||||
|
||||
## Audio Transcription
|
||||
|
||||
### Pipeline
|
||||
1. User provides audio file (or records within Obsidian)
|
||||
2. Audio is sent to transcription API (Whisper, Deepgram, or similar)
|
||||
3. Raw transcript is returned
|
||||
4. Optional: AI post-processing for clarity and structure
|
||||
5. Transcript saved as note (or appended to existing note)
|
||||
|
||||
### Post-Processing Options
|
||||
- Clean up filler words, false starts
|
||||
- Add punctuation and paragraph breaks
|
||||
- Restructure for readability while preserving meaning
|
||||
- Extract key points / summary
|
||||
- Add timestamps for reference
|
||||
|
||||
## Video Transcription
|
||||
|
||||
### Core Features
|
||||
- Extract audio track from video files
|
||||
- Transcribe extracted audio (same pipeline as audio)
|
||||
- Support local video files
|
||||
|
||||
### Stretch Goal: Video Content Analysis
|
||||
- Frame extraction at intervals
|
||||
- Vision model analysis of key frames
|
||||
- Combined transcript + visual description
|
||||
|
||||
## URL-Based Media Fetching
|
||||
|
||||
### Supported Platforms
|
||||
- **YouTube**: Use yt-dlp or similar to download/extract audio
|
||||
- **TikTok**: Use yt-dlp (supports TikTok) to fetch video/audio
|
||||
|
||||
### URL Detection Flow
|
||||
1. User pastes URL into note or provides via command
|
||||
2. Plugin detects supported platform URL pattern
|
||||
3. Fetches media using appropriate tool (yt-dlp)
|
||||
4. Extracts audio track
|
||||
5. Runs transcription pipeline
|
||||
6. Creates/updates note with transcript
|
||||
|
||||
### URL Pattern Matching
|
||||
```typescript
|
||||
const YOUTUBE_REGEX = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/;
|
||||
const TIKTOK_REGEX = /tiktok\.com\/@[\w.-]+\/video\/(\d+)/;
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
- Transcription API provider and key
|
||||
- Post-processing level (raw, cleaned, structured)
|
||||
- Auto-detect URLs in notes (on/off)
|
||||
- Output format (inline, new note, append)
|
||||
- Language settings
|
||||
- yt-dlp binary path
|
||||
|
||||
## Technical Considerations
|
||||
- yt-dlp must be installed on user's system (or bundled)
|
||||
- Large files may need chunked transcription
|
||||
- API rate limits and costs should be surfaced to user
|
||||
- Consider offline/local transcription option (whisper.cpp)
|
||||
71
.claude/skills/note-elaboration/SKILL.md
Normal file
71
.claude/skills/note-elaboration/SKILL.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
name: note-elaboration
|
||||
description: Designing the note elaboration system - detecting placeholder notes, proposing non-destructive additions, storing proposals separately, and AI-powered content expansion. Use when working on the note analysis and proposal features.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Note Elaboration System Design
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Non-destructive**: Never modify original notes without explicit user approval
|
||||
2. **Proposal-based**: Store suggestions in separate files, not inline
|
||||
3. **Smart detection**: Focus on notes that are clearly placeholders or stubs
|
||||
4. **Context-aware**: Consider vault structure, linked notes, and tags when elaborating
|
||||
|
||||
## Placeholder Detection Heuristics
|
||||
|
||||
Identify notes likely to be placeholders:
|
||||
- Very short notes (< N words/lines threshold, configurable)
|
||||
- Notes with bullet points but no prose
|
||||
- Notes with TODO/TBD/placeholder markers
|
||||
- Notes with headings but empty sections
|
||||
- Recently created notes with minimal content
|
||||
- Notes linked from other notes but with sparse content
|
||||
|
||||
## Proposal Storage Strategy
|
||||
|
||||
```
|
||||
vault/
|
||||
├── notes/
|
||||
│ └── my-note.md # Original note (untouched)
|
||||
└── .auto-notes/ # Hidden proposals directory
|
||||
└── proposals/
|
||||
└── my-note.proposal.md # Proposed additions
|
||||
```
|
||||
|
||||
### Proposal File Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
source: notes/my-note.md
|
||||
created: 2026-03-12T10:00:00Z
|
||||
status: pending # pending | accepted | rejected | partial
|
||||
---
|
||||
|
||||
## Proposed Additions
|
||||
|
||||
### Section: [Original Section Name]
|
||||
[Proposed content to add or expand]
|
||||
|
||||
### New Section: [Suggested Section]
|
||||
[Suggested new content]
|
||||
```
|
||||
|
||||
## User Interaction Flow
|
||||
|
||||
1. User triggers scan (manual command or on schedule)
|
||||
2. Plugin identifies candidate notes
|
||||
3. AI generates proposals stored in `.auto-notes/proposals/`
|
||||
4. User reviews proposals via dedicated UI (sidebar, modal, or diff view)
|
||||
5. User can accept, reject, or partially accept each proposal
|
||||
6. Accepted content is merged into original note
|
||||
|
||||
## Configuration Options
|
||||
|
||||
- Target directory/subdirectory within vault
|
||||
- Minimum note length threshold for "placeholder" detection
|
||||
- AI model/API settings
|
||||
- Auto-scan interval (or manual only)
|
||||
- Proposal storage location
|
||||
- Include/exclude patterns (folders, tags, frontmatter)
|
||||
80
.claude/skills/obsidian-plugin-dev/SKILL.md
Normal file
80
.claude/skills/obsidian-plugin-dev/SKILL.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
name: obsidian-plugin-dev
|
||||
description: Obsidian plugin development patterns, API usage, manifest configuration, settings management, and build pipeline. Use when working on plugin architecture, lifecycle hooks, or Obsidian API integration.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Obsidian Plugin Development Reference
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
plugin-root/
|
||||
├── src/
|
||||
│ └── main.ts # Plugin class extending Plugin
|
||||
├── manifest.json # Plugin metadata (id, name, version, minAppVersion)
|
||||
├── package.json # npm dependencies
|
||||
├── tsconfig.json # TypeScript config (ES6 target, strict mode)
|
||||
├── esbuild.config.mjs # Bundler (main.ts → main.js, obsidian external)
|
||||
├── styles.css # Plugin styles
|
||||
├── versions.json # Version-to-Obsidian compatibility
|
||||
└── main.js # Compiled output (generated)
|
||||
```
|
||||
|
||||
## manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "auto-notes",
|
||||
"name": "Auto Notes",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Flesh out and elaborate on notes with AI assistance",
|
||||
"author": "Dustin Keeton",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
```
|
||||
|
||||
## Plugin Lifecycle
|
||||
|
||||
```typescript
|
||||
import { Plugin } from 'obsidian';
|
||||
|
||||
export default class AutoNotesPlugin extends Plugin {
|
||||
async onload() {
|
||||
// Load settings, register UI, commands, events
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new AutoNotesSettingTab(this.app, this));
|
||||
this.registerCommands();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Automatic cleanup for registered resources
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key API Patterns
|
||||
|
||||
- **Commands**: `this.addCommand({ id, name, callback })` or `editorCallback` for editor context
|
||||
- **Ribbon icons**: `this.addRibbonIcon(icon, title, callback)`
|
||||
- **Settings**: `this.loadData()` / `this.saveData()` with `Object.assign(DEFAULT_SETTINGS, loaded)`
|
||||
- **Events**: `this.registerEvent(this.app.vault.on('modify', callback))`
|
||||
- **DOM events**: `this.registerDomEvent(document, 'click', callback)` (auto-cleanup)
|
||||
- **File operations**: `this.app.vault.read(file)`, `this.app.vault.modify(file, content)`
|
||||
- **Modals**: Extend `Modal` class with `onOpen()` / `onClose()`
|
||||
- **Setting tabs**: Extend `PluginSettingTab` with `display()` method
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
- `npm install` → install deps (obsidian, @types/node, esbuild, typescript)
|
||||
- `npm run dev` → esbuild watch mode (rebuilds on change)
|
||||
- `npm run build` → production build with type checking (`tsc -noEmit && esbuild`)
|
||||
- Obsidian API is external (provided at runtime, not bundled)
|
||||
|
||||
## Development Tips
|
||||
|
||||
- Use a dedicated development vault, not your primary vault
|
||||
- Install Hot-Reload plugin for auto-reloading during dev
|
||||
- Use `new Notice('message')` for user-facing notifications
|
||||
- Settings persist to `data.json` in the plugin directory
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
main.js
|
||||
dist/
|
||||
data.json
|
||||
24
START.md
Normal file
24
START.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# START HERE
|
||||
|
||||
## This is a fresh repo. It is for the development of an Obsidian plugin that will will "flesh out" or elaborate on notes taken within a particular Obsidian vault or subdirectory of a vault.
|
||||
|
||||
### The main features should include:
|
||||
- proposing additions to documents
|
||||
- minimize destructive actions the user might not want
|
||||
- focus on notes that are clearly placeholders for more thoughts
|
||||
- consider a system where proposals are stored in separate file
|
||||
- transcribing audio
|
||||
- transcribe audio to text
|
||||
- option for transcription to be auto-iterated upon for efficient telling of whatever was transcribed
|
||||
- transcriving video
|
||||
- same needs as transcribing audio
|
||||
- stretch goal of being able to discern video
|
||||
- auto fetches tikotk and youtube videos from urls to transcribe
|
||||
|
||||
### Your first steps should be to do the following:
|
||||
1. Refer to https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin for documentation.
|
||||
2. Create sub agents that specialize in meaningfully distinct specialities for this. Arm them with the above skills as appropriate
|
||||
3. Spawn agents as a team in planning mode to discuss what is needed.
|
||||
1. Create skills useful for this task. Reference https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf
|
||||
2. Initialize codebase based off of findings.
|
||||
4. Check in with me for next steps.
|
||||
46
esbuild.config.mjs
Normal file
46
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "auto-notes",
|
||||
"name": "Auto Notes",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "AI-powered note elaboration, audio transcription, and video transcription for Obsidian",
|
||||
"author": "Dustin Keeton",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
643
package-lock.json
generated
Normal file
643
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
{
|
||||
"name": "auto-notes",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-notes",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.38.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"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",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/codemirror": {
|
||||
"version": "5.60.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
|
||||
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/tern": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tern": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz",
|
||||
"integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/obsidian": {
|
||||
"version": "1.12.3",
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
|
||||
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "5.60.8",
|
||||
"moment": "2.29.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.38.6"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "auto-notes",
|
||||
"version": "0.1.0",
|
||||
"description": "AI-powered note elaboration, audio transcription, and video transcription for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Dustin Keeton",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
111
src/audio/index.ts
Normal file
111
src/audio/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Notice, Plugin, TFile } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { notifyError } from '../shared/api-utils';
|
||||
import { writeNote } from '../shared/file-utils';
|
||||
import { PostProcessor } from './post-processor';
|
||||
import { Transcriber } from './transcriber';
|
||||
import { AudioTranscriptionModal } from './transcription-modal';
|
||||
import { TranscribeOptions, TranscriptionResult } from './types';
|
||||
|
||||
export class AudioModule {
|
||||
private transcriber: Transcriber;
|
||||
private postProcessor: PostProcessor;
|
||||
|
||||
constructor(
|
||||
private plugin: Plugin,
|
||||
private getSettings: () => AutoNotesSettings
|
||||
) {
|
||||
this.transcriber = new Transcriber(getSettings);
|
||||
this.postProcessor = new PostProcessor(getSettings);
|
||||
}
|
||||
|
||||
async onload(): Promise<void> {
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:transcribe-audio',
|
||||
name: 'Transcribe audio file',
|
||||
callback: () => this.openTranscriptionModal(),
|
||||
});
|
||||
}
|
||||
|
||||
onunload(): void {}
|
||||
|
||||
async transcribe(
|
||||
audioData: ArrayBuffer,
|
||||
fileName: string,
|
||||
options?: TranscribeOptions
|
||||
): Promise<TranscriptionResult> {
|
||||
const result = await this.transcriber.transcribe(audioData, fileName);
|
||||
|
||||
if (options?.postProcess !== false) {
|
||||
result.processed = await this.postProcessor.process(result.raw);
|
||||
}
|
||||
|
||||
if (options?.sourceName) {
|
||||
result.sourceName = options.sourceName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async saveTranscription(
|
||||
result: TranscriptionResult,
|
||||
targetPath?: string
|
||||
): Promise<void> {
|
||||
const settings = this.getSettings().audio.output;
|
||||
const content = this.formatTranscription(result);
|
||||
|
||||
const path =
|
||||
targetPath || this.buildOutputPath(result.sourceName, settings);
|
||||
|
||||
await writeNote(this.plugin.app, path, content);
|
||||
new Notice(`Auto Notes: Transcription saved to ${path}`);
|
||||
}
|
||||
|
||||
private openTranscriptionModal(): void {
|
||||
new AudioTranscriptionModal(
|
||||
this.plugin.app,
|
||||
this.getSettings,
|
||||
async (file) => {
|
||||
try {
|
||||
new Notice('Auto Notes: Transcribing...');
|
||||
const data = await this.plugin.app.vault.readBinary(file);
|
||||
const result = await this.transcribe(data, file.name);
|
||||
await this.saveTranscription(result);
|
||||
} catch (error) {
|
||||
notifyError('Transcription failed', error);
|
||||
}
|
||||
}
|
||||
).open();
|
||||
}
|
||||
|
||||
private formatTranscription(result: TranscriptionResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`---`);
|
||||
parts.push(`source: ${result.sourceName}`);
|
||||
parts.push(`date: ${new Date().toISOString().split('T')[0]}`);
|
||||
if (result.language) parts.push(`language: ${result.language}`);
|
||||
if (result.duration) {
|
||||
parts.push(`duration: ${Math.round(result.duration)}s`);
|
||||
}
|
||||
parts.push(`---`);
|
||||
parts.push('');
|
||||
|
||||
const text = result.processed || result.raw;
|
||||
parts.push(text);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
private buildOutputPath(
|
||||
sourceName: string,
|
||||
settings: { folder: string; fileNameTemplate: string }
|
||||
): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const source = sourceName.replace(/\.[^.]+$/, '');
|
||||
const fileName = settings.fileNameTemplate
|
||||
.replace('{{date}}', date)
|
||||
.replace('{{source}}', source);
|
||||
return `${settings.folder}/${fileName}.md`;
|
||||
}
|
||||
}
|
||||
48
src/audio/post-processor.ts
Normal file
48
src/audio/post-processor.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { AutoNotesSettings } from '../settings';
|
||||
import { AIClient } from '../shared/ai-client';
|
||||
|
||||
export class PostProcessor {
|
||||
private aiClient: AIClient;
|
||||
|
||||
constructor(private getSettings: () => AutoNotesSettings) {
|
||||
this.aiClient = new AIClient(getSettings);
|
||||
}
|
||||
|
||||
async process(rawTranscript: string): Promise<string> {
|
||||
const settings = this.getSettings().audio.postProcessing;
|
||||
if (!settings.enabled) return rawTranscript;
|
||||
|
||||
const instructions: string[] = [];
|
||||
|
||||
if (settings.removeFiller) {
|
||||
instructions.push(
|
||||
'Remove filler words (um, uh, like, you know) and false starts'
|
||||
);
|
||||
}
|
||||
if (settings.addStructure) {
|
||||
instructions.push(
|
||||
'Add proper punctuation, paragraph breaks, and section headers where appropriate'
|
||||
);
|
||||
}
|
||||
if (settings.extractKeyPoints) {
|
||||
instructions.push(
|
||||
'Add a "Key Points" summary section at the top with bullet points'
|
||||
);
|
||||
}
|
||||
if (settings.customPrompt) {
|
||||
instructions.push(settings.customPrompt);
|
||||
}
|
||||
|
||||
if (instructions.length === 0) return rawTranscript;
|
||||
|
||||
const systemPrompt =
|
||||
'You are a transcription editor. Process the following raw transcript according to the instructions. ' +
|
||||
'Preserve all meaning and key information. Output only the processed transcript.';
|
||||
|
||||
const prompt =
|
||||
`Instructions:\n${instructions.map((i) => `- ${i}`).join('\n')}\n\n` +
|
||||
`Raw transcript:\n${rawTranscript}`;
|
||||
|
||||
return this.aiClient.complete(prompt, systemPrompt);
|
||||
}
|
||||
}
|
||||
114
src/audio/transcriber.ts
Normal file
114
src/audio/transcriber.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { TranscriptionResult } from './types';
|
||||
|
||||
export class Transcriber {
|
||||
constructor(private getSettings: () => AutoNotesSettings) {}
|
||||
|
||||
async transcribe(
|
||||
audioData: ArrayBuffer,
|
||||
fileName: string
|
||||
): Promise<TranscriptionResult> {
|
||||
const settings = this.getSettings().audio;
|
||||
|
||||
switch (settings.transcriptionProvider) {
|
||||
case 'whisper-api':
|
||||
return this.transcribeWhisperAPI(audioData, fileName);
|
||||
case 'deepgram':
|
||||
return this.transcribeDeepgram(audioData, fileName);
|
||||
case 'local-whisper':
|
||||
return this.transcribeLocalWhisper(audioData, fileName);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported transcription provider: ${settings.transcriptionProvider}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async transcribeWhisperAPI(
|
||||
audioData: ArrayBuffer,
|
||||
fileName: string
|
||||
): Promise<TranscriptionResult> {
|
||||
const settings = this.getSettings();
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
'file',
|
||||
new Blob([audioData]),
|
||||
fileName
|
||||
);
|
||||
formData.append('model', settings.audio.whisperModel);
|
||||
if (settings.audio.language) {
|
||||
formData.append('language', settings.audio.language);
|
||||
}
|
||||
formData.append('response_format', 'verbose_json');
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.openai.com/v1/audio/transcriptions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.ai.apiKey}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
raw: data.text,
|
||||
language: data.language,
|
||||
duration: data.duration,
|
||||
sourceName: fileName,
|
||||
timestamps: data.segments?.map(
|
||||
(s: { start: number; end: number; text: string }) => ({
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
text: s.text,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async transcribeDeepgram(
|
||||
audioData: ArrayBuffer,
|
||||
fileName: string
|
||||
): Promise<TranscriptionResult> {
|
||||
const settings = this.getSettings().audio;
|
||||
const params = new URLSearchParams({
|
||||
punctuate: 'true',
|
||||
paragraphs: 'true',
|
||||
});
|
||||
if (settings.language) {
|
||||
params.set('language', settings.language);
|
||||
}
|
||||
|
||||
const response = await requestUrl({
|
||||
url: `https://api.deepgram.com/v1/listen?${params}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${settings.deepgramApiKey}`,
|
||||
'Content-Type': 'audio/*',
|
||||
},
|
||||
body: audioData,
|
||||
});
|
||||
|
||||
const result = response.json.results.channels[0].alternatives[0];
|
||||
return {
|
||||
raw: result.transcript,
|
||||
language: response.json.results.channels[0].detected_language,
|
||||
sourceName: fileName,
|
||||
};
|
||||
}
|
||||
|
||||
private async transcribeLocalWhisper(
|
||||
_audioData: ArrayBuffer,
|
||||
fileName: string
|
||||
): Promise<TranscriptionResult> {
|
||||
// Local whisper requires writing to temp file and running CLI
|
||||
// This will be implemented via child_process in the video module pattern
|
||||
throw new Error(
|
||||
'Local Whisper transcription not yet implemented. ' +
|
||||
'Please use whisper-api or deepgram provider.'
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/audio/transcription-modal.ts
Normal file
71
src/audio/transcription-modal.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { App, Modal, Notice, Setting, TFile } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
|
||||
export class AudioTranscriptionModal extends Modal {
|
||||
private selectedFile: TFile | null = null;
|
||||
private onTranscribe: (file: TFile) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private getSettings: () => AutoNotesSettings,
|
||||
onTranscribe: (file: TFile) => Promise<void>
|
||||
) {
|
||||
super(app);
|
||||
this.onTranscribe = onTranscribe;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h2', { text: 'Transcribe Audio' });
|
||||
|
||||
// File selector
|
||||
const audioFiles = this.app.vault
|
||||
.getFiles()
|
||||
.filter((f) =>
|
||||
/\.(mp3|wav|m4a|ogg|flac|webm|aac)$/i.test(f.extension)
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Audio file')
|
||||
.setDesc('Select an audio file from your vault')
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown.addOption('', 'Select a file...');
|
||||
for (const file of audioFiles) {
|
||||
dropdown.addOption(file.path, file.path);
|
||||
}
|
||||
dropdown.onChange((value) => {
|
||||
this.selectedFile =
|
||||
this.app.vault.getAbstractFileByPath(value) as TFile | null;
|
||||
});
|
||||
});
|
||||
|
||||
// Post-processing info
|
||||
const settings = this.getSettings().audio.postProcessing;
|
||||
const ppStatus = settings.enabled
|
||||
? 'Enabled (configure in settings)'
|
||||
: 'Disabled';
|
||||
new Setting(contentEl)
|
||||
.setName('Post-processing')
|
||||
.setDesc(ppStatus);
|
||||
|
||||
// Transcribe button
|
||||
new Setting(contentEl).addButton((btn) => {
|
||||
btn.setButtonText('Transcribe')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
if (!this.selectedFile) {
|
||||
new Notice('Please select an audio file');
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
await this.onTranscribe(this.selectedFile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
20
src/audio/types.ts
Normal file
20
src/audio/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface TranscriptionResult {
|
||||
raw: string;
|
||||
processed?: string;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
sourceName: string;
|
||||
timestamps?: TimestampEntry[];
|
||||
}
|
||||
|
||||
export interface TimestampEntry {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TranscribeOptions {
|
||||
language?: string;
|
||||
postProcess?: boolean;
|
||||
sourceName?: string;
|
||||
}
|
||||
132
src/elaboration/detector.ts
Normal file
132
src/elaboration/detector.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { wordCount } from '../shared/file-utils';
|
||||
import { DetectionReason, DetectionResult } from './types';
|
||||
|
||||
export class PlaceholderDetector {
|
||||
constructor(
|
||||
private app: App,
|
||||
private getSettings: () => AutoNotesSettings
|
||||
) {}
|
||||
|
||||
async detect(file: TFile): Promise<DetectionResult | null> {
|
||||
const settings = this.getSettings().elaboration;
|
||||
const content = await this.app.vault.read(file);
|
||||
const reasons: DetectionReason[] = [];
|
||||
|
||||
if (this.isExcluded(file)) return null;
|
||||
|
||||
// Strip frontmatter for analysis
|
||||
const body = this.stripFrontmatter(content);
|
||||
|
||||
if (settings.detection.detectTodoMarkers) {
|
||||
const markers = this.findTodoMarkers(body);
|
||||
if (markers.length > 0) {
|
||||
reasons.push({ type: 'todo-marker', markers });
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.detection.detectEmptySections) {
|
||||
const emptySection = this.findEmptySection(body);
|
||||
if (emptySection) {
|
||||
reasons.push({ type: 'empty-section', heading: emptySection });
|
||||
}
|
||||
}
|
||||
|
||||
const wc = wordCount(body);
|
||||
if (wc < settings.detection.minWordThreshold) {
|
||||
reasons.push({ type: 'short-note', wordCount: wc });
|
||||
}
|
||||
|
||||
if (settings.detection.detectSparseLinks) {
|
||||
const linkedFrom = this.findInboundLinks(file);
|
||||
if (linkedFrom.length > 0 && wc < settings.detection.minWordThreshold) {
|
||||
reasons.push({ type: 'sparse-link', linkedFrom });
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.length === 0) return null;
|
||||
|
||||
return { notePath: file.path, reasons };
|
||||
}
|
||||
|
||||
private isExcluded(file: TFile): boolean {
|
||||
const settings = this.getSettings().elaboration;
|
||||
for (const folder of settings.detection.excludeFolders) {
|
||||
if (file.path.startsWith(folder + '/')) return true;
|
||||
}
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter?.tags) {
|
||||
const tags: string[] = Array.isArray(cache.frontmatter.tags)
|
||||
? cache.frontmatter.tags
|
||||
: [cache.frontmatter.tags];
|
||||
for (const tag of settings.detection.excludeTags) {
|
||||
if (tags.includes(tag)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private stripFrontmatter(content: string): string {
|
||||
const match = content.match(/^---\n[\s\S]*?\n---\n?/);
|
||||
return match ? content.slice(match[0].length) : content;
|
||||
}
|
||||
|
||||
private findTodoMarkers(body: string): string[] {
|
||||
const markers: string[] = [];
|
||||
const patterns = [/\bTODO\b/g, /\bTBD\b/g, /\bFIXME\b/g, /\bPLACEHOLDER\b/gi];
|
||||
for (const pattern of patterns) {
|
||||
const matches = body.match(pattern);
|
||||
if (matches) {
|
||||
markers.push(...matches);
|
||||
}
|
||||
}
|
||||
return [...new Set(markers)];
|
||||
}
|
||||
|
||||
private findEmptySection(body: string): string | null {
|
||||
const lines = body.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)/);
|
||||
if (!headingMatch) continue;
|
||||
|
||||
const headingLevel = headingMatch[1].length;
|
||||
const headingText = headingMatch[2];
|
||||
let hasContent = false;
|
||||
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const nextHeading = lines[j].match(/^(#{1,6})\s/);
|
||||
if (nextHeading && nextHeading[1].length <= headingLevel) break;
|
||||
if (lines[j].trim().length > 0) {
|
||||
hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent) return headingText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private findInboundLinks(file: TFile): string[] {
|
||||
const links: string[] = [];
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
for (const other of allFiles) {
|
||||
if (other.path === file.path) continue;
|
||||
const cache = this.app.metadataCache.getFileCache(other);
|
||||
if (cache?.links) {
|
||||
for (const link of cache.links) {
|
||||
const resolved = this.app.metadataCache.getFirstLinkpathDest(
|
||||
link.link,
|
||||
other.path
|
||||
);
|
||||
if (resolved?.path === file.path) {
|
||||
links.push(other.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
}
|
||||
202
src/elaboration/index.ts
Normal file
202
src/elaboration/index.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { Notice, Plugin, TFile } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { notifyError } from '../shared/api-utils';
|
||||
import { PlaceholderDetector } from './detector';
|
||||
import { ProposalDetailModal } from './proposal-modal';
|
||||
import { ProposalStore } from './proposal-store';
|
||||
import { PROPOSAL_VIEW_TYPE, ProposalReviewView } from './proposal-view';
|
||||
import { ProposalGenerator } from './proposer';
|
||||
|
||||
export class ElaborationModule {
|
||||
private detector: PlaceholderDetector;
|
||||
private proposer: ProposalGenerator;
|
||||
private store: ProposalStore;
|
||||
private scanInterval: number | null = null;
|
||||
|
||||
constructor(
|
||||
private plugin: Plugin,
|
||||
private getSettings: () => AutoNotesSettings
|
||||
) {
|
||||
this.detector = new PlaceholderDetector(plugin.app, getSettings);
|
||||
this.proposer = new ProposalGenerator(plugin.app, getSettings);
|
||||
this.store = new ProposalStore(plugin.app, getSettings);
|
||||
}
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.store.init();
|
||||
|
||||
this.plugin.registerView(PROPOSAL_VIEW_TYPE, (leaf) => {
|
||||
return new ProposalReviewView(leaf, {
|
||||
onAccept: (id) => this.acceptProposal(id),
|
||||
onReject: (id) => this.rejectProposal(id),
|
||||
onDetail: (id) => this.showProposalDetail(id),
|
||||
});
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:scan-vault',
|
||||
name: 'Scan vault for stub notes',
|
||||
callback: () => this.scanVault(),
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:scan-current-note',
|
||||
name: 'Scan current note for elaboration',
|
||||
editorCallback: async (_editor, ctx) => {
|
||||
if (ctx.file) {
|
||||
await this.scanNote(ctx.file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:review-proposals',
|
||||
name: 'Open proposal review sidebar',
|
||||
callback: () => this.activateProposalView(),
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:clear-proposals',
|
||||
name: 'Clear all pending proposals',
|
||||
callback: () => this.clearProposals(),
|
||||
});
|
||||
|
||||
const settings = this.getSettings().elaboration;
|
||||
if (settings.scanOnStartup) {
|
||||
// Delay startup scan to let vault index
|
||||
setTimeout(() => this.scanVault(), 5000);
|
||||
}
|
||||
|
||||
if (settings.autoScanInterval > 0) {
|
||||
this.scanInterval = window.setInterval(
|
||||
() => this.scanVault(),
|
||||
settings.autoScanInterval * 60 * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
if (this.scanInterval !== null) {
|
||||
window.clearInterval(this.scanInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async scanVault(): Promise<number> {
|
||||
try {
|
||||
new Notice('Auto Notes: Scanning vault...');
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
let proposalCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await this.detector.detect(file);
|
||||
if (result) {
|
||||
const proposal = await this.proposer.generate(result);
|
||||
await this.store.save(proposal);
|
||||
proposalCount++;
|
||||
}
|
||||
}
|
||||
|
||||
new Notice(`Auto Notes: Found ${proposalCount} notes to elaborate`);
|
||||
await this.refreshProposalView();
|
||||
return proposalCount;
|
||||
} catch (error) {
|
||||
notifyError('Vault scan failed', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async scanNote(file: TFile): Promise<void> {
|
||||
try {
|
||||
const result = await this.detector.detect(file);
|
||||
if (result) {
|
||||
const proposal = await this.proposer.generate(result);
|
||||
await this.store.save(proposal);
|
||||
new Notice('Auto Notes: Proposal generated');
|
||||
await this.refreshProposalView();
|
||||
} else {
|
||||
new Notice('Auto Notes: Note does not appear to be a stub');
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError('Note scan failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptProposal(id: string): Promise<void> {
|
||||
const proposal = await this.store.load(id);
|
||||
if (!proposal) return;
|
||||
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(proposal.sourceNotePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
|
||||
const content = await this.plugin.app.vault.read(file);
|
||||
const newContent = content + '\n\n' + proposal.proposedAdditions;
|
||||
await this.plugin.app.vault.modify(file, newContent);
|
||||
|
||||
await this.store.updateStatus(id, 'accepted');
|
||||
new Notice('Auto Notes: Proposal accepted');
|
||||
await this.refreshProposalView();
|
||||
}
|
||||
|
||||
async rejectProposal(id: string): Promise<void> {
|
||||
await this.store.updateStatus(id, 'rejected');
|
||||
new Notice('Auto Notes: Proposal rejected');
|
||||
await this.refreshProposalView();
|
||||
}
|
||||
|
||||
private async showProposalDetail(id: string): Promise<void> {
|
||||
const proposal = await this.store.load(id);
|
||||
if (!proposal) return;
|
||||
|
||||
new ProposalDetailModal(this.plugin.app, proposal, {
|
||||
onAccept: async (editedContent) => {
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(
|
||||
proposal.sourceNotePath
|
||||
);
|
||||
if (!(file instanceof TFile)) return;
|
||||
|
||||
const content = await this.plugin.app.vault.read(file);
|
||||
await this.plugin.app.vault.modify(
|
||||
file,
|
||||
content + '\n\n' + editedContent
|
||||
);
|
||||
await this.store.updateStatus(id, 'accepted');
|
||||
new Notice('Auto Notes: Proposal accepted');
|
||||
await this.refreshProposalView();
|
||||
},
|
||||
onReject: async () => {
|
||||
await this.rejectProposal(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async clearProposals(): Promise<void> {
|
||||
const pending = await this.store.loadPending();
|
||||
for (const p of pending) {
|
||||
await this.store.delete(p.id);
|
||||
}
|
||||
new Notice(`Auto Notes: Cleared ${pending.length} proposals`);
|
||||
await this.refreshProposalView();
|
||||
}
|
||||
|
||||
private async activateProposalView(): Promise<void> {
|
||||
const { workspace } = this.plugin.app;
|
||||
let leaf = workspace.getLeavesOfType(PROPOSAL_VIEW_TYPE)[0];
|
||||
if (!leaf) {
|
||||
const rightLeaf = workspace.getRightLeaf(false);
|
||||
if (!rightLeaf) return;
|
||||
leaf = rightLeaf;
|
||||
await leaf.setViewState({ type: PROPOSAL_VIEW_TYPE, active: true });
|
||||
}
|
||||
workspace.revealLeaf(leaf);
|
||||
await this.refreshProposalView();
|
||||
}
|
||||
|
||||
private async refreshProposalView(): Promise<void> {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(PROPOSAL_VIEW_TYPE);
|
||||
for (const leaf of leaves) {
|
||||
const view = leaf.view as ProposalReviewView;
|
||||
const proposals = await this.store.loadPending();
|
||||
view.setProposals(proposals);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/elaboration/proposal-modal.ts
Normal file
72
src/elaboration/proposal-modal.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import { Proposal } from './types';
|
||||
|
||||
export class ProposalDetailModal extends Modal {
|
||||
private onAccept: (content: string) => void;
|
||||
private onReject: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private proposal: Proposal,
|
||||
callbacks: {
|
||||
onAccept: (content: string) => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
) {
|
||||
super(app);
|
||||
this.onAccept = callbacks.onAccept;
|
||||
this.onReject = callbacks.onReject;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h2', { text: `Proposal for: ${this.proposal.sourceNotePath}` });
|
||||
|
||||
const reasons = this.proposal.detectionReasons
|
||||
.map(r => {
|
||||
switch (r.type) {
|
||||
case 'short-note': return `Short note (${r.wordCount} words)`;
|
||||
case 'todo-marker': return `TODO markers: ${r.markers.join(', ')}`;
|
||||
case 'empty-section': return `Empty section: "${r.heading}"`;
|
||||
case 'sparse-link': return `Linked from ${r.linkedFrom.length} notes`;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
contentEl.createEl('p', { text: `Detected: ${reasons}`, cls: 'auto-notes-detection-info' });
|
||||
|
||||
contentEl.createEl('h3', { text: 'Proposed Additions' });
|
||||
contentEl.createEl('p', { text: 'Edit the content below before accepting:' });
|
||||
|
||||
const textarea = contentEl.createEl('textarea', {
|
||||
cls: 'auto-notes-proposal-editor',
|
||||
});
|
||||
textarea.value = this.proposal.proposedAdditions;
|
||||
textarea.rows = 20;
|
||||
textarea.style.width = '100%';
|
||||
textarea.style.fontFamily = 'monospace';
|
||||
|
||||
const actions = contentEl.createDiv({ cls: 'auto-notes-modal-actions' });
|
||||
|
||||
const acceptBtn = actions.createEl('button', {
|
||||
text: 'Accept',
|
||||
cls: 'mod-cta',
|
||||
});
|
||||
acceptBtn.addEventListener('click', () => {
|
||||
this.onAccept(textarea.value);
|
||||
this.close();
|
||||
});
|
||||
|
||||
const rejectBtn = actions.createEl('button', { text: 'Reject' });
|
||||
rejectBtn.addEventListener('click', () => {
|
||||
this.onReject();
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
99
src/elaboration/proposal-store.ts
Normal file
99
src/elaboration/proposal-store.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { App, normalizePath } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { ensureFolder } from '../shared/file-utils';
|
||||
import { Proposal } from './types';
|
||||
|
||||
export class ProposalStore {
|
||||
constructor(
|
||||
private app: App,
|
||||
private getSettings: () => AutoNotesSettings
|
||||
) {}
|
||||
|
||||
private get folderPath(): string {
|
||||
return this.getSettings().elaboration.proposalFolderPath;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await ensureFolder(this.app, this.folderPath);
|
||||
}
|
||||
|
||||
async save(proposal: Proposal): Promise<void> {
|
||||
const fileName = this.proposalFileName(proposal);
|
||||
const path = normalizePath(`${this.folderPath}/${fileName}`);
|
||||
const content = JSON.stringify(proposal, null, 2);
|
||||
const existing = this.app.vault.getAbstractFileByPath(path);
|
||||
if (existing) {
|
||||
await this.app.vault.adapter.write(path, content);
|
||||
} else {
|
||||
await this.app.vault.create(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
async load(id: string): Promise<Proposal | null> {
|
||||
const files = await this.listProposalFiles();
|
||||
for (const filePath of files) {
|
||||
const content = await this.app.vault.adapter.read(filePath);
|
||||
const proposal: Proposal = JSON.parse(content);
|
||||
if (proposal.id === id) return proposal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadAll(): Promise<Proposal[]> {
|
||||
const files = await this.listProposalFiles();
|
||||
const proposals: Proposal[] = [];
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const content = await this.app.vault.adapter.read(filePath);
|
||||
proposals.push(JSON.parse(content));
|
||||
} catch {
|
||||
// Skip invalid proposal files
|
||||
}
|
||||
}
|
||||
return proposals;
|
||||
}
|
||||
|
||||
async loadPending(): Promise<Proposal[]> {
|
||||
const all = await this.loadAll();
|
||||
return all.filter(p => p.status === 'pending');
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const files = await this.listProposalFiles();
|
||||
for (const filePath of files) {
|
||||
const content = await this.app.vault.adapter.read(filePath);
|
||||
const proposal: Proposal = JSON.parse(content);
|
||||
if (proposal.id === id) {
|
||||
await this.app.vault.adapter.remove(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: Proposal['status']): Promise<void> {
|
||||
const proposal = await this.load(id);
|
||||
if (proposal) {
|
||||
proposal.status = status;
|
||||
await this.save(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
private proposalFileName(proposal: Proposal): string {
|
||||
const baseName = proposal.sourceNotePath
|
||||
.replace(/\.md$/, '')
|
||||
.replace(/\//g, '-');
|
||||
const shortId = proposal.id.slice(0, 8);
|
||||
return `${baseName}-${shortId}.json`;
|
||||
}
|
||||
|
||||
private async listProposalFiles(): Promise<string[]> {
|
||||
const folder = this.app.vault.getAbstractFileByPath(
|
||||
normalizePath(this.folderPath)
|
||||
);
|
||||
if (!folder) return [];
|
||||
const files = await this.app.vault.adapter.list(
|
||||
normalizePath(this.folderPath)
|
||||
);
|
||||
return files.files.filter(f => f.endsWith('.json'));
|
||||
}
|
||||
}
|
||||
105
src/elaboration/proposal-view.ts
Normal file
105
src/elaboration/proposal-view.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { ItemView, WorkspaceLeaf } from 'obsidian';
|
||||
import { Proposal } from './types';
|
||||
|
||||
export const PROPOSAL_VIEW_TYPE = 'auto-notes-proposal-review';
|
||||
|
||||
export class ProposalReviewView extends ItemView {
|
||||
private proposals: Proposal[] = [];
|
||||
private onAccept: (id: string) => Promise<void>;
|
||||
private onReject: (id: string) => Promise<void>;
|
||||
private onDetail: (id: string) => void;
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
callbacks: {
|
||||
onAccept: (id: string) => Promise<void>;
|
||||
onReject: (id: string) => Promise<void>;
|
||||
onDetail: (id: string) => void;
|
||||
}
|
||||
) {
|
||||
super(leaf);
|
||||
this.onAccept = callbacks.onAccept;
|
||||
this.onReject = callbacks.onReject;
|
||||
this.onDetail = callbacks.onDetail;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return PROPOSAL_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return 'Auto Notes Proposals';
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return 'sparkles';
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.render();
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
setProposals(proposals: Proposal[]): void {
|
||||
this.proposals = proposals;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h3', { text: 'Pending Proposals' });
|
||||
|
||||
const pending = this.proposals.filter(p => p.status === 'pending');
|
||||
if (pending.length === 0) {
|
||||
contentEl.createEl('p', {
|
||||
text: 'No pending proposals. Run "Scan vault" to find stub notes.',
|
||||
cls: 'auto-notes-empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by source note
|
||||
const grouped = new Map<string, Proposal[]>();
|
||||
for (const p of pending) {
|
||||
const existing = grouped.get(p.sourceNotePath) || [];
|
||||
existing.push(p);
|
||||
grouped.set(p.sourceNotePath, existing);
|
||||
}
|
||||
|
||||
for (const [notePath, noteProposals] of grouped) {
|
||||
const section = contentEl.createDiv({ cls: 'auto-notes-proposal-group' });
|
||||
section.createEl('h4', { text: notePath });
|
||||
|
||||
for (const proposal of noteProposals) {
|
||||
const card = section.createDiv({ cls: 'auto-notes-proposal-card' });
|
||||
|
||||
const reasons = proposal.detectionReasons
|
||||
.map(r => r.type)
|
||||
.join(', ');
|
||||
card.createEl('small', { text: reasons, cls: 'auto-notes-reasons' });
|
||||
|
||||
const preview = proposal.proposedAdditions.slice(0, 200);
|
||||
card.createEl('p', {
|
||||
text: preview + (proposal.proposedAdditions.length > 200 ? '...' : ''),
|
||||
cls: 'auto-notes-preview',
|
||||
});
|
||||
|
||||
const actions = card.createDiv({ cls: 'auto-notes-actions' });
|
||||
|
||||
const viewBtn = actions.createEl('button', { text: 'View' });
|
||||
viewBtn.addEventListener('click', () => this.onDetail(proposal.id));
|
||||
|
||||
const acceptBtn = actions.createEl('button', { text: 'Accept' });
|
||||
acceptBtn.addEventListener('click', () => this.onAccept(proposal.id));
|
||||
|
||||
const rejectBtn = actions.createEl('button', { text: 'Reject' });
|
||||
rejectBtn.addEventListener('click', () => this.onReject(proposal.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/elaboration/proposer.ts
Normal file
99
src/elaboration/proposer.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { App } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { AIClient } from '../shared/ai-client';
|
||||
import { DetectionResult, Proposal } from './types';
|
||||
|
||||
export class ProposalGenerator {
|
||||
private aiClient: AIClient;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private getSettings: () => AutoNotesSettings
|
||||
) {
|
||||
this.aiClient = new AIClient(getSettings);
|
||||
}
|
||||
|
||||
async generate(detection: DetectionResult): Promise<Proposal> {
|
||||
const content = await this.app.vault.adapter.read(detection.notePath);
|
||||
const settings = this.getSettings().elaboration;
|
||||
|
||||
let contextNotes = '';
|
||||
if (settings.proposal.includeSourceContext) {
|
||||
contextNotes = await this.gatherContext(detection.notePath);
|
||||
}
|
||||
|
||||
const prompt = this.buildPrompt(content, detection, contextNotes);
|
||||
const systemPrompt = `You are a note-taking assistant. Your job is to expand placeholder or stub notes into fuller, more useful content. Preserve the original voice and intent. Output only the proposed additions in markdown format.`;
|
||||
|
||||
const proposedAdditions = await this.aiClient.complete(prompt, systemPrompt);
|
||||
|
||||
return {
|
||||
id: this.generateId(),
|
||||
sourceNotePath: detection.notePath,
|
||||
createdAt: new Date().toISOString(),
|
||||
detectionReasons: detection.reasons,
|
||||
originalContent: content,
|
||||
proposedAdditions,
|
||||
insertionPoint: 'append',
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private buildPrompt(
|
||||
content: string,
|
||||
detection: DetectionResult,
|
||||
contextNotes: string
|
||||
): string {
|
||||
const reasonDescriptions = detection.reasons.map(r => {
|
||||
switch (r.type) {
|
||||
case 'short-note':
|
||||
return `Short note (${r.wordCount} words)`;
|
||||
case 'todo-marker':
|
||||
return `Contains TODO markers: ${r.markers.join(', ')}`;
|
||||
case 'empty-section':
|
||||
return `Empty section: "${r.heading}"`;
|
||||
case 'sparse-link':
|
||||
return `Linked from ${r.linkedFrom.length} notes but has sparse content`;
|
||||
}
|
||||
});
|
||||
|
||||
let prompt = `The following note appears to be a placeholder or stub:\n\n---\n${content}\n---\n\nReasons it was flagged:\n${reasonDescriptions.map(r => `- ${r}`).join('\n')}\n\nPlease propose additions that would flesh out this note.`;
|
||||
|
||||
if (contextNotes) {
|
||||
prompt += `\n\nContext from related notes:\n${contextNotes}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private async gatherContext(notePath: string): Promise<string> {
|
||||
const cache = this.app.metadataCache.getCache(notePath);
|
||||
if (!cache?.links) return '';
|
||||
|
||||
const contextParts: string[] = [];
|
||||
for (const link of cache.links.slice(0, 5)) {
|
||||
const resolved = this.app.metadataCache.getFirstLinkpathDest(
|
||||
link.link,
|
||||
notePath
|
||||
);
|
||||
if (resolved) {
|
||||
const content = await this.app.vault.read(resolved);
|
||||
contextParts.push(
|
||||
`### ${resolved.basename}\n${content.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return contextParts.join('\n\n');
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/elaboration/types.ts
Normal file
22
src/elaboration/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export type DetectionReason =
|
||||
| { type: 'short-note'; wordCount: number }
|
||||
| { type: 'todo-marker'; markers: string[] }
|
||||
| { type: 'empty-section'; heading: string }
|
||||
| { type: 'sparse-link'; linkedFrom: string[] };
|
||||
|
||||
export interface DetectionResult {
|
||||
notePath: string;
|
||||
reasons: DetectionReason[];
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
sourceNotePath: string;
|
||||
createdAt: string;
|
||||
detectionReasons: DetectionReason[];
|
||||
originalContent: string;
|
||||
proposedAdditions: string;
|
||||
insertionPoint: 'append' | 'after-heading' | 'replace-section';
|
||||
insertionTarget?: string;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
}
|
||||
114
src/main.ts
Normal file
114
src/main.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Plugin } from 'obsidian';
|
||||
import { AutoNotesSettings, DEFAULT_SETTINGS } from './settings';
|
||||
import { AutoNotesSettingTab } from './settings-tab';
|
||||
import { ElaborationModule } from './elaboration';
|
||||
import { AudioModule } from './audio';
|
||||
import { VideoModule } from './video';
|
||||
import { PROPOSAL_VIEW_TYPE } from './elaboration/proposal-view';
|
||||
import { AudioTranscriptionModal } from './audio/transcription-modal';
|
||||
|
||||
export default class AutoNotesPlugin extends Plugin {
|
||||
settings!: AutoNotesSettings;
|
||||
|
||||
private elaboration!: ElaborationModule;
|
||||
private audio!: AudioModule;
|
||||
private video!: VideoModule;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new AutoNotesSettingTab(this.app, this));
|
||||
|
||||
const getSettings = () => this.settings;
|
||||
|
||||
// Initialize modules (Audio before Video since Video depends on Audio)
|
||||
this.elaboration = new ElaborationModule(this, getSettings);
|
||||
this.audio = new AudioModule(this, getSettings);
|
||||
this.video = new VideoModule(this, getSettings, this.audio);
|
||||
|
||||
// Load enabled modules
|
||||
if (this.settings.elaboration.enabled) {
|
||||
await this.elaboration.onload();
|
||||
}
|
||||
if (this.settings.audio.enabled) {
|
||||
await this.audio.onload();
|
||||
}
|
||||
if (this.settings.video.enabled) {
|
||||
await this.video.onload();
|
||||
}
|
||||
|
||||
// Ribbon icons
|
||||
this.addRibbonIcon('sparkles', 'Review elaboration proposals', () => {
|
||||
this.activateProposalView();
|
||||
});
|
||||
|
||||
this.addRibbonIcon('mic', 'Transcribe audio', () => {
|
||||
new AudioTranscriptionModal(
|
||||
this.app,
|
||||
getSettings,
|
||||
async (file) => {
|
||||
const data = await this.app.vault.readBinary(file);
|
||||
const result = await this.audio.transcribe(data, file.name);
|
||||
await this.audio.saveTranscription(result);
|
||||
}
|
||||
).open();
|
||||
});
|
||||
|
||||
// Status bar
|
||||
this.addStatusBarItem().setText('Auto Notes: idle');
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
this.elaboration?.onunload();
|
||||
this.audio?.onunload();
|
||||
this.video?.onunload();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = this.deepMerge(
|
||||
DEFAULT_SETTINGS,
|
||||
(await this.loadData()) || {}
|
||||
);
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
private async activateProposalView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
let leaf = workspace.getLeavesOfType(PROPOSAL_VIEW_TYPE)[0];
|
||||
if (!leaf) {
|
||||
const rightLeaf = workspace.getRightLeaf(false);
|
||||
if (!rightLeaf) return;
|
||||
leaf = rightLeaf;
|
||||
await leaf.setViewState({
|
||||
type: PROPOSAL_VIEW_TYPE,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private deepMerge<T>(target: T, source: any): T {
|
||||
const output: any = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (
|
||||
source[key] &&
|
||||
typeof source[key] === 'object' &&
|
||||
!Array.isArray(source[key]) &&
|
||||
key in (target as any) &&
|
||||
typeof (target as any)[key] === 'object' &&
|
||||
!Array.isArray((target as any)[key])
|
||||
) {
|
||||
output[key] = this.deepMerge(
|
||||
(target as any)[key],
|
||||
source[key]
|
||||
);
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
}
|
||||
return output as T;
|
||||
}
|
||||
}
|
||||
292
src/settings-tab.ts
Normal file
292
src/settings-tab.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type AutoNotesPlugin from './main';
|
||||
|
||||
export class AutoNotesSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private plugin: AutoNotesPlugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// ── AI Configuration ──
|
||||
containerEl.createEl('h2', { text: 'AI Configuration' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('AI Provider')
|
||||
.setDesc('Which AI service to use for elaboration and post-processing')
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOptions({
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
ollama: 'Ollama (Local)',
|
||||
})
|
||||
.setValue(this.plugin.settings.ai.provider)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ai.provider = value as 'openai' | 'anthropic' | 'ollama';
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('API Key')
|
||||
.setDesc('API key for OpenAI or Anthropic')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('sk-...')
|
||||
.setValue(this.plugin.settings.ai.apiKey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ai.apiKey = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ollama Endpoint')
|
||||
.setDesc('URL for local Ollama server')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.ai.ollamaEndpoint)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ai.ollamaEndpoint = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Model')
|
||||
.setDesc('Model name (e.g., gpt-4o, claude-sonnet-4-20250514, llama3)')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.ai.model)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ai.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Temperature')
|
||||
.setDesc('Controls randomness (0-1)')
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(0, 1, 0.1)
|
||||
.setValue(this.plugin.settings.ai.temperature)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ai.temperature = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// ── Note Elaboration ──
|
||||
containerEl.createEl('h2', { text: 'Note Elaboration' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable elaboration')
|
||||
.setDesc('Enable stub note detection and proposal generation')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.elaboration.enabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.elaboration.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Minimum word threshold')
|
||||
.setDesc('Notes with fewer words than this are considered stubs')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(String(this.plugin.settings.elaboration.detection.minWordThreshold))
|
||||
.onChange(async (value) => {
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
this.plugin.settings.elaboration.detection.minWordThreshold = num;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Detect TODO markers')
|
||||
.setDesc('Flag notes containing TODO, TBD, FIXME, PLACEHOLDER')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.elaboration.detection.detectTodoMarkers)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.elaboration.detection.detectTodoMarkers = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Detect empty sections')
|
||||
.setDesc('Flag notes with headings but no content beneath them')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.elaboration.detection.detectEmptySections)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.elaboration.detection.detectEmptySections = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Excluded folders')
|
||||
.setDesc('Comma-separated list of folders to skip')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.elaboration.detection.excludeFolders.join(', '))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.elaboration.detection.excludeFolders =
|
||||
value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// ── Audio Transcription ──
|
||||
containerEl.createEl('h2', { text: 'Audio Transcription' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable audio transcription')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.audio.enabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Transcription provider')
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOptions({
|
||||
'whisper-api': 'OpenAI Whisper API',
|
||||
deepgram: 'Deepgram',
|
||||
'local-whisper': 'Local Whisper',
|
||||
})
|
||||
.setValue(this.plugin.settings.audio.transcriptionProvider)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.transcriptionProvider =
|
||||
value as 'whisper-api' | 'deepgram' | 'local-whisper';
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Deepgram API Key')
|
||||
.setDesc('Required if using Deepgram provider')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('dg-...')
|
||||
.setValue(this.plugin.settings.audio.deepgramApiKey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.deepgramApiKey = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Post-processing')
|
||||
.setDesc('Clean up and structure transcriptions with AI')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.audio.postProcessing.enabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.postProcessing.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Remove filler words')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.audio.postProcessing.removeFiller)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.postProcessing.removeFiller = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Output folder')
|
||||
.setDesc('Where to save transcription notes')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.audio.output.folder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.audio.output.folder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// ── Video Transcription ──
|
||||
containerEl.createEl('h2', { text: 'Video Transcription' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable video transcription')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.video.enabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.video.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('yt-dlp path')
|
||||
.setDesc('Path to yt-dlp binary')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.video.ytDlpPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.video.ytDlpPath = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('ffmpeg path')
|
||||
.setDesc('Path to ffmpeg binary')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.video.ffmpegPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.video.ffmpegPath = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Video output folder')
|
||||
.setDesc('Where to save video transcription notes')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(this.plugin.settings.video.output.folder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.video.output.folder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Include video metadata')
|
||||
.setDesc('Add title, channel, duration to transcription notes')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.video.output.includeVideoMetadata)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.video.output.includeVideoMetadata = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
161
src/settings.ts
Normal file
161
src/settings.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
export interface AISettings {
|
||||
provider: 'openai' | 'anthropic' | 'ollama';
|
||||
apiKey: string;
|
||||
ollamaEndpoint: string;
|
||||
model: string;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface DetectionSettings {
|
||||
minWordThreshold: number;
|
||||
detectTodoMarkers: boolean;
|
||||
detectEmptySections: boolean;
|
||||
detectSparseLinks: boolean;
|
||||
excludeFolders: string[];
|
||||
excludeTags: string[];
|
||||
}
|
||||
|
||||
export interface ProposalSettings {
|
||||
maxProposalsPerNote: number;
|
||||
preserveFrontmatter: boolean;
|
||||
includeSourceContext: boolean;
|
||||
}
|
||||
|
||||
export interface ElaborationSettings {
|
||||
enabled: boolean;
|
||||
proposalFolderPath: string;
|
||||
scanOnStartup: boolean;
|
||||
autoScanInterval: number;
|
||||
detection: DetectionSettings;
|
||||
proposal: ProposalSettings;
|
||||
}
|
||||
|
||||
export interface PostProcessingSettings {
|
||||
enabled: boolean;
|
||||
removeFiller: boolean;
|
||||
addStructure: boolean;
|
||||
extractKeyPoints: boolean;
|
||||
customPrompt: string;
|
||||
}
|
||||
|
||||
export interface AudioOutputSettings {
|
||||
folder: string;
|
||||
fileNameTemplate: string;
|
||||
appendToExisting: boolean;
|
||||
}
|
||||
|
||||
export interface AudioSettings {
|
||||
enabled: boolean;
|
||||
transcriptionProvider: 'whisper-api' | 'deepgram' | 'local-whisper';
|
||||
deepgramApiKey: string;
|
||||
whisperModel: string;
|
||||
localWhisperPath: string;
|
||||
language: string;
|
||||
postProcessing: PostProcessingSettings;
|
||||
output: AudioOutputSettings;
|
||||
}
|
||||
|
||||
export interface FrameExtractionSettings {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
visionModel: string;
|
||||
maxFrames: number;
|
||||
}
|
||||
|
||||
export interface VideoOutputSettings {
|
||||
folder: string;
|
||||
fileNameTemplate: string;
|
||||
includeVideoMetadata: boolean;
|
||||
}
|
||||
|
||||
export interface VideoSettings {
|
||||
enabled: boolean;
|
||||
ytDlpPath: string;
|
||||
ffmpegPath: string;
|
||||
tempFolder: string;
|
||||
supportedPlatforms: {
|
||||
youtube: boolean;
|
||||
tiktok: boolean;
|
||||
};
|
||||
frameExtraction: FrameExtractionSettings;
|
||||
output: VideoOutputSettings;
|
||||
}
|
||||
|
||||
export interface AutoNotesSettings {
|
||||
ai: AISettings;
|
||||
elaboration: ElaborationSettings;
|
||||
audio: AudioSettings;
|
||||
video: VideoSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AutoNotesSettings = {
|
||||
ai: {
|
||||
provider: 'openai',
|
||||
apiKey: '',
|
||||
ollamaEndpoint: 'http://localhost:11434',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.7,
|
||||
},
|
||||
elaboration: {
|
||||
enabled: true,
|
||||
proposalFolderPath: '.auto-notes/proposals',
|
||||
scanOnStartup: false,
|
||||
autoScanInterval: 0,
|
||||
detection: {
|
||||
minWordThreshold: 50,
|
||||
detectTodoMarkers: true,
|
||||
detectEmptySections: true,
|
||||
detectSparseLinks: true,
|
||||
excludeFolders: ['templates', '.auto-notes'],
|
||||
excludeTags: ['no-elaborate'],
|
||||
},
|
||||
proposal: {
|
||||
maxProposalsPerNote: 3,
|
||||
preserveFrontmatter: true,
|
||||
includeSourceContext: true,
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
enabled: true,
|
||||
transcriptionProvider: 'whisper-api',
|
||||
deepgramApiKey: '',
|
||||
whisperModel: 'whisper-1',
|
||||
localWhisperPath: '',
|
||||
language: '',
|
||||
postProcessing: {
|
||||
enabled: true,
|
||||
removeFiller: true,
|
||||
addStructure: true,
|
||||
extractKeyPoints: false,
|
||||
customPrompt: '',
|
||||
},
|
||||
output: {
|
||||
folder: 'Transcriptions',
|
||||
fileNameTemplate: '{{date}}-{{source}}',
|
||||
appendToExisting: false,
|
||||
},
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
ytDlpPath: 'yt-dlp',
|
||||
ffmpegPath: 'ffmpeg',
|
||||
tempFolder: '.auto-notes/temp',
|
||||
supportedPlatforms: {
|
||||
youtube: true,
|
||||
tiktok: true,
|
||||
},
|
||||
frameExtraction: {
|
||||
enabled: false,
|
||||
intervalSeconds: 30,
|
||||
visionModel: 'gpt-4o',
|
||||
maxFrames: 20,
|
||||
},
|
||||
output: {
|
||||
folder: 'Video Notes',
|
||||
fileNameTemplate: '{{date}}-{{title}}',
|
||||
includeVideoMetadata: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
100
src/shared/ai-client.ts
Normal file
100
src/shared/ai-client.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class AIClient {
|
||||
constructor(private getSettings: () => AutoNotesSettings) {}
|
||||
|
||||
async complete(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const messages: ChatMessage[] = [];
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
return this.chat(messages);
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[]): Promise<string> {
|
||||
const { ai } = this.getSettings();
|
||||
|
||||
switch (ai.provider) {
|
||||
case 'openai':
|
||||
return this.callOpenAI(messages);
|
||||
case 'anthropic':
|
||||
return this.callAnthropic(messages);
|
||||
case 'ollama':
|
||||
return this.callOllama(messages);
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${ai.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async callOpenAI(messages: ChatMessage[]): Promise<string> {
|
||||
const { ai } = this.getSettings();
|
||||
const response = await requestUrl({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${ai.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: ai.model,
|
||||
messages,
|
||||
max_tokens: ai.maxTokens,
|
||||
temperature: ai.temperature,
|
||||
}),
|
||||
});
|
||||
return response.json.choices[0].message.content;
|
||||
}
|
||||
|
||||
private async callAnthropic(messages: ChatMessage[]): Promise<string> {
|
||||
const { ai } = this.getSettings();
|
||||
const systemMsg = messages.find(m => m.role === 'system');
|
||||
const nonSystemMsgs = messages.filter(m => m.role !== 'system');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: ai.model,
|
||||
max_tokens: ai.maxTokens,
|
||||
temperature: ai.temperature,
|
||||
messages: nonSystemMsgs.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
if (systemMsg) {
|
||||
body.system = systemMsg.content;
|
||||
}
|
||||
|
||||
const response = await requestUrl({
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': ai.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return response.json.content[0].text;
|
||||
}
|
||||
|
||||
private async callOllama(messages: ChatMessage[]): Promise<string> {
|
||||
const { ai } = this.getSettings();
|
||||
const response = await requestUrl({
|
||||
url: `${ai.ollamaEndpoint}/api/chat`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ai.model,
|
||||
messages,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
return response.json.message.content;
|
||||
}
|
||||
}
|
||||
30
src/shared/api-utils.ts
Normal file
30
src/shared/api-utils.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Notice } from 'obsidian';
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries = 3,
|
||||
delayMs = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
await sleep(delayMs * Math.pow(2, attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function notifyError(context: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
new Notice(`Auto Notes: ${context} - ${message}`);
|
||||
console.error(`[Auto Notes] ${context}:`, error);
|
||||
}
|
||||
47
src/shared/file-utils.ts
Normal file
47
src/shared/file-utils.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { App, TFile, TFolder, normalizePath } from 'obsidian';
|
||||
|
||||
export async function ensureFolder(app: App, path: string): Promise<void> {
|
||||
const normalized = normalizePath(path);
|
||||
const existing = app.vault.getAbstractFileByPath(normalized);
|
||||
if (!existing) {
|
||||
await app.vault.createFolder(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNote(app: App, path: string): Promise<string | null> {
|
||||
const file = app.vault.getAbstractFileByPath(normalizePath(path));
|
||||
if (file instanceof TFile) {
|
||||
return app.vault.read(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function writeNote(
|
||||
app: App,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<TFile> {
|
||||
const normalized = normalizePath(path);
|
||||
const existing = app.vault.getAbstractFileByPath(normalized);
|
||||
if (existing instanceof TFile) {
|
||||
await app.vault.modify(existing, content);
|
||||
return existing;
|
||||
}
|
||||
// Ensure parent folder exists
|
||||
const parentPath = normalized.substring(0, normalized.lastIndexOf('/'));
|
||||
if (parentPath) {
|
||||
await ensureFolder(app, parentPath);
|
||||
}
|
||||
return app.vault.create(normalized, content);
|
||||
}
|
||||
|
||||
export function getMarkdownFiles(app: App, folder?: string): TFile[] {
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
if (!folder) return files;
|
||||
const normalized = normalizePath(folder);
|
||||
return files.filter(f => f.path.startsWith(normalized + '/'));
|
||||
}
|
||||
|
||||
export function wordCount(text: string): number {
|
||||
return text.split(/\s+/).filter(w => w.length > 0).length;
|
||||
}
|
||||
103
src/video/audio-extractor.ts
Normal file
103
src/video/audio-extractor.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { normalizePath } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { ExtractionResult, VideoMetadata } from './types';
|
||||
|
||||
// child_process is available in Obsidian's Electron environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { exec } = require('child_process') as typeof import('child_process');
|
||||
|
||||
export class AudioExtractor {
|
||||
constructor(private getSettings: () => AutoNotesSettings) {}
|
||||
|
||||
async extractFromUrl(url: string): Promise<ExtractionResult> {
|
||||
const settings = this.getSettings().video;
|
||||
const tempDir = settings.tempFolder;
|
||||
const outputPath = normalizePath(
|
||||
`${tempDir}/audio-${Date.now()}.mp3`
|
||||
);
|
||||
|
||||
// Get metadata first
|
||||
const metadata = await this.getMetadata(url);
|
||||
|
||||
// Download and extract audio
|
||||
await this.runCommand(
|
||||
`"${settings.ytDlpPath}" -x --audio-format mp3 ` +
|
||||
`-o "${outputPath}" "${url}"`
|
||||
);
|
||||
|
||||
return { audioPath: outputPath, metadata };
|
||||
}
|
||||
|
||||
async extractFromFile(filePath: string): Promise<ExtractionResult> {
|
||||
const settings = this.getSettings().video;
|
||||
const tempDir = settings.tempFolder;
|
||||
const outputPath = normalizePath(
|
||||
`${tempDir}/audio-${Date.now()}.mp3`
|
||||
);
|
||||
|
||||
await this.runCommand(
|
||||
`"${settings.ffmpegPath}" -i "${filePath}" -vn -acodec libmp3lame ` +
|
||||
`"${outputPath}"`
|
||||
);
|
||||
|
||||
const fileName = filePath.split('/').pop() || 'video';
|
||||
return {
|
||||
audioPath: outputPath,
|
||||
metadata: { title: fileName.replace(/\.[^.]+$/, '') },
|
||||
};
|
||||
}
|
||||
|
||||
async checkDependencies(): Promise<{
|
||||
ytDlp: boolean;
|
||||
ffmpeg: boolean;
|
||||
}> {
|
||||
const settings = this.getSettings().video;
|
||||
const [ytDlp, ffmpeg] = await Promise.all([
|
||||
this.commandExists(settings.ytDlpPath),
|
||||
this.commandExists(settings.ffmpegPath),
|
||||
]);
|
||||
return { ytDlp, ffmpeg };
|
||||
}
|
||||
|
||||
private async getMetadata(url: string): Promise<VideoMetadata> {
|
||||
const settings = this.getSettings().video;
|
||||
try {
|
||||
const output = await this.runCommand(
|
||||
`"${settings.ytDlpPath}" --dump-json --no-download "${url}"`
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
return {
|
||||
title: data.title || 'Untitled',
|
||||
channel: data.channel || data.uploader,
|
||||
duration: data.duration,
|
||||
uploadDate: data.upload_date,
|
||||
description: data.description?.slice(0, 500),
|
||||
platform: data.extractor_key,
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return { title: 'Untitled', url };
|
||||
}
|
||||
}
|
||||
|
||||
private runCommand(cmd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Command failed: ${error.message}\n${stderr}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
await this.runCommand(`which "${cmd}"`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/video/frame-extractor.ts
Normal file
23
src/video/frame-extractor.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { AutoNotesSettings } from '../settings';
|
||||
|
||||
// Stretch goal: frame extraction and vision analysis
|
||||
// This module is a placeholder for future implementation
|
||||
|
||||
export class FrameExtractor {
|
||||
constructor(private getSettings: () => AutoNotesSettings) {}
|
||||
|
||||
async extractFrames(_videoPath: string): Promise<string[]> {
|
||||
const settings = this.getSettings().video.frameExtraction;
|
||||
if (!settings.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Future implementation:
|
||||
// 1. Use ffmpeg to extract frames at settings.intervalSeconds intervals
|
||||
// 2. Cap at settings.maxFrames
|
||||
// 3. Send frames to vision model for analysis
|
||||
// 4. Return descriptions
|
||||
|
||||
throw new Error('Frame extraction is not yet implemented');
|
||||
}
|
||||
}
|
||||
152
src/video/index.ts
Normal file
152
src/video/index.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Notice, Plugin } from 'obsidian';
|
||||
import { AutoNotesSettings } from '../settings';
|
||||
import { AudioModule } from '../audio';
|
||||
import { notifyError } from '../shared/api-utils';
|
||||
import { ensureFolder, writeNote } from '../shared/file-utils';
|
||||
import { AudioExtractor } from './audio-extractor';
|
||||
import { TranscriptionResult } from '../audio/types';
|
||||
import { VideoMetadata, VideoProcessOptions } from './types';
|
||||
import { detectPlatform } from './url-detector';
|
||||
import { VideoTranscriptionModal } from './video-modal';
|
||||
|
||||
export class VideoModule {
|
||||
private extractor: AudioExtractor;
|
||||
|
||||
constructor(
|
||||
private plugin: Plugin,
|
||||
private getSettings: () => AutoNotesSettings,
|
||||
private audioModule: AudioModule
|
||||
) {
|
||||
this.extractor = new AudioExtractor(getSettings);
|
||||
}
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await ensureFolder(
|
||||
this.plugin.app,
|
||||
this.getSettings().video.tempFolder
|
||||
);
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:transcribe-video-url',
|
||||
name: 'Transcribe video from URL',
|
||||
callback: () => this.openUrlModal(),
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:transcribe-video-file',
|
||||
name: 'Transcribe local video file',
|
||||
callback: () => {
|
||||
// TODO: Implement file picker for video files
|
||||
new Notice('Auto Notes: Video file transcription coming soon');
|
||||
},
|
||||
});
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: 'auto-notes:check-dependencies',
|
||||
name: 'Check external tool availability',
|
||||
callback: () => this.checkDependencies(),
|
||||
});
|
||||
}
|
||||
|
||||
onunload(): void {}
|
||||
|
||||
async processUrl(
|
||||
url: string,
|
||||
options?: VideoProcessOptions
|
||||
): Promise<TranscriptionResult> {
|
||||
const detected = detectPlatform(url);
|
||||
const platform = detected?.platform || 'unknown';
|
||||
|
||||
new Notice(`Auto Notes: Downloading ${platform} video...`);
|
||||
const extraction = await this.extractor.extractFromUrl(url);
|
||||
|
||||
new Notice('Auto Notes: Extracting audio...');
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
const audioData = fs.readFileSync(extraction.audioPath);
|
||||
|
||||
new Notice('Auto Notes: Transcribing...');
|
||||
const result = await this.audioModule.transcribe(
|
||||
audioData.buffer as ArrayBuffer,
|
||||
extraction.metadata.title + '.mp3',
|
||||
{ sourceName: extraction.metadata.title }
|
||||
);
|
||||
|
||||
// Clean up temp file
|
||||
try {
|
||||
fs.unlinkSync(extraction.audioPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Save with metadata
|
||||
const outputPath = options?.outputPath || this.buildOutputPath(extraction.metadata);
|
||||
const content = this.formatVideoTranscription(result, extraction.metadata);
|
||||
await writeNote(this.plugin.app, outputPath, content);
|
||||
|
||||
new Notice(`Auto Notes: Transcription saved to ${outputPath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private openUrlModal(): void {
|
||||
new VideoTranscriptionModal(this.plugin.app, async (url) => {
|
||||
try {
|
||||
await this.processUrl(url);
|
||||
} catch (error) {
|
||||
notifyError('Video transcription failed', error);
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async checkDependencies(): Promise<void> {
|
||||
const deps = await this.extractor.checkDependencies();
|
||||
const lines: string[] = [];
|
||||
lines.push(`yt-dlp: ${deps.ytDlp ? 'Found' : 'NOT FOUND'}`);
|
||||
lines.push(`ffmpeg: ${deps.ffmpeg ? 'Found' : 'NOT FOUND'}`);
|
||||
|
||||
if (!deps.ytDlp || !deps.ffmpeg) {
|
||||
lines.push('');
|
||||
lines.push('Install missing tools:');
|
||||
if (!deps.ytDlp) lines.push(' brew install yt-dlp');
|
||||
if (!deps.ffmpeg) lines.push(' brew install ffmpeg');
|
||||
}
|
||||
|
||||
new Notice(lines.join('\n'), deps.ytDlp && deps.ffmpeg ? 5000 : 15000);
|
||||
}
|
||||
|
||||
private formatVideoTranscription(
|
||||
result: TranscriptionResult,
|
||||
metadata: VideoMetadata
|
||||
): string {
|
||||
const settings = this.getSettings().video.output;
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push('---');
|
||||
parts.push(`source: ${metadata.title}`);
|
||||
parts.push(`date: ${new Date().toISOString().split('T')[0]}`);
|
||||
if (settings.includeVideoMetadata) {
|
||||
if (metadata.platform) parts.push(`platform: ${metadata.platform}`);
|
||||
if (metadata.channel) parts.push(`channel: ${metadata.channel}`);
|
||||
if (metadata.duration) parts.push(`duration: ${Math.round(metadata.duration)}s`);
|
||||
if (metadata.url) parts.push(`url: "${metadata.url}"`);
|
||||
}
|
||||
parts.push('---');
|
||||
parts.push('');
|
||||
|
||||
const text = result.processed || result.raw;
|
||||
parts.push(text);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
private buildOutputPath(metadata: VideoMetadata): string {
|
||||
const settings = this.getSettings().video.output;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const title = (metadata.title || 'video')
|
||||
.replace(/[^a-zA-Z0-9-_ ]/g, '')
|
||||
.slice(0, 60);
|
||||
const fileName = settings.fileNameTemplate
|
||||
.replace('{{date}}', date)
|
||||
.replace('{{title}}', title);
|
||||
return `${settings.folder}/${fileName}.md`;
|
||||
}
|
||||
}
|
||||
30
src/video/types.ts
Normal file
30
src/video/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export interface VideoSource {
|
||||
type: 'url' | 'file';
|
||||
platform?: 'youtube' | 'tiktok' | 'unknown';
|
||||
url?: string;
|
||||
filePath?: string;
|
||||
title?: string;
|
||||
channel?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface VideoProcessOptions {
|
||||
postProcess?: boolean;
|
||||
extractFrames?: boolean;
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
audioPath: string;
|
||||
metadata: VideoMetadata;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title: string;
|
||||
channel?: string;
|
||||
duration?: number;
|
||||
uploadDate?: string;
|
||||
description?: string;
|
||||
platform?: string;
|
||||
url?: string;
|
||||
}
|
||||
30
src/video/url-detector.ts
Normal file
30
src/video/url-detector.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const YOUTUBE_REGEX =
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]+)/;
|
||||
const TIKTOK_REGEX =
|
||||
/tiktok\.com\/@[\w.-]+\/video\/(\d+)/;
|
||||
|
||||
export type Platform = 'youtube' | 'tiktok' | 'unknown';
|
||||
|
||||
export interface UrlDetectionResult {
|
||||
platform: Platform;
|
||||
videoId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function detectPlatform(url: string): UrlDetectionResult | null {
|
||||
const ytMatch = url.match(YOUTUBE_REGEX);
|
||||
if (ytMatch) {
|
||||
return { platform: 'youtube', videoId: ytMatch[1], url };
|
||||
}
|
||||
|
||||
const ttMatch = url.match(TIKTOK_REGEX);
|
||||
if (ttMatch) {
|
||||
return { platform: 'tiktok', videoId: ttMatch[1], url };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSupportedUrl(url: string): boolean {
|
||||
return detectPlatform(url) !== null;
|
||||
}
|
||||
67
src/video/video-modal.ts
Normal file
67
src/video/video-modal.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||
import { detectPlatform } from './url-detector';
|
||||
|
||||
export class VideoTranscriptionModal extends Modal {
|
||||
private url = '';
|
||||
private onTranscribe: (url: string) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
onTranscribe: (url: string) => Promise<void>
|
||||
) {
|
||||
super(app);
|
||||
this.onTranscribe = onTranscribe;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h2', { text: 'Transcribe Video from URL' });
|
||||
|
||||
const platformBadge = contentEl.createDiv({
|
||||
cls: 'auto-notes-platform-badge',
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Video URL')
|
||||
.setDesc('YouTube or TikTok URL')
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('https://youtube.com/watch?v=...');
|
||||
text.onChange((value) => {
|
||||
this.url = value;
|
||||
const detected = detectPlatform(value);
|
||||
if (detected) {
|
||||
platformBadge.setText(`Platform: ${detected.platform}`);
|
||||
platformBadge.show();
|
||||
} else if (value.length > 0) {
|
||||
platformBadge.setText('Unsupported URL');
|
||||
platformBadge.show();
|
||||
} else {
|
||||
platformBadge.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(contentEl).addButton((btn) => {
|
||||
btn.setButtonText('Transcribe')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
if (!this.url) {
|
||||
new Notice('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
if (!detectPlatform(this.url)) {
|
||||
new Notice('Unsupported URL. Please use YouTube or TikTok.');
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
await this.onTranscribe(this.url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
1
styles.css
Normal file
1
styles.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Auto Notes plugin styles */
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": ["DOM", "ES6", "ES2015.Promise", "ScriptHost", "ES2019"],
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue