This commit is contained in:
2026-03-28 10:55:42 -04:00
commit 6a2d0cffd6
21 changed files with 2175 additions and 0 deletions

View File

@ -0,0 +1,59 @@
import { App, TFile } from 'obsidian';
export interface FileSwitcherBarOptions {
app: App;
currentFile: TFile;
taskFiles: TFile[];
inboxPath: string;
onSwitchFile: (file: TFile) => void;
onCreateFile: (name: string) => void;
}
/**
* Renders a header bar with a file switcher dropdown and a "New task file" button.
* Returns the container element to be prepended into the view's root.
*/
export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement {
const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts;
const bar = createDiv({ cls: 'yaotp-switcher-bar' });
// Left side: file selector
const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' });
const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' });
// Always show Inbox first if it exists in the list
const inbox = taskFiles.find((f) => f.path === inboxPath);
const others = taskFiles.filter((f) => f.path !== inboxPath);
const ordered = inbox ? [inbox, ...others] : others;
for (const file of ordered) {
const opt = select.createEl('option', {
text: file.basename,
value: file.path,
});
if (file.path === currentFile.path) {
opt.selected = true;
}
}
select.addEventListener('change', () => {
const chosen = taskFiles.find((f) => f.path === select.value);
if (chosen) onSwitchFile(chosen);
});
// Right side: new file button
const newBtn = bar.createEl('button', {
text: '+ New list',
cls: 'yaotp-switcher-new-btn',
});
newBtn.addEventListener('click', () => {
const name = prompt('New task file name (without .md):');
if (name && name.trim()) {
onCreateFile(name.trim());
}
});
return bar;
}

136
src/view/TaskEditorModal.ts Normal file
View File

