Initial commit
Some checks failed
Node.js build / build (20.x) (push) Failing after 5m57s
Node.js build / build (22.x) (push) Failing after 5m52s

This commit is contained in:
2026-02-19 16:35:20 -08:00
parent dc2fa22c4d
commit 2936f7d359
18 changed files with 2727 additions and 93 deletions

74
.gitignore vendored
View File

@@ -1,12 +1,13 @@
# vscode
.vscode
.vscode-test
# Intellij
*.iml
.idea
# npm
node_modules
node_modules/
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
@@ -20,3 +21,74 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.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
View 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

View File

@@ -1,9 +1,9 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "todo-tracker",
"name": "Todo Tracker",
"version": "1.0.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",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",

1603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/node": "^20.0.0",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
@@ -21,7 +23,8 @@
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1"
"jiti": "2.6.1",
"vitest": "^3.0.0"
},
"dependencies": {
"obsidian": "latest"

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

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

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

View File

@@ -1,76 +1,64 @@
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian';
import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings";
import { Plugin, WorkspaceLeaf } from 'obsidian';
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 MyPlugin extends Plugin {
export default class TodoTrackerPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
// Register the todo sidebar view
this.registerView(TODO_VIEW_TYPE, (leaf: WorkspaceLeaf) => {
return new TodoSidebarView(leaf);
});
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status bar text');
// Add ribbon icon to open todo tracker
this.addRibbonIcon('check-square', 'Open Todo Tracker', () => {
this.activateView();
});
// This adds a simple command that can be triggered anywhere
// Add command to toggle todo tracker
this.addCommand({
id: 'open-modal-simple',
name: 'Open modal (simple)',
id: 'open-todo-tracker',
name: 'Open Todo Tracker',
callback: () => {
new SampleModal(this.app).open();
}
});
// 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.activateView();
},
});
// 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));
// 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() {
// 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() {
@@ -81,19 +69,3 @@ export default class MyPlugin extends Plugin {
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();
}
}

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

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

View File

@@ -1,5 +1,5 @@
import {App, PluginSettingTab, Setting} from "obsidian";
import MyPlugin from "./main";
import TodoTrackerPlugin from "./main";
export interface MyPluginSettings {
mySetting: string;
@@ -10,9 +10,9 @@ export const DEFAULT_SETTINGS: MyPluginSettings = {
}
export class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
plugin: TodoTrackerPlugin;
constructor(app: App, plugin: MyPlugin) {
constructor(app: App, plugin: TodoTrackerPlugin) {
super(app, plugin);
this.plugin = plugin;
}

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

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

View File

@@ -1,8 +1,77 @@
/*
/* Todo Tracker Styles */
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.todo-tracker-container {
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
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});