Initial commit
This commit is contained in:
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,12 +1,13 @@
|
|||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
# Intellij
|
# Intellij
|
||||||
*.iml
|
*.iml
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# npm
|
# npm
|
||||||
node_modules
|
node_modules/
|
||||||
|
|
||||||
# Don't include the compiled main.js file in the repo.
|
# Don't include the compiled main.js file in the repo.
|
||||||
# They should be uploaded to GitHub releases instead.
|
# They should be uploaded to GitHub releases instead.
|
||||||
@@ -20,3 +21,74 @@ data.json
|
|||||||
|
|
||||||
# Exclude macOS Finder (System Explorer) View States
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|||||||
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Type
|
||||||
|
|
||||||
|
This is an Obsidian community plugin written in TypeScript and bundled to JavaScript using esbuild.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development mode (watch mode, auto-recompiles on changes)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build (type checks, then bundles with minification)
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Lint code
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Entry Points & Build Process
|
||||||
|
- Source code lives in `src/`
|
||||||
|
- Main entry point: `src/main.ts` (plugin lifecycle management)
|
||||||
|
- Build target: `main.js` (bundled output at root)
|
||||||
|
- Bundler: **esbuild** (configured in `esbuild.config.mjs`)
|
||||||
|
- Required release artifacts: `main.js`, `manifest.json`, `styles.css` (if present)
|
||||||
|
|
||||||
|
### Code Organization Pattern
|
||||||
|
- **Keep `main.ts` minimal**: Only plugin lifecycle (onload, onunload, command registration)
|
||||||
|
- Delegate feature logic to separate modules
|
||||||
|
- Settings are defined in `src/settings.ts`
|
||||||
|
- Organize larger features into subdirectories:
|
||||||
|
- `commands/` for command implementations
|
||||||
|
- `ui/` for modals, views, and UI components
|
||||||
|
- `utils/` for helper functions
|
||||||
|
|
||||||
|
### Plugin Architecture
|
||||||
|
- Extends `Plugin` class from `obsidian` package
|
||||||
|
- Settings are loaded/saved using `this.loadData()` / `this.saveData()`
|
||||||
|
- All event listeners, intervals, and DOM events must use `this.register*()` helpers for proper cleanup
|
||||||
|
- Commands are registered via `this.addCommand()` with stable IDs
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
|
||||||
|
### Obsidian-Specific
|
||||||
|
- **Bundle everything**: No external runtime dependencies (except `obsidian`, `electron`, CodeMirror packages)
|
||||||
|
- **External packages**: Listed in `esbuild.config.mjs` external array; never bundle these
|
||||||
|
- **Mobile compatibility**: Avoid Node/Electron APIs unless `isDesktopOnly: true` in manifest
|
||||||
|
- **Network requests**: Default to offline; require explicit user consent for any external calls
|
||||||
|
- **No remote code execution**: Never fetch/eval scripts or auto-update outside normal releases
|
||||||
|
|
||||||
|
### Manifest (`manifest.json`)
|
||||||
|
- Never change `id` after initial release
|
||||||
|
- Use semantic versioning for `version`
|
||||||
|
- Keep `minAppVersion` accurate when using newer Obsidian APIs
|
||||||
|
- Update both `manifest.json` and `versions.json` when bumping versions
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Testing Locally
|
||||||
|
1. Build the plugin: `npm run dev` or `npm run build`
|
||||||
|
2. Copy `main.js`, `manifest.json`, `styles.css` to:
|
||||||
|
```
|
||||||
|
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||||
|
```
|
||||||
|
3. Reload Obsidian and enable plugin in Settings → Community plugins
|
||||||
|
|
||||||
|
### Type Checking
|
||||||
|
- TypeScript strict mode enabled in `tsconfig.json`
|
||||||
|
- Build command runs `tsc -noEmit -skipLibCheck` before bundling
|
||||||
|
- Fix type errors before considering build successful
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
|
- `src/main.ts` - Plugin class and lifecycle
|
||||||
|
- `src/settings.ts` - Settings interface, defaults, and settings tab UI
|
||||||
|
- `manifest.json` - Plugin metadata (never commit with wrong version)
|
||||||
|
- `esbuild.config.mjs` - Build configuration
|
||||||
|
- `eslint.config.mts` - ESLint configuration with Obsidian-specific rules
|
||||||
|
- `styles.css` - Optional plugin styles
|
||||||
|
|
||||||
|
## Code Quality Guidelines
|
||||||
|
|
||||||
|
From AGENTS.md and Obsidian best practices:
|
||||||
|
- Split files when they exceed ~200-300 lines
|
||||||
|
- Use clear module boundaries (single responsibility per file)
|
||||||
|
- Prefer `async/await` over promise chains
|
||||||
|
- Register all cleanup via `this.register*()` helpers to prevent memory leaks
|
||||||
|
- Keep startup lightweight; defer heavy work until needed
|
||||||
|
- Use stable command IDs (don't rename after release)
|
||||||
|
- Provide sensible defaults for all settings
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "sample-plugin",
|
"id": "todo-tracker",
|
||||||
"name": "Sample Plugin",
|
"name": "Todo Tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Demonstrates some of the capabilities of the Obsidian API.",
|
"description": "Track and manage todos from your notes in a sidebar panel. Toggle checkboxes and move todos between notes.",
|
||||||
"author": "Obsidian",
|
"author": "Obsidian",
|
||||||
"authorUrl": "https://obsidian.md",
|
"authorUrl": "https://obsidian.md",
|
||||||
"fundingUrl": "https://obsidian.md/pricing",
|
"fundingUrl": "https://obsidian.md/pricing",
|
||||||
|
|||||||
1603
package-lock.json
generated
1603
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,14 @@
|
|||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"license": "0-BSD",
|
"license": "0-BSD",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^20.0.0",
|
||||||
"esbuild": "0.25.5",
|
"esbuild": "0.25.5",
|
||||||
"eslint-plugin-obsidianmd": "0.1.9",
|
"eslint-plugin-obsidianmd": "0.1.9",
|
||||||
"globals": "14.0.0",
|
"globals": "14.0.0",
|
||||||
@@ -21,7 +23,8 @@
|
|||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "8.35.1",
|
"typescript-eslint": "8.35.1",
|
||||||
"@eslint/js": "9.30.1",
|
"@eslint/js": "9.30.1",
|
||||||
"jiti": "2.6.1"
|
"jiti": "2.6.1",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"obsidian": "latest"
|
"obsidian": "latest"
|
||||||
|
|||||||
124
src/core/todo-parser.test.ts
Normal file
124
src/core/todo-parser.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseTodos } from './todo-parser';
|
||||||
|
|
||||||
|
describe('parseTodos', () => {
|
||||||
|
it('should parse unchecked todos with dash marker', () => {
|
||||||
|
const content = '- [ ] Buy groceries';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]).toEqual({
|
||||||
|
id: 'line-0',
|
||||||
|
text: 'Buy groceries',
|
||||||
|
completed: false,
|
||||||
|
lineNumber: 0,
|
||||||
|
rawLine: '- [ ] Buy groceries',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse checked todos with lowercase x', () => {
|
||||||
|
const content = '- [x] Done task';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.completed).toBe(true);
|
||||||
|
expect(result.todos[0]?.text).toBe('Done task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse checked todos with uppercase X', () => {
|
||||||
|
const content = '- [X] Completed item';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple todos', () => {
|
||||||
|
const content = '- [ ] First\n- [x] Second\n- [ ] Third';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(3);
|
||||||
|
expect(result.todos[0]?.lineNumber).toBe(0);
|
||||||
|
expect(result.todos[1]?.lineNumber).toBe(1);
|
||||||
|
expect(result.todos[2]?.lineNumber).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle asterisk list marker', () => {
|
||||||
|
const content = '* [ ] Asterisk todo';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Asterisk todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle plus list marker', () => {
|
||||||
|
const content = '+ [ ] Plus todo';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Plus todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed content with non-todo lines', () => {
|
||||||
|
const content = '# Heading\nSome text\n- [ ] A todo\n- Regular list item\nMore text';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.lineNumber).toBe(2);
|
||||||
|
expect(result.todos[0]?.text).toBe('A todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle indented todos', () => {
|
||||||
|
const content = ' - [ ] Indented todo';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Indented todo');
|
||||||
|
expect(result.todos[0]?.rawLine).toBe(' - [ ] Indented todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tab-indented todos', () => {
|
||||||
|
const content = '\t- [ ] Tab indented';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Tab indented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for content with no todos', () => {
|
||||||
|
const content = '# Just a heading\nSome paragraph text.\n- Regular list item';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for empty content', () => {
|
||||||
|
const content = '';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle todos with extra whitespace after checkbox', () => {
|
||||||
|
const content = '- [ ] Extra spaces';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Extra spaces');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve full rawLine for each todo', () => {
|
||||||
|
const content = '- [ ] Simple task\n - [x] Nested done';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos[0]?.rawLine).toBe('- [ ] Simple task');
|
||||||
|
expect(result.todos[1]?.rawLine).toBe(' - [x] Nested done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique ids based on line numbers', () => {
|
||||||
|
const content = '- [ ] First\n\n- [ ] Second';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos[0]?.id).toBe('line-0');
|
||||||
|
expect(result.todos[1]?.id).toBe('line-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match checkboxes without proper spacing', () => {
|
||||||
|
const content = '-[ ] No space before bracket\n-[x]No space after';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle todos with special characters in text', () => {
|
||||||
|
const content = '- [ ] Task with `code` and **bold** and [link](url)';
|
||||||
|
const result = parseTodos(content);
|
||||||
|
expect(result.todos).toHaveLength(1);
|
||||||
|
expect(result.todos[0]?.text).toBe('Task with `code` and **bold** and [link](url)');
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/core/todo-parser.ts
Normal file
52
src/core/todo-parser.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ParseResult, TodoItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex pattern for markdown todo checkboxes.
|
||||||
|
* Matches: optional whitespace, list marker (-, *, +), space, checkbox [ ] or [x]/[X], space(s), text
|
||||||
|
*/
|
||||||
|
const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\]\s+)(.*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single line and return a TodoItem if it's a todo, null otherwise.
|
||||||
|
*/
|
||||||
|
export function parseTodoLine(line: string, lineNumber: number): TodoItem | null {
|
||||||
|
const match = line.match(TODO_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxContent = match[2];
|
||||||
|
const text = match[4]?.trim() ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `line-${lineNumber}`,
|
||||||
|
text,
|
||||||
|
completed: checkboxContent === 'x' || checkboxContent === 'X',
|
||||||
|
lineNumber,
|
||||||
|
rawLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content and extract all todo items.
|
||||||
|
*/
|
||||||
|
export function parseTodos(content: string): ParseResult {
|
||||||
|
if (!content) {
|
||||||
|
return { todos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const todos: TodoItem[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line === undefined) continue;
|
||||||
|
|
||||||
|
const todo = parseTodoLine(line, i);
|
||||||
|
if (todo) {
|
||||||
|
todos.push(todo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { todos };
|
||||||
|
}
|
||||||
183
src/core/todo-transformer.test.ts
Normal file
183
src/core/todo-transformer.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
toggleTodo,
|
||||||
|
removeTodoLine,
|
||||||
|
insertTodoAtEnd,
|
||||||
|
insertTodoUnderHeading,
|
||||||
|
} from './todo-transformer';
|
||||||
|
|
||||||
|
describe('toggleTodo', () => {
|
||||||
|
it('should check an unchecked todo', () => {
|
||||||
|
const content = '- [ ] Todo item';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('- [x] Todo item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should uncheck a checked todo with lowercase x', () => {
|
||||||
|
const content = '- [x] Done item';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Done item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should uncheck a checked todo with uppercase X', () => {
|
||||||
|
const content = '- [X] Done item';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Done item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve surrounding content', () => {
|
||||||
|
const content = 'Line 1\n- [ ] Todo\nLine 3';
|
||||||
|
const result = toggleTodo(content, 1);
|
||||||
|
expect(result).toBe('Line 1\n- [x] Todo\nLine 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle toggling first line', () => {
|
||||||
|
const content = '- [ ] First\nSecond line';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('- [x] First\nSecond line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle toggling last line', () => {
|
||||||
|
const content = 'First line\n- [ ] Last';
|
||||||
|
const result = toggleTodo(content, 1);
|
||||||
|
expect(result).toBe('First line\n- [x] Last');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve indentation when toggling', () => {
|
||||||
|
const content = ' - [ ] Indented';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe(' - [x] Indented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle asterisk marker', () => {
|
||||||
|
const content = '* [ ] Asterisk todo';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('* [x] Asterisk todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unchanged content if line is not a todo', () => {
|
||||||
|
const content = 'Not a todo\n- [ ] Real todo';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('Not a todo\n- [ ] Real todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single line content', () => {
|
||||||
|
const content = '- [x] Only line';
|
||||||
|
const result = toggleTodo(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Only line');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeTodoLine', () => {
|
||||||
|
it('should remove a line from the middle', () => {
|
||||||
|
const content = 'Line 1\n- [ ] Todo\nLine 3';
|
||||||
|
const result = removeTodoLine(content, 1);
|
||||||
|
expect(result).toBe('Line 1\nLine 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the first line', () => {
|
||||||
|
const content = '- [ ] First\nSecond\nThird';
|
||||||
|
const result = removeTodoLine(content, 0);
|
||||||
|
expect(result).toBe('Second\nThird');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the last line', () => {
|
||||||
|
const content = 'First\nSecond\n- [ ] Last';
|
||||||
|
const result = removeTodoLine(content, 2);
|
||||||
|
expect(result).toBe('First\nSecond');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single line content', () => {
|
||||||
|
const content = '- [ ] Only line';
|
||||||
|
const result = removeTodoLine(content, 0);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle removing from two-line content', () => {
|
||||||
|
const content = '- [ ] First\n- [ ] Second';
|
||||||
|
const result = removeTodoLine(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle out of bounds line number gracefully', () => {
|
||||||
|
const content = 'Line 1\nLine 2';
|
||||||
|
const result = removeTodoLine(content, 5);
|
||||||
|
expect(result).toBe('Line 1\nLine 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertTodoAtEnd', () => {
|
||||||
|
it('should append todo to end of file', () => {
|
||||||
|
const content = '# Heading\nContent';
|
||||||
|
const result = insertTodoAtEnd(content, '- [ ] New todo');
|
||||||
|
expect(result).toBe('# Heading\nContent\n- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty file', () => {
|
||||||
|
const content = '';
|
||||||
|
const result = insertTodoAtEnd(content, '- [ ] New todo');
|
||||||
|
expect(result).toBe('- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file ending with newline', () => {
|
||||||
|
const content = 'Content\n';
|
||||||
|
const result = insertTodoAtEnd(content, '- [ ] New todo');
|
||||||
|
expect(result).toBe('Content\n- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file ending with multiple newlines', () => {
|
||||||
|
const content = 'Content\n\n';
|
||||||
|
const result = insertTodoAtEnd(content, '- [ ] New todo');
|
||||||
|
expect(result).toBe('Content\n\n- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve indentation of the todo being inserted', () => {
|
||||||
|
const content = 'Content';
|
||||||
|
const result = insertTodoAtEnd(content, ' - [ ] Indented todo');
|
||||||
|
expect(result).toBe('Content\n - [ ] Indented todo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertTodoUnderHeading', () => {
|
||||||
|
it('should insert after heading when no next heading', () => {
|
||||||
|
const content = '# Heading\nExisting content';
|
||||||
|
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo');
|
||||||
|
expect(result).toBe('# Heading\n- [ ] New todo\nExisting content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert before next heading', () => {
|
||||||
|
const content = '# Heading 1\nContent 1\n# Heading 2\nContent 2';
|
||||||
|
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', 2);
|
||||||
|
expect(result).toBe('# Heading 1\nContent 1\n- [ ] New todo\n# Heading 2\nContent 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert at end of section when section is empty (back to back headings)', () => {
|
||||||
|
const content = '# Empty Section\n# Next Section';
|
||||||
|
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', 1);
|
||||||
|
expect(result).toBe('# Empty Section\n- [ ] New todo\n# Next Section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert after nested heading', () => {
|
||||||
|
const content = '# H1\n## H2\nContent\n# H1 again';
|
||||||
|
const result = insertTodoUnderHeading(content, 1, '- [ ] Under H2', 3);
|
||||||
|
expect(result).toBe('# H1\n## H2\nContent\n- [ ] Under H2\n# H1 again');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle heading at end of file', () => {
|
||||||
|
const content = 'Some content\n# Final heading';
|
||||||
|
const result = insertTodoUnderHeading(content, 1, '- [ ] New todo');
|
||||||
|
expect(result).toBe('Some content\n# Final heading\n- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file with only a heading', () => {
|
||||||
|
const content = '# Only heading';
|
||||||
|
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo');
|
||||||
|
expect(result).toBe('# Only heading\n- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve indentation of inserted todo', () => {
|
||||||
|
const content = '# Heading\nContent';
|
||||||
|
const result = insertTodoUnderHeading(content, 0, ' - [ ] Indented');
|
||||||
|
expect(result).toBe('# Heading\n - [ ] Indented\nContent');
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/core/todo-transformer.ts
Normal file
98
src/core/todo-transformer.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Regex pattern for markdown todo checkboxes (for toggling).
|
||||||
|
*/
|
||||||
|
const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\].*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the checkbox state at the specified line number.
|
||||||
|
* Returns the modified content.
|
||||||
|
*/
|
||||||
|
export function toggleTodo(content: string, lineNumber: number): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
if (lineNumber < 0 || lineNumber >= lines.length) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = lines[lineNumber];
|
||||||
|
if (line === undefined) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(TODO_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxContent = match[2];
|
||||||
|
const isChecked = checkboxContent === 'x' || checkboxContent === 'X';
|
||||||
|
const newCheckbox = isChecked ? ' ' : 'x';
|
||||||
|
|
||||||
|
lines[lineNumber] = `${match[1]}${newCheckbox}${match[3]}`;
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the line at the specified line number.
|
||||||
|
* Returns the modified content.
|
||||||
|
*/
|
||||||
|
export function removeTodoLine(content: string, lineNumber: number): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
if (lineNumber < 0 || lineNumber >= lines.length) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.splice(lineNumber, 1);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a todo line at the end of the content.
|
||||||
|
* Handles files with/without trailing newlines.
|
||||||
|
*/
|
||||||
|
export function insertTodoAtEnd(content: string, todoLine: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return todoLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If content ends with newline(s), append directly
|
||||||
|
if (content.endsWith('\n')) {
|
||||||
|
return content + todoLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise add a newline before the todo
|
||||||
|
return content + '\n' + todoLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a todo line under a specific heading.
|
||||||
|
* @param content - The file content
|
||||||
|
* @param headingLineNumber - The line number of the heading (0-based)
|
||||||
|
* @param todoLine - The todo line to insert
|
||||||
|
* @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary)
|
||||||
|
*/
|
||||||
|
export function insertTodoUnderHeading(
|
||||||
|
content: string,
|
||||||
|
headingLineNumber: number,
|
||||||
|
todoLine: string,
|
||||||
|
nextHeadingLineNumber?: number
|
||||||
|
): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Determine where to insert
|
||||||
|
let insertPosition: number;
|
||||||
|
|
||||||
|
if (nextHeadingLineNumber !== undefined) {
|
||||||
|
// Insert just before the next heading (at the end of this section)
|
||||||
|
insertPosition = nextHeadingLineNumber;
|
||||||
|
} else {
|
||||||
|
// No next heading - insert right after the heading line
|
||||||
|
insertPosition = headingLineNumber + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the todo line
|
||||||
|
lines.splice(insertPosition, 0, todoLine);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
34
src/core/types.ts
Normal file
34
src/core/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Represents a parsed to-do item from markdown
|
||||||
|
*/
|
||||||
|
export interface TodoItem {
|
||||||
|
/** Unique identifier based on line number */
|
||||||
|
id: string;
|
||||||
|
/** The text content after the checkbox */
|
||||||
|
text: string;
|
||||||
|
/** Whether the checkbox is checked */
|
||||||
|
completed: boolean;
|
||||||
|
/** 0-based line number in the source file */
|
||||||
|
lineNumber: number;
|
||||||
|
/** The full original line text (for accurate replacement) */
|
||||||
|
rawLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a note's content
|
||||||
|
*/
|
||||||
|
export interface ParseResult {
|
||||||
|
todos: TodoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a heading in a note
|
||||||
|
*/
|
||||||
|
export interface HeadingInfo {
|
||||||
|
/** The heading text without # symbols */
|
||||||
|
text: string;
|
||||||
|
/** Heading level (1-6) */
|
||||||
|
level: number;
|
||||||
|
/** 0-based line number */
|
||||||
|
lineNumber: number;
|
||||||
|
}
|
||||||
116
src/main.ts
116
src/main.ts
@@ -1,76 +1,64 @@
|
|||||||
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian';
|
import { Plugin, WorkspaceLeaf } from 'obsidian';
|
||||||
import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings";
|
import { DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab } from './settings';
|
||||||
|
import { TodoSidebarView, TODO_VIEW_TYPE } from './views/todo-sidebar-view';
|
||||||
|
|
||||||
// Remember to rename these classes and interfaces!
|
export default class TodoTrackerPlugin extends Plugin {
|
||||||
|
|
||||||
export default class MyPlugin extends Plugin {
|
|
||||||
settings: MyPluginSettings;
|
settings: MyPluginSettings;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
||||||
// This creates an icon in the left ribbon.
|
// Register the todo sidebar view
|
||||||
this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => {
|
this.registerView(TODO_VIEW_TYPE, (leaf: WorkspaceLeaf) => {
|
||||||
// Called when the user clicks the icon.
|
return new TodoSidebarView(leaf);
|
||||||
new Notice('This is a notice!');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
// Add ribbon icon to open todo tracker
|
||||||
const statusBarItemEl = this.addStatusBarItem();
|
this.addRibbonIcon('check-square', 'Open Todo Tracker', () => {
|
||||||
statusBarItemEl.setText('Status bar text');
|
this.activateView();
|
||||||
|
});
|
||||||
|
|
||||||
// This adds a simple command that can be triggered anywhere
|
// Add command to toggle todo tracker
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'open-modal-simple',
|
id: 'open-todo-tracker',
|
||||||
name: 'Open modal (simple)',
|
name: 'Open Todo Tracker',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
new SampleModal(this.app).open();
|
this.activateView();
|
||||||
}
|
},
|
||||||
});
|
|
||||||
// This adds an editor command that can perform some operation on the current editor instance
|
|
||||||
this.addCommand({
|
|
||||||
id: 'replace-selected',
|
|
||||||
name: 'Replace selected content',
|
|
||||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
|
||||||
editor.replaceSelection('Sample editor command');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
|
||||||
this.addCommand({
|
|
||||||
id: 'open-modal-complex',
|
|
||||||
name: 'Open modal (complex)',
|
|
||||||
checkCallback: (checking: boolean) => {
|
|
||||||
// Conditions to check
|
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
||||||
if (markdownView) {
|
|
||||||
// If checking is true, we're simply "checking" if the command can be run.
|
|
||||||
// If checking is false, then we want to actually perform the operation.
|
|
||||||
if (!checking) {
|
|
||||||
new SampleModal(this.app).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
// This command will only show up in Command Palette when the check function returns true
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
// Add settings tab
|
||||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||||
|
|
||||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
|
||||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
|
||||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
|
||||||
new Notice("Click");
|
|
||||||
});
|
|
||||||
|
|
||||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
|
||||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
|
// Detach all leaves of this view type to ensure clean unload
|
||||||
|
this.app.workspace.detachLeavesOfType(TODO_VIEW_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateView(): Promise<void> {
|
||||||
|
const { workspace } = this.app;
|
||||||
|
|
||||||
|
// Check if view already exists
|
||||||
|
const existingLeaves = workspace.getLeavesOfType(TODO_VIEW_TYPE);
|
||||||
|
if (existingLeaves.length > 0) {
|
||||||
|
// Reveal the existing view
|
||||||
|
const leaf = existingLeaves[0];
|
||||||
|
if (leaf) {
|
||||||
|
workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new leaf in right sidebar
|
||||||
|
const leaf = workspace.getRightLeaf(false);
|
||||||
|
if (leaf) {
|
||||||
|
await leaf.setViewState({
|
||||||
|
type: TODO_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
@@ -81,19 +69,3 @@ export default class MyPlugin extends Plugin {
|
|||||||
await this.saveData(this.settings);
|
await this.saveData(this.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SampleModal extends Modal {
|
|
||||||
constructor(app: App) {
|
|
||||||
super(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen() {
|
|
||||||
let {contentEl} = this;
|
|
||||||
contentEl.setText('Woah!');
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
const {contentEl} = this;
|
|
||||||
contentEl.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
92
src/modals/heading-select-modal.ts
Normal file
92
src/modals/heading-select-modal.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { App, FuzzySuggestModal, TFile, HeadingCache } from 'obsidian';
|
||||||
|
import type { TodoItem } from '../core/types';
|
||||||
|
import { removeTodoLine, insertTodoAtEnd, insertTodoUnderHeading } from '../core/todo-transformer';
|
||||||
|
|
||||||
|
interface HeadingOption {
|
||||||
|
heading: HeadingCache | null;
|
||||||
|
displayText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
|
||||||
|
private todo: TodoItem;
|
||||||
|
private sourceFile: TFile;
|
||||||
|
private targetFile: TFile;
|
||||||
|
private headings: HeadingCache[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
todo: TodoItem,
|
||||||
|
sourceFile: TFile,
|
||||||
|
targetFile: TFile,
|
||||||
|
headings: HeadingCache[]
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.todo = todo;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.targetFile = targetFile;
|
||||||
|
this.headings = headings;
|
||||||
|
this.setPlaceholder('Select a heading (or end of file)...');
|
||||||
|
}
|
||||||
|
|
||||||
|
getItems(): HeadingOption[] {
|
||||||
|
const items: HeadingOption[] = [];
|
||||||
|
|
||||||
|
// Add "End of file" option first
|
||||||
|
items.push({
|
||||||
|
heading: null,
|
||||||
|
displayText: 'End of file',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all headings
|
||||||
|
for (const heading of this.headings) {
|
||||||
|
const indent = ' '.repeat(heading.level - 1);
|
||||||
|
items.push({
|
||||||
|
heading,
|
||||||
|
displayText: `${indent}${'#'.repeat(heading.level)} ${heading.heading}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemText(option: HeadingOption): string {
|
||||||
|
return option.displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onChooseItem(option: HeadingOption): Promise<void> {
|
||||||
|
await this.moveTodo(option.heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async moveTodo(selectedHeading: HeadingCache | null): Promise<void> {
|
||||||
|
const todoLine = this.todo.rawLine;
|
||||||
|
|
||||||
|
// Remove from source file
|
||||||
|
await this.app.vault.process(this.sourceFile, (content) => {
|
||||||
|
return removeTodoLine(content, this.todo.lineNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to target file
|
||||||
|
if (selectedHeading) {
|
||||||
|
// Find the next heading (if any) to determine section boundary
|
||||||
|
const headingIndex = this.headings.findIndex(
|
||||||
|
(h) => h.position.start.line === selectedHeading.position.start.line
|
||||||
|
);
|
||||||
|
const nextHeading = this.headings[headingIndex + 1];
|
||||||
|
const nextHeadingLine = nextHeading?.position.start.line;
|
||||||
|
|
||||||
|
await this.app.vault.process(this.targetFile, (content) => {
|
||||||
|
return insertTodoUnderHeading(
|
||||||
|
content,
|
||||||
|
selectedHeading.position.start.line,
|
||||||
|
todoLine,
|
||||||
|
nextHeadingLine
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// End of file
|
||||||
|
await this.app.vault.process(this.targetFile, (content) => {
|
||||||
|
return insertTodoAtEnd(content, todoLine);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/modals/note-select-modal.ts
Normal file
65
src/modals/note-select-modal.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { App, FuzzySuggestModal, TFile } from 'obsidian';
|
||||||
|
import type { TodoItem } from '../core/types';
|
||||||
|
import { HeadingSelectModal } from './heading-select-modal';
|
||||||
|
import { removeTodoLine, insertTodoAtEnd } from '../core/todo-transformer';
|
||||||
|
|
||||||
|
export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
||||||
|
private todo: TodoItem;
|
||||||
|
private sourceFile: TFile;
|
||||||
|
|
||||||
|
constructor(app: App, todo: TodoItem, sourceFile: TFile) {
|
||||||
|
super(app);
|
||||||
|
this.todo = todo;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.setPlaceholder('Select a note to move the todo to...');
|
||||||
|
}
|
||||||
|
|
||||||
|
getItems(): TFile[] {
|
||||||
|
return this.app.vault.getMarkdownFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemText(file: TFile): string {
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onChooseItem(file: TFile): Promise<void> {
|
||||||
|
// Get headings from the target file
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const headings = fileCache?.headings ?? [];
|
||||||
|
|
||||||
|
if (headings.length > 0) {
|
||||||
|
// Show heading selection modal
|
||||||
|
new HeadingSelectModal(
|
||||||
|
this.app,
|
||||||
|
this.todo,
|
||||||
|
this.sourceFile,
|
||||||
|
file,
|
||||||
|
headings
|
||||||
|
).open();
|
||||||
|
} else {
|
||||||
|
// No headings - move directly to end of file
|
||||||
|
await this.moveTodo(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async moveTodo(targetFile: TFile, headingLineNumber?: number, nextHeadingLineNumber?: number): Promise<void> {
|
||||||
|
const todoLine = this.todo.rawLine;
|
||||||
|
|
||||||
|
// Remove from source file
|
||||||
|
await this.app.vault.process(this.sourceFile, (content) => {
|
||||||
|
return removeTodoLine(content, this.todo.lineNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to target file
|
||||||
|
if (headingLineNumber !== undefined) {
|
||||||
|
const { insertTodoUnderHeading } = await import('../core/todo-transformer');
|
||||||
|
await this.app.vault.process(targetFile, (content) => {
|
||||||
|
return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.app.vault.process(targetFile, (content) => {
|
||||||
|
return insertTodoAtEnd(content, todoLine);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {App, PluginSettingTab, Setting} from "obsidian";
|
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||||
import MyPlugin from "./main";
|
import TodoTrackerPlugin from "./main";
|
||||||
|
|
||||||
export interface MyPluginSettings {
|
export interface MyPluginSettings {
|
||||||
mySetting: string;
|
mySetting: string;
|
||||||
@@ -10,9 +10,9 @@ export const DEFAULT_SETTINGS: MyPluginSettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SampleSettingTab extends PluginSettingTab {
|
export class SampleSettingTab extends PluginSettingTab {
|
||||||
plugin: MyPlugin;
|
plugin: TodoTrackerPlugin;
|
||||||
|
|
||||||
constructor(app: App, plugin: MyPlugin) {
|
constructor(app: App, plugin: TodoTrackerPlugin) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/views/todo-item-component.ts
Normal file
55
src/views/todo-item-component.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Menu, setIcon } from 'obsidian';
|
||||||
|
import type { TodoItem } from '../core/types';
|
||||||
|
|
||||||
|
export interface TodoItemCallbacks {
|
||||||
|
onToggle: () => void;
|
||||||
|
onMoveClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a DOM element for a todo item with checkbox and menu.
|
||||||
|
*/
|
||||||
|
export function createTodoItemEl(
|
||||||
|
container: HTMLElement,
|
||||||
|
todo: TodoItem,
|
||||||
|
callbacks: TodoItemCallbacks
|
||||||
|
): HTMLElement {
|
||||||
|
const itemEl = container.createEl('li', { cls: 'todo-tracker-item' });
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
const checkboxEl = itemEl.createEl('input', { type: 'checkbox' });
|
||||||
|
checkboxEl.checked = todo.completed;
|
||||||
|
checkboxEl.addEventListener('change', () => {
|
||||||
|
callbacks.onToggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
const textEl = itemEl.createEl('span', { cls: 'todo-tracker-item-text' });
|
||||||
|
textEl.setText(todo.text);
|
||||||
|
if (todo.completed) {
|
||||||
|
textEl.addClass('todo-tracker-item-completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu button (ellipsis)
|
||||||
|
const menuBtn = itemEl.createEl('button', { cls: 'todo-tracker-menu-btn' });
|
||||||
|
setIcon(menuBtn, 'more-vertical');
|
||||||
|
menuBtn.setAttribute('aria-label', 'Todo options');
|
||||||
|
|
||||||
|
menuBtn.addEventListener('click', (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
const menu = new Menu();
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle('Move to...')
|
||||||
|
.setIcon('arrow-right')
|
||||||
|
.onClick(() => {
|
||||||
|
callbacks.onMoveClick();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.showAtMouseEvent(evt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return itemEl;
|
||||||
|
}
|
||||||
117
src/views/todo-sidebar-view.ts
Normal file
117
src/views/todo-sidebar-view.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { ItemView, TFile, WorkspaceLeaf } from 'obsidian';
|
||||||
|
import type { TodoItem } from '../core/types';
|
||||||
|
import { parseTodos } from '../core/todo-parser';
|
||||||
|
import { toggleTodo } from '../core/todo-transformer';
|
||||||
|
import { createTodoItemEl } from './todo-item-component';
|
||||||
|
|
||||||
|
export const TODO_VIEW_TYPE = 'todo-tracker-view';
|
||||||
|
|
||||||
|
export class TodoSidebarView extends ItemView {
|
||||||
|
private currentFile: TFile | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
|
super(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return TODO_VIEW_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return 'Todo Tracker';
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return 'check-square';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
|
// Listen for active leaf changes
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on('active-leaf-change', () => {
|
||||||
|
this.refresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for file modifications
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on('modify', (file) => {
|
||||||
|
if (this.currentFile && file.path === this.currentFile.path) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClose(): Promise<void> {
|
||||||
|
// Cleanup is handled automatically by registerEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
|
||||||
|
// Check if it's a markdown file
|
||||||
|
if (!activeFile || activeFile.extension !== 'md') {
|
||||||
|
this.currentFile = null;
|
||||||
|
this.renderEmpty('Open a markdown file to see its todos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentFile = activeFile;
|
||||||
|
|
||||||
|
const content = await this.app.vault.read(activeFile);
|
||||||
|
const { todos } = parseTodos(content);
|
||||||
|
|
||||||
|
if (todos.length === 0) {
|
||||||
|
this.renderEmpty('No todos in this note');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderTodos(todos, activeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmpty(message: string): void {
|
||||||
|
const container = this.contentEl;
|
||||||
|
container.empty();
|
||||||
|
container.addClass('todo-tracker-container');
|
||||||
|
|
||||||
|
const emptyEl = container.createDiv({ cls: 'todo-tracker-empty' });
|
||||||
|
emptyEl.setText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTodos(todos: TodoItem[], file: TFile): void {
|
||||||
|
const container = this.contentEl;
|
||||||
|
container.empty();
|
||||||
|
container.addClass('todo-tracker-container');
|
||||||
|
|
||||||
|
// Header with file name
|
||||||
|
const headerEl = container.createDiv({ cls: 'todo-tracker-header' });
|
||||||
|
headerEl.setText(file.basename);
|
||||||
|
|
||||||
|
// Todo list
|
||||||
|
const listEl = container.createEl('ul', { cls: 'todo-tracker-list' });
|
||||||
|
|
||||||
|
for (const todo of todos) {
|
||||||
|
const itemEl = createTodoItemEl(listEl, todo, {
|
||||||
|
onToggle: () => this.handleToggle(file, todo),
|
||||||
|
onMoveClick: () => this.handleMoveClick(todo, file),
|
||||||
|
});
|
||||||
|
listEl.appendChild(itemEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleToggle(file: TFile, todo: TodoItem): Promise<void> {
|
||||||
|
await this.app.vault.process(file, (content) => {
|
||||||
|
return toggleTodo(content, todo.lineNumber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMoveClick(todo: TodoItem, file: TFile): void {
|
||||||
|
// Import dynamically to avoid circular dependencies
|
||||||
|
import('../modals/note-select-modal').then(({ NoteSelectModal }) => {
|
||||||
|
new NoteSelectModal(this.app, todo, file).open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
79
styles.css
79
styles.css
@@ -1,8 +1,77 @@
|
|||||||
/*
|
/* Todo Tracker Styles */
|
||||||
|
|
||||||
This CSS file will be included with your plugin, and
|
.todo-tracker-container {
|
||||||
available in the app when your plugin is enabled.
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
If your plugin does not need CSS, delete this file.
|
.todo-tracker-header {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
*/
|
.todo-tracker-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item input[type="checkbox"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item-completed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-menu-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-item:hover .todo-tracker-menu-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tracker-menu-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user