This commit is contained in:
Dustin Keeton 2026-03-12 16:55:45 -07:00
commit 0d9defb428
40 changed files with 3312 additions and 0 deletions

View 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.

View 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.

View 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
View file

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true
}
}

View 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)

View 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)

View 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
View file

@ -0,0 +1,4 @@
node_modules/
main.js
dist/
data.json

24
START.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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`;
}
}

View 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
View 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.'
);
}
}

View 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
View 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
View 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
View 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);
}
}
}

View 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();
}
}

View 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'));
}
}

View 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));
}
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;
}
}
}

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/* Auto Notes plugin styles */

20
tsconfig.json Normal file
View 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
View file

@ -0,0 +1,3 @@
{
"0.1.0": "0.15.0"
}