Tweaks & fixes

This commit is contained in:
2026-03-29 09:56:40 -04:00
parent 6a2d0cffd6
commit c21d8b1097
11 changed files with 310 additions and 81 deletions

View File

@ -5,5 +5,6 @@ export const DEFAULT_INBOX_PATH = 'Tasks/Inbox.md';
export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$';
export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD';
export const DEFAULT_DAILY_NOTE_FOLDER = '';
export const DEFAULT_TASK_FILE_FOLDER = 'Tasks';
export const DAILY_NOTE_TASKS_HEADING = '#### Tasks';

View File

@ -5,6 +5,21 @@ type SetViewState = WorkspaceLeaf['setViewState'];
let originalSetViewState: SetViewState | null = null;
/**
* Leaves that have been granted a one-shot bypass of the intercept.
* Used to allow the task view's "Source mode" button to switch to the
* markdown editor without being redirected back.
*/
const bypassLeaves = new WeakSet<WorkspaceLeaf>();
/**
* Allow the next setViewState call on this leaf to pass through the
* intercept unmodified. Call this immediately before setViewState.
*/
export function allowMarkdownOnce(leaf: WorkspaceLeaf): void {
bypassLeaves.add(leaf);
}
/**
* Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a
* task file (identified by the given predicate) redirects to our custom
@ -25,8 +40,21 @@ export function installFileIntercept(isTaskFile: (path: string) => boolean): voi
if (
state?.type === 'markdown' &&
state?.state?.file &&
isTaskFile(state.state.file as string)
isTaskFile(state.state.file as string) &&
// Don't intercept mode-only changes within an already-open markdown view
// for the same file (e.g. switching to Source mode via the context menu).
// We DO still intercept if the leaf is markdown but showing a different
// file, or if the view is not yet markdown at all.
!(
this.view?.getViewType() === 'markdown' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.view as any)?.file?.path === (state.state?.file as string)
)
) {
if (bypassLeaves.has(this)) {
bypassLeaves.delete(this);
return originalSetViewState!.call(this, state, eState);
}
const newState = { ...state, type: VIEW_TYPE };
return originalSetViewState!.call(this, newState, eState);
}

View File

@ -31,12 +31,6 @@ export default class YaotpPlugin extends Plugin {
// Settings tab
this.addSettingTab(new YaotpSettingTab(this.app, this));
// Command: open inbox
this.addCommand({
id: 'open-inbox',
name: 'Open Inbox',
callback: () => this.openInbox(),
});
}
onunload(): void {
@ -84,13 +78,4 @@ export default class YaotpPlugin extends Plugin {
});
}
private async openInbox(): Promise<void> {
const { inboxPath } = this.settings;
let file = this.app.vault.getAbstractFileByPath(inboxPath) as TFile | null;
if (!file) {
file = await this.app.vault.create(inboxPath, '');
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
}

View File

@ -2,8 +2,14 @@ import type { Task } from './types';
const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
export interface TaskFileData {
/** Raw text before the first checklist item, preserved verbatim on serialize. */
header: string;
tasks: Task[];
}
/**
* Parse a task file's markdown content into an array of Tasks.
* Parse a task file's markdown content into header text and an array of Tasks.
*
* Format expected:
* - [ ] Task title
@ -13,13 +19,14 @@ const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
*
* - [ ] Next task
*
* Notes are lines between two blank-line boundaries following a checklist item.
* Blank lines that are not between a checklist item and notes, or between notes
* lines, are ignored.
* Any non-checklist lines before the first task are captured as `header` and
* round-tripped unchanged. Notes are lines between two blank-line boundaries
* following a checklist item.
*/
export function parseTaskFile(markdown: string): Task[] {
export function parseTaskFile(markdown: string): TaskFileData {
const lines = markdown.split('\n');
const tasks: Task[] = [];
const headerLines: string[] = [];
let current: Task | null = null;
// State: 'idle' | 'after-task' | 'in-notes'
// after-task: saw checklist line, waiting for blank line or next checklist
@ -56,7 +63,8 @@ export function parseTaskFile(markdown: string): Task[] {
}
if (current === null) {
// No active task; ignore non-checklist lines before the first task
// Before the first task — capture as header
headerLines.push(line);
continue;
}
@ -82,13 +90,22 @@ export function parseTaskFile(markdown: string): Task[] {
}
flush();
return tasks;
// Trim trailing blank lines from header
while (headerLines.length > 0 && headerLines[headerLines.length - 1].trim() === '') {
headerLines.pop();
}
const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : '';
return { header, tasks };
}
/**
* Serialize an array of Tasks back to canonical markdown.
* Serialize a TaskFileData back to canonical markdown.
*
* The header (if any) is written verbatim first, separated from tasks by a
* blank line. Task format:
*
* Format produced:
* - [ ] Task title
*
* notes line 1
@ -96,11 +113,13 @@ export function parseTaskFile(markdown: string): Task[] {
*
* - [ ] Next task
*
* Tasks with no notes have no trailing blank line before the next task,
* except the final newline at EOF.
* Tasks with notes always have a blank line between the checklist line and the
* first note line. Completed items use `- [x] …`.
*/
export function serializeTaskFile(tasks: Task[]): string {
if (tasks.length === 0) return '';
export function serializeTaskFile(data: TaskFileData): string {
const { header, tasks } = data;
if (tasks.length === 0) return header || '';
const parts: string[] = [];
@ -119,5 +138,8 @@ export function serializeTaskFile(tasks: Task[]): string {
}
// Join and ensure single trailing newline
return parts.join('\n').replace(/\n+$/, '') + '\n';
const taskContent = parts.join('\n').replace(/\n+$/, '') + '\n';
// header already ends with '\n'; add one more for the blank separator line
return header ? header + '\n' + taskContent : taskContent;
}

View File

@ -3,6 +3,7 @@ import {
DEFAULT_DAILY_NOTE_FOLDER,
DEFAULT_DAILY_NOTE_FORMAT,
DEFAULT_INBOX_PATH,
DEFAULT_TASK_FILE_FOLDER,
DEFAULT_TASK_REGEX,
} from './constants';
import type YaotpPlugin from './main';
@ -10,6 +11,7 @@ import type YaotpPlugin from './main';
export interface YaotpSettings {
inboxPath: string;
taskFileRegex: string;
taskFileFolder: string;
dailyNoteFormat: string;
dailyNoteFolder: string;
}
@ -17,6 +19,7 @@ export interface YaotpSettings {
export const DEFAULT_SETTINGS: YaotpSettings = {
inboxPath: DEFAULT_INBOX_PATH,
taskFileRegex: DEFAULT_TASK_REGEX,
taskFileFolder: DEFAULT_TASK_FILE_FOLDER,
dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT,
dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER,
};
@ -69,6 +72,19 @@ export class YaotpSettingTab extends PluginSettingTab {
text.inputEl.style.width = '100%';
});
new Setting(containerEl)
.setName('Task file folder')
.setDesc('Vault-relative folder where new task lists are created. Leave empty for the vault root.')
.addText((text) =>
text
.setPlaceholder('Tasks')
.setValue(this.plugin.settings.taskFileFolder)
.onChange(async (value) => {
this.plugin.settings.taskFileFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Daily note date format')
.setDesc(

View File

@ -1,7 +1,7 @@
import { App, TFile } from 'obsidian';
import { TFile } from 'obsidian';
export interface FileSwitcherBarOptions {
app: App;
app: unknown; // kept for interface compatibility, unused after modal removal
currentFile: TFile;
taskFiles: TFile[];
inboxPath: string;
@ -10,8 +10,8 @@ export interface FileSwitcherBarOptions {
}
/**
* 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.
* Renders a header bar with a file switcher dropdown and an inline
* "New task file" affordance. Returns the container element.
*/
export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement {
const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts;
@ -20,39 +20,70 @@ export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement
// 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;
}
for (const file of (inbox ? [inbox, ...others] : others)) {
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
// Right side: "+ New list" button / inline create input (mutually exclusive)
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());
const createGroup = bar.createDiv({ cls: 'yaotp-switcher-create-group' });
createGroup.style.display = 'none';
const nameInput = createGroup.createEl('input', {
type: 'text',
cls: 'yaotp-switcher-name-input',
attr: { placeholder: 'File name (without .md)' },
}) as HTMLInputElement;
const confirmBtn = createGroup.createEl('button', {
text: 'Create',
cls: 'mod-cta yaotp-switcher-confirm-btn',
});
const cancelBtn = createGroup.createEl('button', {
text: '✕',
cls: 'yaotp-switcher-cancel-btn',
});
const showInput = () => {
newBtn.style.display = 'none';
createGroup.style.display = 'flex';
nameInput.value = '';
setTimeout(() => nameInput.focus(), 0);
};
const hideInput = () => {
createGroup.style.display = 'none';
newBtn.style.display = '';
};
const submit = () => {
const name = nameInput.value.trim();
if (name) {
hideInput();
onCreateFile(name);
}
};
newBtn.addEventListener('click', showInput);
confirmBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', hideInput);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') hideInput();
});
return bar;

View File

@ -9,6 +9,7 @@ export interface TaskEditorResult {
}
type SaveCallback = (result: TaskEditorResult) => void;
type DeleteCallback = () => void;
/**
* Modal for editing a task's text and notes together, and optionally
@ -19,6 +20,7 @@ export class TaskEditorModal extends Modal {
private taskFiles: TFile[];
private currentFile: TFile;
private onSave: SaveCallback;
private onDelete: DeleteCallback;
private textarea: HTMLTextAreaElement | null = null;
private selectedFile: TFile | null = null;
private fileLabel: HTMLSpanElement | null = null;
@ -28,13 +30,15 @@ export class TaskEditorModal extends Modal {
task: Task,
taskFiles: TFile[],
currentFile: TFile,
onSave: SaveCallback
onSave: SaveCallback,
onDelete: DeleteCallback
) {
super(app);
this.task = task;
this.taskFiles = taskFiles;
this.currentFile = currentFile;
this.onSave = onSave;
this.onDelete = onDelete;
}
onOpen(): void {
@ -44,8 +48,11 @@ export class TaskEditorModal extends 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');
// Textarea: first line = task text, blank line, then notes
const initialValue =
this.task.notes.length > 0
? [this.task.text, '', ...this.task.notes].join('\n')
: this.task.text;
this.textarea = contentEl.createEl('textarea', {
cls: 'yaotp-editor-textarea',
attr: { rows: '8', placeholder: 'Task title\n\nNotes…' },
@ -81,6 +88,18 @@ export class TaskEditorModal extends Modal {
// Buttons
const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const deleteBtn = btnRow.createEl('button', {
text: 'Delete',
cls: 'yaotp-editor-delete-btn',
});
deleteBtn.addEventListener('click', () => {
new ConfirmModal(this.app, 'Delete this task? This cannot be undone.', () => {
this.close();
this.onDelete();
}).open();
});
const saveBtn = btnRow.createEl('button', {
text: 'Save',
cls: 'mod-cta yaotp-editor-save-btn',
@ -100,7 +119,7 @@ export class TaskEditorModal extends Modal {
const lines = this.textarea.value.split('\n');
const text = lines[0] ?? '';
const notes = lines.slice(1);
// Trim leading blank lines from notes
// Trim leading blank lines from notes (user may have removed or added the blank separator)
while (notes.length > 0 && notes[0].trim() === '') notes.shift();
this.onSave({
text,
@ -111,6 +130,37 @@ export class TaskEditorModal extends Modal {
}
}
class ConfirmModal extends Modal {
private message: string;
private onConfirm: () => void;
constructor(app: App, message: string, onConfirm: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('p', { text: this.message });
const row = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const confirmBtn = row.createEl('button', {
text: 'Delete',
cls: 'mod-warning yaotp-editor-delete-btn',
});
confirmBtn.addEventListener('click', () => {
this.onConfirm();
this.close();
});
const cancelBtn = row.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}
class TaskFilePicker extends FuzzySuggestModal<TFile> {
private files: TFile[];
private onSelect: (file: TFile) => void;

View File

@ -5,11 +5,13 @@ import { TaskListComponent } from './TaskListComponent';
import { buildFileSwitcherBar } from './FileSwitcherBar';
import { TaskEditorModal } from './TaskEditorModal';
import { VIEW_ICON, VIEW_TYPE } from '../constants';
import { allowMarkdownOnce } from '../file-intercept';
import type { Task } from '../types';
import type { YaotpSettings } from '../settings';
export class TaskFileView extends TextFileView {
private tasks: Task[] = [];
private fileHeader: string = '';
private taskList: TaskListComponent | null = null;
private rootEl: HTMLElement | null = null;
private listEl: HTMLElement | null = null;
@ -40,16 +42,19 @@ export class TaskFileView extends TextFileView {
// Called by Obsidian when the file content is loaded or changes externally.
setViewData(data: string, _clear: boolean): void {
this.tasks = parseTaskFile(data);
const { header, tasks } = parseTaskFile(data);
this.fileHeader = header;
this.tasks = tasks;
this.renderView();
}
// Called by Obsidian when it needs to read back the file content.
getViewData(): string {
return serializeTaskFile(this.tasks);
return serializeTaskFile({ header: this.fileHeader, tasks: this.tasks });
}
clear(): void {
this.fileHeader = '';
this.tasks = [];
this.renderView();
}
@ -58,6 +63,24 @@ export class TaskFileView extends TextFileView {
async onOpen(): Promise<void> {
this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl;
this.addAction('pencil', 'Edit source', async () => {
if (!this.file) return;
const filePath = this.file.path;
// First call: bypass the intercept to land in markdown (Obsidian may
// open in Live Preview regardless of the mode we pass here).
allowMarkdownOnce(this.leaf);
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath },
});
// Second call: now the leaf is markdown + same file, so the intercept
// treats this as a mode change and lets it through.
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath, mode: 'source' },
});
});
}
async onClose(): Promise<void> {
@ -100,7 +123,16 @@ export class TaskFileView extends TextFileView {
private async persistTasks(): Promise<void> {
if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks));
await this.app.vault.modify(
this.file,
serializeTaskFile({ header: this.fileHeader, tasks: this.tasks })
);
}
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
}
private async completeTask(index: number): Promise<void> {
@ -142,18 +174,17 @@ export class TaskFileView extends TextFileView {
await this.persistTasks();
}
this.renderView();
},
async () => {
this.tasks.splice(index, 1);
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);
@ -163,9 +194,9 @@ export class TaskFileView extends TextFileView {
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));
const data = parseTaskFile(content);
data.tasks.push(task);
await this.app.vault.modify(file, serializeTaskFile(data));
}
private openFile(file: TFile): void {
@ -173,11 +204,8 @@ export class TaskFileView extends TextFileView {
}
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`;
const folder = this.settings.taskFileFolder;
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
let file = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!file) {