mirror of
https://github.com/brendan-ch/todo-tracker-obsidian.git
synced 2026-04-19 08:10:29 +00:00
Compare commits
4 Commits
0a8f333698
...
33aaec15e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 33aaec15e2 | |||
| 17ce7efbd1 | |||
| 0b2e0ce1b6 | |||
| 2291bdfe3b |
@@ -22,6 +22,14 @@ npm run build
|
|||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
After every code change, run **both**:
|
||||||
|
1. `npm run build` — type-checks and bundles
|
||||||
|
2. `npm run lint` — ESLint (Obsidian-specific rules + TypeScript strict checks)
|
||||||
|
|
||||||
|
Fix all lint errors before considering a change complete.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Entry Points & Build Process
|
### Entry Points & Build Process
|
||||||
|
|||||||
25
src/main.ts
25
src/main.ts
@@ -1,9 +1,9 @@
|
|||||||
import { Plugin, WorkspaceLeaf } from 'obsidian';
|
import { Plugin, WorkspaceLeaf } from 'obsidian';
|
||||||
import { DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab } from './settings';
|
import { DEFAULT_SETTINGS, TodoTrackerSettings, TodoTrackerSettingTab } from './settings';
|
||||||
import { TodoSidebarView, TODO_VIEW_TYPE } from './views/todo-sidebar-view';
|
import { TodoSidebarView, TODO_VIEW_TYPE } from './views/todo-sidebar-view';
|
||||||
|
|
||||||
export default class TodoTrackerPlugin extends Plugin {
|
export default class TodoTrackerPlugin extends Plugin {
|
||||||
settings: MyPluginSettings;
|
settings: TodoTrackerSettings;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
@@ -14,26 +14,25 @@ export default class TodoTrackerPlugin extends Plugin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add ribbon icon to open todo tracker
|
// Add ribbon icon to open todo tracker
|
||||||
this.addRibbonIcon('check-square', 'Open Todo Tracker', () => {
|
this.addRibbonIcon('check-square', 'Open todo tracker', () => {
|
||||||
this.activateView();
|
void this.activateView();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add command to toggle todo tracker
|
// Add command to toggle todo tracker
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'open-todo-tracker',
|
id: 'open',
|
||||||
name: 'Open Todo Tracker',
|
name: 'Open sidebar',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
this.activateView();
|
void this.activateView();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add settings tab
|
// Add settings tab
|
||||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
this.addSettingTab(new TodoTrackerSettingTab(this.app, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
// Detach all leaves of this view type to ensure clean unload
|
// Cleanup is handled automatically by registered events
|
||||||
this.app.workspace.detachLeavesOfType(TODO_VIEW_TYPE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateView(): Promise<void> {
|
async activateView(): Promise<void> {
|
||||||
@@ -45,7 +44,7 @@ export default class TodoTrackerPlugin extends Plugin {
|
|||||||
// Reveal the existing view
|
// Reveal the existing view
|
||||||
const leaf = existingLeaves[0];
|
const leaf = existingLeaves[0];
|
||||||
if (leaf) {
|
if (leaf) {
|
||||||
workspace.revealLeaf(leaf);
|
await workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,12 +56,12 @@ export default class TodoTrackerPlugin extends Plugin {
|
|||||||
type: TODO_VIEW_TYPE,
|
type: TODO_VIEW_TYPE,
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
workspace.revealLeaf(leaf);
|
await workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<MyPluginSettings>);
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<TodoTrackerSettings>);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
|||||||
102
src/modals/daily-note-select-modal.ts
Normal file
102
src/modals/daily-note-select-modal.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { App, Modal, Notice, Setting, TFile } from 'obsidian';
|
||||||
|
import type { TodoItem } from '../core/types';
|
||||||
|
import { HeadingSelectModal } from './heading-select-modal';
|
||||||
|
import {
|
||||||
|
removeTodoBlock,
|
||||||
|
insertTodoAtBeginning,
|
||||||
|
collectTodoBlockLines,
|
||||||
|
} from '../core/todo-transformer';
|
||||||
|
|
||||||
|
export class DailyNoteSelectModal extends Modal {
|
||||||
|
private todo: TodoItem;
|
||||||
|
private sourceFile: TFile;
|
||||||
|
private selectedDate = '';
|
||||||
|
|
||||||
|
constructor(app: App, todo: TodoItem, sourceFile: TFile) {
|
||||||
|
super(app);
|
||||||
|
this.todo = todo;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.setTitle('Select a daily note');
|
||||||
|
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setName('Date')
|
||||||
|
.addText((text) => {
|
||||||
|
text.inputEl.type = 'date';
|
||||||
|
text.onChange((value) => {
|
||||||
|
this.selectedDate = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText('Submit')
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onSubmit();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSubmit(): void {
|
||||||
|
if (!this.selectedDate) {
|
||||||
|
new Notice('Please select a date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetFile = this.findDailyNote(this.selectedDate);
|
||||||
|
if (!targetFile) {
|
||||||
|
new Notice(`No daily note found for ${this.selectedDate}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
this.openTargetFile(targetFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findDailyNote(dateString: string): TFile | null {
|
||||||
|
interface DailyNotesOptions { format?: string; folder?: string; }
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||||
|
const options = ((this.app as any).internalPlugins?.plugins?.['daily-notes']?.instance?.options ?? {}) as DailyNotesOptions;
|
||||||
|
const format = options.format ?? 'YYYY-MM-DD';
|
||||||
|
const folder = options.folder ?? '';
|
||||||
|
|
||||||
|
const fileName = window.moment(dateString).format(format);
|
||||||
|
const filePath = folder ? `${folder}/${fileName}.md` : `${fileName}.md`;
|
||||||
|
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||||
|
return file instanceof TFile ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private openTargetFile(targetFile: TFile): void {
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(targetFile);
|
||||||
|
const headings = fileCache?.headings ?? [];
|
||||||
|
|
||||||
|
if (headings.length > 0) {
|
||||||
|
new HeadingSelectModal(
|
||||||
|
this.app,
|
||||||
|
this.todo,
|
||||||
|
this.sourceFile,
|
||||||
|
targetFile,
|
||||||
|
headings
|
||||||
|
).open();
|
||||||
|
} else {
|
||||||
|
void this.moveTodo(targetFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async moveTodo(targetFile: TFile): Promise<void> {
|
||||||
|
let blockLines: string[] = [];
|
||||||
|
|
||||||
|
await this.app.vault.process(this.sourceFile, (content) => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const blockLineNumbers = [this.todo.lineNumber, ...collectTodoBlockLines(content, this.todo.lineNumber)];
|
||||||
|
blockLines = blockLineNumbers.map((ln) => lines[ln]!);
|
||||||
|
return removeTodoBlock(content, this.todo.lineNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.app.vault.process(targetFile, (content) => {
|
||||||
|
return insertTodoAtBeginning(content, blockLines.join('\n'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,8 +55,8 @@ export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
|
|||||||
return option.displayText;
|
return option.displayText;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onChooseItem(option: HeadingOption): Promise<void> {
|
onChooseItem(option: HeadingOption): void {
|
||||||
await this.moveTodo(option.heading);
|
void this.moveTodo(option.heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async moveTodo(selectedHeading: HeadingCache | null): Promise<void> {
|
private async moveTodo(selectedHeading: HeadingCache | null): Promise<void> {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
|||||||
return file.path;
|
return file.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onChooseItem(file: TFile): Promise<void> {
|
onChooseItem(file: TFile): void {
|
||||||
// Get headings from the target file
|
// Get headings from the target file
|
||||||
const fileCache = this.app.metadataCache.getFileCache(file);
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
||||||
const headings = fileCache?.headings ?? [];
|
const headings = fileCache?.headings ?? [];
|
||||||
@@ -38,7 +38,7 @@ export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
|||||||
).open();
|
).open();
|
||||||
} else {
|
} else {
|
||||||
// No headings - move directly to beginning of file
|
// No headings - move directly to beginning of file
|
||||||
await this.moveTodo(file);
|
void this.moveTodo(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {App, PluginSettingTab, Setting} from "obsidian";
|
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||||
import TodoTrackerPlugin from "./main";
|
import TodoTrackerPlugin from "./main";
|
||||||
|
|
||||||
export interface MyPluginSettings {
|
export interface TodoTrackerSettings {
|
||||||
mySetting: string;
|
mySetting: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: MyPluginSettings = {
|
export const DEFAULT_SETTINGS: TodoTrackerSettings = {
|
||||||
mySetting: 'default'
|
mySetting: 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SampleSettingTab extends PluginSettingTab {
|
export class TodoTrackerSettingTab extends PluginSettingTab {
|
||||||
plugin: TodoTrackerPlugin;
|
plugin: TodoTrackerPlugin;
|
||||||
|
|
||||||
constructor(app: App, plugin: TodoTrackerPlugin) {
|
constructor(app: App, plugin: TodoTrackerPlugin) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TodoItem } from '../core/types';
|
|||||||
export interface TodoItemCallbacks {
|
export interface TodoItemCallbacks {
|
||||||
onToggle: (todo: TodoItem) => void;
|
onToggle: (todo: TodoItem) => void;
|
||||||
onMoveClick: (todo: TodoItem) => void;
|
onMoveClick: (todo: TodoItem) => void;
|
||||||
|
onMoveDailyNoteClick: (todo: TodoItem) => void;
|
||||||
onClick: (todo: TodoItem) => void;
|
onClick: (todo: TodoItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@ export function createTodoItemEl(
|
|||||||
callbacks.onMoveClick(todo);
|
callbacks.onMoveClick(todo);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle('Move to daily note...')
|
||||||
|
.setIcon('arrow-right')
|
||||||
|
.onClick(() => {
|
||||||
|
callbacks.onMoveDailyNoteClick(todo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
menu.showAtMouseEvent(evt);
|
menu.showAtMouseEvent(evt);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian';
|
import { App, ItemView, Platform, TFile, WorkspaceLeaf } from 'obsidian';
|
||||||
import type { TodoItem, TodoGroup } from '../core/types';
|
import type { TodoItem, TodoGroup } from '../core/types';
|
||||||
import { parseTodosGroupedByHeading } from '../core/todo-parser';
|
import { parseTodosGroupedByHeading } from '../core/todo-parser';
|
||||||
import { toggleTodo, collectTodoBlockLines, removeTodoBlock } from '../core/todo-transformer';
|
import { toggleTodo } from '../core/todo-transformer';
|
||||||
import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component';
|
import { createTodoItemEl } from './todo-item-component';
|
||||||
|
|
||||||
export const TODO_VIEW_TYPE = 'todo-tracker-view';
|
export const TODO_VIEW_TYPE = 'todo-tracker-view';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export class TodoSidebarView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDisplayText(): string {
|
getDisplayText(): string {
|
||||||
return 'Todo Tracker';
|
return 'Todo tracker';
|
||||||
}
|
}
|
||||||
|
|
||||||
getIcon(): string {
|
getIcon(): string {
|
||||||
@@ -44,14 +44,14 @@ export class TodoSidebarView extends ItemView {
|
|||||||
|
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
this.app.workspace.on('active-leaf-change', () => {
|
this.app.workspace.on('active-leaf-change', () => {
|
||||||
this.refresh();
|
void this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
this.app.vault.on('modify', (file) => {
|
this.app.vault.on('modify', (file) => {
|
||||||
if (this.currentFile && file.path === this.currentFile.path) {
|
if (this.currentFile && file.path === this.currentFile.path) {
|
||||||
this.refresh();
|
void this.refresh();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -142,8 +142,9 @@ export class TodoSidebarView extends ItemView {
|
|||||||
group: TodoGroup
|
group: TodoGroup
|
||||||
): void {
|
): void {
|
||||||
const itemEl = createTodoItemEl(container, todo, {
|
const itemEl = createTodoItemEl(container, todo, {
|
||||||
onToggle: (t) => this.handleToggle(file, t),
|
onToggle: (t) => { void this.handleToggle(file, t); },
|
||||||
onMoveClick: (t) => this.handleMoveClick(t, file),
|
onMoveClick: (t) => this.handleMoveClick(t, file),
|
||||||
|
onMoveDailyNoteClick: (t) => this.handleMoveDailyNoteClick(t, file),
|
||||||
onClick: (t) => this.openTodoInEditor(file, t),
|
onClick: (t) => this.openTodoInEditor(file, t),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,9 +163,9 @@ export class TodoSidebarView extends ItemView {
|
|||||||
group: TodoGroup
|
group: TodoGroup
|
||||||
): void {
|
): void {
|
||||||
// Find the child's <li> element within the parent
|
// Find the child's <li> element within the parent
|
||||||
const childEl = parentEl.querySelector(
|
const childEl = parentEl.querySelector<HTMLElement>(
|
||||||
`li[data-line-number="${todo.lineNumber}"]`
|
`li[data-line-number="${todo.lineNumber}"]`
|
||||||
) as HTMLElement | null;
|
);
|
||||||
if (childEl) {
|
if (childEl) {
|
||||||
this.flatTodoList.push({ todo, element: childEl, group });
|
this.flatTodoList.push({ todo, element: childEl, group });
|
||||||
|
|
||||||
@@ -221,15 +222,19 @@ export class TodoSidebarView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isToggleCheckboxHotkey(evt: KeyboardEvent): boolean {
|
private isToggleCheckboxHotkey(evt: KeyboardEvent): boolean {
|
||||||
// Bug 3 fix: properly match Obsidian hotkeys using correct API
|
interface HotkeyDef { modifiers: string[]; key: string; }
|
||||||
const hotkeyManager = (this.app as any).hotkeyManager;
|
interface HotkeyManager {
|
||||||
|
getHotkeys(commandId: string): HotkeyDef[];
|
||||||
|
getDefaultHotkeys(commandId: string): HotkeyDef[];
|
||||||
|
}
|
||||||
|
const hotkeyManager = (this.app as App & { hotkeyManager?: HotkeyManager }).hotkeyManager;
|
||||||
if (!hotkeyManager) return false;
|
if (!hotkeyManager) return false;
|
||||||
|
|
||||||
const commandId = 'editor:toggle-checklist-status';
|
const commandId = 'editor:toggle-checklist-status';
|
||||||
|
|
||||||
// Get custom hotkeys (user-configured), then fall back to defaults
|
// Get custom hotkeys (user-configured), then fall back to defaults
|
||||||
const customHotkeys = hotkeyManager.getHotkeys?.(commandId) ?? [];
|
const customHotkeys = hotkeyManager.getHotkeys(commandId);
|
||||||
const defaultHotkeys = hotkeyManager.getDefaultHotkeys?.(commandId) ?? [];
|
const defaultHotkeys = hotkeyManager.getDefaultHotkeys(commandId);
|
||||||
const allHotkeys = [...customHotkeys, ...defaultHotkeys];
|
const allHotkeys = [...customHotkeys, ...defaultHotkeys];
|
||||||
|
|
||||||
if (allHotkeys.length === 0) return false;
|
if (allHotkeys.length === 0) return false;
|
||||||
@@ -255,8 +260,6 @@ export class TodoSidebarView extends ItemView {
|
|||||||
|
|
||||||
// "Mod" means Cmd on Mac, Ctrl on Windows/Linux
|
// "Mod" means Cmd on Mac, Ctrl on Windows/Linux
|
||||||
const isMac = Platform.isMacOS;
|
const isMac = Platform.isMacOS;
|
||||||
const modPressed = isMac ? evt.metaKey : evt.ctrlKey;
|
|
||||||
const modExpected = needsMod;
|
|
||||||
|
|
||||||
// On Mac, Ctrl is separate from Mod (Cmd)
|
// On Mac, Ctrl is separate from Mod (Cmd)
|
||||||
// On Windows/Linux, Mod and Ctrl both map to ctrlKey
|
// On Windows/Linux, Mod and Ctrl both map to ctrlKey
|
||||||
@@ -314,7 +317,7 @@ export class TodoSidebarView extends ItemView {
|
|||||||
|
|
||||||
private openTodoInEditor(file: TFile, todo: TodoItem): void {
|
private openTodoInEditor(file: TFile, todo: TodoItem): void {
|
||||||
const leaf = this.app.workspace.getLeaf(false);
|
const leaf = this.app.workspace.getLeaf(false);
|
||||||
leaf.openFile(file).then(() => {
|
void leaf.openFile(file).then(() => {
|
||||||
const editor = this.app.workspace.activeEditor?.editor;
|
const editor = this.app.workspace.activeEditor?.editor;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const line = todo.lineNumber;
|
const line = todo.lineNumber;
|
||||||
@@ -327,7 +330,7 @@ export class TodoSidebarView extends ItemView {
|
|||||||
private toggleFocusedTodo(file: TFile): void {
|
private toggleFocusedTodo(file: TFile): void {
|
||||||
const entry = this.flatTodoList[this.focusedIndex];
|
const entry = this.flatTodoList[this.focusedIndex];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
this.handleToggle(file, entry.todo);
|
void this.handleToggle(file, entry.todo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveFocusedTodo(file: TFile): void {
|
private moveFocusedTodo(file: TFile): void {
|
||||||
@@ -345,8 +348,14 @@ export class TodoSidebarView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMoveClick(todo: TodoItem, file: TFile): void {
|
private handleMoveClick(todo: TodoItem, file: TFile): void {
|
||||||
import('../modals/note-select-modal').then(({ NoteSelectModal }) => {
|
void import('../modals/note-select-modal').then(({ NoteSelectModal }) => {
|
||||||
new NoteSelectModal(this.app, todo, file).open();
|
new NoteSelectModal(this.app, todo, file).open();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMoveDailyNoteClick(todo: TodoItem, file: TFile): void {
|
||||||
|
void import('../modals/daily-note-select-modal').then(({ DailyNoteSelectModal }) => {
|
||||||
|
new DailyNoteSelectModal(this.app, todo, file).open();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts"
|
"src/**/*.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user