@ -0,0 +1,136 @@
import { App, FuzzySuggestModal, Modal, TFile } from 'obsidian';
import type { Task } from '../types';
export interface TaskEditorResult {
text: string;
notes: string[];
/** If set, move the task to this file. */
targetFile: TFile | null;
}
type SaveCallback = (result: TaskEditorResult) => void;
/**
* Modal for editing a task's text and notes together, and optionally
* moving it to a different task file.
*/
export class TaskEditorModal extends Modal {
private task: Task;
private taskFiles: TFile[];
private currentFile: TFile;
private onSave: SaveCallback;
private textarea: HTMLTextAreaElement | null = null;
private selectedFile: TFile | null = null;
private fileLabel: HTMLSpanElement | null = null;
constructor(
app: App,
task: Task,
taskFiles: TFile[],
currentFile: TFile,
onSave: SaveCallback
) {
super(app);
this.task = task;
this.taskFiles = taskFiles;
this.currentFile = currentFile;
this.onSave = onSave;
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass('yaotp-editor-modal');
// Title
contentEl.createEl('h2', { text: 'Edit task' });
// Textarea: first line = task text, rest = notes
const initialValue = [this.task.text, ...this.task.notes].join('\n');
this.textarea = contentEl.createEl('textarea', {
cls: 'yaotp-editor-textarea',
attr: { rows: '8', placeholder: 'Task title\n\nNotes…' },
});
this.textarea.value = initialValue;
// Auto-focus
setTimeout(() => this.textarea?.focus(), 50);
// File selector row
const fileRow = contentEl.createDiv({ cls: 'yaotp-editor-file-row' });
fileRow.createEl('span', { text: 'File: ', cls: 'yaotp-editor-file-label-prefix' });
this.fileLabel = fileRow.createEl('span', {
text: this.currentFile.basename,
cls: 'yaotp-editor-file-name',
});
const changeBtn = fileRow.createEl('button', {
text: 'Change…',
cls: 'yaotp-editor-file-btn',
});
changeBtn.addEventListener('click', () => {
const picker = new TaskFilePicker(
this.app,
this.taskFiles,
(file) => {
this.selectedFile = file;
if (this.fileLabel) {
this.fileLabel.setText(file.basename);
}
}
);
picker.open();
});
// Buttons
const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const saveBtn = btnRow.createEl('button', {
text: 'Save',
cls: 'mod-cta yaotp-editor-save-btn',
});
saveBtn.addEventListener('click', () => this.save());
const cancelBtn = btnRow.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
private save(): void {
if (!this.textarea) return;
const lines = this.textarea.value.split('\n');
const text = lines[0] ?? '';
const notes = lines.slice(1);
// Trim leading blank lines from notes
while (notes.length > 0 && notes[0].trim() === '') notes.shift();
this.onSave({
text,
notes,
targetFile: this.selectedFile,
});
this.close();
}
}
class TaskFilePicker extends FuzzySuggestModal<TFile> {
private files: TFile[];
private onSelect: (file: TFile) => void;
constructor(app: App, files: TFile[], onSelect: (file: TFile) => void) {
super(app);
this.files = files;
this.onSelect = onSelect;
this.setPlaceholder('Select a task file…');
}
getItems(): TFile[] {
return this.files;
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.onSelect(file);
}
}

193
src/view/TaskFileView.ts Normal file
View File

@ -0,0 +1,193 @@
import { App, Notice, TextFileView, TFile, WorkspaceLeaf } from 'obsidian';
import { parseTaskFile, serializeTaskFile } from '../parser';
import { appendTaskToDailyNote } from '../daily-notes';
import { TaskListComponent } from './TaskListComponent';
import { buildFileSwitcherBar } from './FileSwitcherBar';
import { TaskEditorModal } from './TaskEditorModal';
import { VIEW_ICON, VIEW_TYPE } from '../constants';
import type { Task } from '../types';
import type { YaotpSettings } from '../settings';
export class TaskFileView extends TextFileView {
private tasks: Task[] = [];
private taskList: TaskListComponent | null = null;
private rootEl: HTMLElement | null = null;
private listEl: HTMLElement | null = null;
private settings: YaotpSettings;
private getTaskFiles: () => TFile[];
constructor(
leaf: WorkspaceLeaf,
settings: YaotpSettings,
getTaskFiles: () => TFile[]
) {
super(leaf);
this.settings = settings;
this.getTaskFiles = getTaskFiles;
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return this.file?.basename ?? 'Tasks';
}
getIcon(): string {
return VIEW_ICON;
}
// Called by Obsidian when the file content is loaded or changes externally.
setViewData(data: string, _clear: boolean): void {
this.tasks = parseTaskFile(data);
this.renderView();
}
// Called by Obsidian when it needs to read back the file content.
getViewData(): string {
return serializeTaskFile(this.tasks);
}
clear(): void {
this.tasks = [];
this.renderView();
}
// Obsidian calls this after the view is attached to the DOM.
async onOpen(): Promise<void> {
this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl;
}
async onClose(): Promise<void> {
this.taskList?.destroy();
this.taskList = null;
}
// ── Private ──────────────────────────────────────────────────────────────
private renderView(): void {
if (!this.rootEl) return;
this.rootEl.empty();
// Switcher bar
if (this.file) {
const taskFiles = this.getTaskFiles();
const bar = buildFileSwitcherBar({
app: this.app,
currentFile: this.file,
taskFiles,
inboxPath: this.settings.inboxPath,
onSwitchFile: (file) => this.openFile(file),
onCreateFile: (name) => this.createAndOpenFile(name),
});
this.rootEl.appendChild(bar);
}
// Task list container
this.listEl = this.rootEl.createDiv({ cls: 'yaotp-list-container' });
this.taskList = new TaskListComponent(this.listEl, {
onComplete: (index) => this.completeTask(index),
onEdit: (index) => this.editTask(index),
onReorder: (oldIndex, newIndex) => this.reorderTask(oldIndex, newIndex),
onAdd: (text) => this.addTask(text),
});
this.taskList.render(this.tasks);
}
private async persistTasks(): Promise<void> {
if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks));
}
private async completeTask(index: number): Promise<void> {
const task = this.tasks[index];
if (!task) return;
// Mark completed and remove from this file
task.completed = true;
this.tasks.splice(index, 1);
await this.persistTasks();
// Append to daily note
await appendTaskToDailyNote(task, this.app);
this.renderView();
}
private editTask(index: number): void {
const task = this.tasks[index];
if (!task || !this.file) return;
const taskFiles = this.getTaskFiles();
const modal = new TaskEditorModal(
this.app,
task,
taskFiles,
this.file,
async (result) => {
task.text = result.text;
task.notes = result.notes;
if (result.targetFile && result.targetFile.path !== this.file?.path) {
// Move task to another file
this.tasks.splice(index, 1);
await this.persistTasks();
await this.appendTaskToFile(task, result.targetFile);
} else {
await this.persistTasks();
}
this.renderView();
}
);
modal.open();
}
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
}
private reorderTask(oldIndex: number, newIndex: number): void {
const moved = this.tasks.splice(oldIndex, 1)[0];
this.tasks.splice(newIndex, 0, moved);
this.persistTasks();
// No need to re-render; SortableJS already updated the DOM
}
private async appendTaskToFile(task: Task, file: TFile): Promise<void> {
const content = await this.app.vault.read(file);
const existing = parseTaskFile(content);
existing.push(task);
await this.app.vault.modify(file, serializeTaskFile(existing));
}
private openFile(file: TFile): void {
this.app.workspace.getLeaf(false).openFile(file);
}
private async createAndOpenFile(name: string): Promise<void> {
// Derive folder from inboxPath
const inboxFolder = this.settings.inboxPath.includes('/')
? this.settings.inboxPath.slice(0, this.settings.inboxPath.lastIndexOf('/'))
: '';
const path = inboxFolder ? `${inboxFolder}/${name}.md` : `${name}.md`;
let file = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!file) {
try {
file = await this.app.vault.create(path, '');
} catch (e) {
new Notice(`YAOTP: Could not create file: ${path}`);
return;
}
}
this.openFile(file);
}
}

View File

@ -0,0 +1,111 @@
import Sortable from 'sortablejs';
import type { Task } from '../types';
export interface TaskListCallbacks {
onComplete: (index: number) => void;
onEdit: (index: number) => void;
onReorder: (oldIndex: number, newIndex: number) => void;
onAdd: (text: string) => void;
}
/**
* Builds and manages the sortable task list DOM.
*/
export class TaskListComponent {
private container: HTMLElement;
private callbacks: TaskListCallbacks;
private sortable: Sortable | null = null;
constructor(container: HTMLElement, callbacks: TaskListCallbacks) {
this.container = container;
this.callbacks = callbacks;
}
render(tasks: Task[]): void {
this.destroy();
this.container.empty();
// Add-task row — always first, outside the sortable list
const addItem = this.container.createDiv({ cls: 'yaotp-task-item yaotp-add-task-item' });
const addHandle = addItem.createDiv({ cls: 'yaotp-drag-handle yaotp-drag-handle-disabled' });
addHandle.innerHTML = '&#8942;';
addItem.createEl('input', {
cls: 'yaotp-checkbox',
type: 'checkbox',
attr: { disabled: true, 'aria-hidden': 'true' },
});
const input = addItem.createEl('input', {
cls: 'yaotp-new-task-input',
type: 'text',
attr: { placeholder: 'Enter new task...' },
}) as HTMLInputElement;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const text = input.value.trim();
if (text) {
this.callbacks.onAdd(text);
}
}
});
if (tasks.length === 0) return;
const list = this.container.createEl('ul', { cls: 'yaotp-task-list' });
tasks.forEach((task, index) => {
const item = list.createEl('li', { cls: 'yaotp-task-item' });
if (task.completed) item.addClass('yaotp-task-completed');
item.dataset.index = String(index);
// Drag handle
const handle = item.createDiv({ cls: 'yaotp-drag-handle' });
handle.innerHTML = '&#8942;'; // vertical ellipsis ⋮
// Checkbox
const checkbox = item.createEl('input', {
cls: 'yaotp-checkbox',
type: 'checkbox',
attr: { 'aria-label': 'Complete task' },
}) as HTMLInputElement;
checkbox.checked = task.completed;
checkbox.addEventListener('click', (e) => {
e.stopPropagation();
this.callbacks.onComplete(index);
});
// Task text (wraps)
const textEl = item.createDiv({ cls: 'yaotp-task-text' });
textEl.setText(task.text);
if (task.notes.length > 0) {
textEl.addClass('yaotp-task-has-notes');
}
textEl.addEventListener('click', () => {
this.callbacks.onEdit(index);
});
});
this.sortable = Sortable.create(list, {
handle: '.yaotp-drag-handle',
animation: 150,
delay: 300,
delayOnTouchOnly: true,
onEnd: (evt) => {
const oldIndex = evt.oldIndex;
const newIndex = evt.newIndex;
if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
this.callbacks.onReorder(oldIndex, newIndex);
}
},
});
}
destroy(): void {
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
}
}