Initial
This commit is contained in:
9
src/constants.ts
Normal file
9
src/constants.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const VIEW_TYPE = 'yaotp-task-view';
|
||||
export const VIEW_ICON = 'check-square';
|
||||
|
||||
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 DAILY_NOTE_TASKS_HEADING = '#### Tasks';
|
||||
85
src/daily-notes.ts
Normal file
85
src/daily-notes.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { App, Notice, TFile } from 'obsidian';
|
||||
import type { Task } from './types';
|
||||
import { DAILY_NOTE_TASKS_HEADING } from './constants';
|
||||
|
||||
// obsidian-daily-notes-interface re-exports moment from Obsidian's bundle.
|
||||
// We import it this way so esbuild treats moment as external.
|
||||
declare const moment: (date?: unknown) => {
|
||||
format(fmt: string): string;
|
||||
};
|
||||
|
||||
interface DailyNotesInterface {
|
||||
appHasDailyNotesPluginLoaded(app: App): boolean;
|
||||
getDailyNote(
|
||||
date: ReturnType<typeof moment>,
|
||||
notes: Record<string, TFile>
|
||||
): TFile | null;
|
||||
createDailyNote(date: ReturnType<typeof moment>): Promise<TFile>;
|
||||
getAllDailyNotes(): Record<string, TFile>;
|
||||
}
|
||||
|
||||
let dni: DailyNotesInterface | null = null;
|
||||
|
||||
async function getDni(): Promise<DailyNotesInterface> {
|
||||
if (!dni) {
|
||||
dni = await import('obsidian-daily-notes-interface') as unknown as DailyNotesInterface;
|
||||
}
|
||||
return dni;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a completed task (and its notes) to the `#### Tasks` section
|
||||
* of today's daily note, creating the section and/or the note if needed.
|
||||
*/
|
||||
export async function appendTaskToDailyNote(task: Task, app: App): Promise<void> {
|
||||
const lib = await getDni();
|
||||
|
||||
if (!lib.appHasDailyNotesPluginLoaded(app)) {
|
||||
new Notice(
|
||||
'YAOTP: Daily Notes plugin is not enabled. ' +
|
||||
'Please enable it in Settings → Core plugins.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const all = lib.getAllDailyNotes();
|
||||
// moment is available as a global in Obsidian's runtime
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const today = (window as any).moment();
|
||||
let file: TFile | null = lib.getDailyNote(today, all);
|
||||
|
||||
if (!file) {
|
||||
file = await lib.createDailyNote(today);
|
||||
}
|
||||
|
||||
const existing = await app.vault.read(file);
|
||||
const appended = buildAppendedContent(existing, task);
|
||||
await app.vault.modify(file, appended);
|
||||
}
|
||||
|
||||
function buildAppendedContent(existing: string, task: Task): string {
|
||||
const taskLine = `- [x] ${task.text}`;
|
||||
const notesBlock =
|
||||
task.notes.length > 0 ? '\n\n' + task.notes.join('\n') : '';
|
||||
const entry = taskLine + notesBlock;
|
||||
|
||||
const headingIndex = existing.lastIndexOf(DAILY_NOTE_TASKS_HEADING);
|
||||
|
||||
if (headingIndex === -1) {
|
||||
// Section doesn't exist — append it at the end
|
||||
const base = existing.trimEnd();
|
||||
return base + (base.length > 0 ? '\n\n' : '') + DAILY_NOTE_TASKS_HEADING + '\n\n' + entry + '\n';
|
||||
}
|
||||
|
||||
// Section exists — insert after heading and any existing items
|
||||
const afterHeading = existing.slice(headingIndex + DAILY_NOTE_TASKS_HEADING.length);
|
||||
// Find where the next same-or-higher-level heading starts (if any)
|
||||
const nextHeadingMatch = afterHeading.match(/\n#{1,4} /);
|
||||
const insertionRelative = nextHeadingMatch?.index ?? afterHeading.length;
|
||||
|
||||
const before = existing.slice(0, headingIndex + DAILY_NOTE_TASKS_HEADING.length);
|
||||
const sectionContent = afterHeading.slice(0, insertionRelative).trimEnd();
|
||||
const after = afterHeading.slice(insertionRelative);
|
||||
|
||||
return before + sectionContent + '\n\n' + entry + (after.length > 0 ? '\n' + after : '\n');
|
||||
}
|
||||
45
src/file-intercept.ts
Normal file
45
src/file-intercept.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { WorkspaceLeaf } from 'obsidian';
|
||||
import { VIEW_TYPE } from './constants';
|
||||
|
||||
type SetViewState = WorkspaceLeaf['setViewState'];
|
||||
|
||||
let originalSetViewState: SetViewState | null = null;
|
||||
|
||||
/**
|
||||
* Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a
|
||||
* task file (identified by the given predicate) redirects to our custom
|
||||
* view type instead of the default markdown editor.
|
||||
*/
|
||||
export function installFileIntercept(isTaskFile: (path: string) => boolean): void {
|
||||
if (originalSetViewState) return; // already installed
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const proto = (WorkspaceLeaf as any).prototype;
|
||||
originalSetViewState = proto.setViewState as SetViewState;
|
||||
|
||||
proto.setViewState = function (
|
||||
this: WorkspaceLeaf,
|
||||
state: Parameters<SetViewState>[0],
|
||||
eState?: Parameters<SetViewState>[1]
|
||||
) {
|
||||
if (
|
||||
state?.type === 'markdown' &&
|
||||
state?.state?.file &&
|
||||
isTaskFile(state.state.file as string)
|
||||
) {
|
||||
const newState = { ...state, type: VIEW_TYPE };
|
||||
return originalSetViewState!.call(this, newState, eState);
|
||||
}
|
||||
return originalSetViewState!.call(this, state, eState);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original setViewState. Call from Plugin.onunload().
|
||||
*/
|
||||
export function uninstallFileIntercept(): void {
|
||||
if (!originalSetViewState) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(WorkspaceLeaf as any).prototype.setViewState = originalSetViewState;
|
||||
originalSetViewState = null;
|
||||
}
|
||||
96
src/main.ts
Normal file
96
src/main.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Plugin, TFile } from 'obsidian';
|
||||
import { VIEW_TYPE } from './constants';
|
||||
import { DEFAULT_SETTINGS, YaotpSettings, YaotpSettingTab } from './settings';
|
||||
import { TaskFileView } from './view/TaskFileView';
|
||||
import { installFileIntercept, uninstallFileIntercept } from './file-intercept';
|
||||
|
||||
export default class YaotpPlugin extends Plugin {
|
||||
settings: YaotpSettings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
private taskRegex: RegExp = new RegExp(DEFAULT_SETTINGS.taskFileRegex);
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.rebuildRegex();
|
||||
|
||||
// Register the custom view
|
||||
this.registerView(
|
||||
VIEW_TYPE,
|
||||
(leaf) =>
|
||||
new TaskFileView(leaf, this.settings, () => this.getTaskFiles())
|
||||
);
|
||||
|
||||
// Intercept file opens for task files
|
||||
installFileIntercept((path) => this.isTaskFile(path));
|
||||
|
||||
// Open existing task-file leaves in the custom view on startup
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.redirectExistingLeaves();
|
||||
});
|
||||
|
||||
// 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 {
|
||||
uninstallFileIntercept();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
this.rebuildRegex();
|
||||
}
|
||||
|
||||
isTaskFile(path: string): boolean {
|
||||
return this.taskRegex.test(path);
|
||||
}
|
||||
|
||||
getTaskFiles(): TFile[] {
|
||||
return this.app.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((f) => this.isTaskFile(f.path));
|
||||
}
|
||||
|
||||
private rebuildRegex(): void {
|
||||
try {
|
||||
this.taskRegex = new RegExp(this.settings.taskFileRegex);
|
||||
} catch {
|
||||
// Fall back to default if the setting is invalid
|
||||
this.taskRegex = new RegExp(DEFAULT_SETTINGS.taskFileRegex);
|
||||
}
|
||||
}
|
||||
|
||||
private redirectExistingLeaves(): void {
|
||||
this.app.workspace.iterateAllLeaves((leaf) => {
|
||||
const state = leaf.getViewState();
|
||||
if (
|
||||
state.type === 'markdown' &&
|
||||
state.state?.file &&
|
||||
this.isTaskFile(state.state.file as string)
|
||||
) {
|
||||
leaf.setViewState({ ...state, type: VIEW_TYPE });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
123
src/parser.ts
Normal file
123
src/parser.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { Task } from './types';
|
||||
|
||||
const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
|
||||
|
||||
/**
|
||||
* Parse a task file's markdown content into an array of Tasks.
|
||||
*
|
||||
* Format expected:
|
||||
* - [ ] Task title
|
||||
*
|
||||
* notes line 1
|
||||
* notes line 2
|
||||
*
|
||||
* - [ ] 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.
|
||||
*/
|
||||
export function parseTaskFile(markdown: string): Task[] {
|
||||
const lines = markdown.split('\n');
|
||||
const tasks: Task[] = [];
|
||||
let current: Task | null = null;
|
||||
// State: 'idle' | 'after-task' | 'in-notes'
|
||||
// after-task: saw checklist line, waiting for blank line or next checklist
|
||||
// in-notes: saw blank line after checklist, collecting note lines
|
||||
let state: 'idle' | 'after-task' | 'in-notes' = 'idle';
|
||||
let noteBuffer: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
// Trim trailing blank lines from notes
|
||||
while (noteBuffer.length > 0 && noteBuffer[noteBuffer.length - 1].trim() === '') {
|
||||
noteBuffer.pop();
|
||||
}
|
||||
current.notes = noteBuffer;
|
||||
tasks.push(current);
|
||||
current = null;
|
||||
noteBuffer = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const match = CHECKLIST_RE.exec(line);
|
||||
|
||||
if (match) {
|
||||
// Start of a new task — flush the previous one first
|
||||
flush();
|
||||
current = {
|
||||
text: match[2],
|
||||
notes: [],
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
};
|
||||
state = 'after-task';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === null) {
|
||||
// No active task; ignore non-checklist lines before the first task
|
||||
continue;
|
||||
}
|
||||
|
||||
const isBlank = line.trim() === '';
|
||||
|
||||
if (state === 'after-task') {
|
||||
if (isBlank) {
|
||||
// Blank line after task — transition to note-collection mode
|
||||
state = 'in-notes';
|
||||
} else {
|
||||
// Non-blank, non-checklist line immediately after task (no separator blank)
|
||||
// Treat as a note anyway, for robustness
|
||||
noteBuffer.push(line);
|
||||
state = 'in-notes';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'in-notes') {
|
||||
noteBuffer.push(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an array of Tasks back to canonical markdown.
|
||||
*
|
||||
* Format produced:
|
||||
* - [ ] Task title
|
||||
*
|
||||
* notes line 1
|
||||
* notes line 2
|
||||
*
|
||||
* - [ ] Next task
|
||||
*
|
||||
* Tasks with no notes have no trailing blank line before the next task,
|
||||
* except the final newline at EOF.
|
||||
*/
|
||||
export function serializeTaskFile(tasks: Task[]): string {
|
||||
if (tasks.length === 0) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const marker = task.completed ? 'x' : ' ';
|
||||
parts.push(`- [${marker}] ${task.text}`);
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
parts.push('');
|
||||
for (const note of task.notes) {
|
||||
parts.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
// Join and ensure single trailing newline
|
||||
return parts.join('\n').replace(/\n+$/, '') + '\n';
|
||||
}
|
||||
102
src/settings.ts
Normal file
102
src/settings.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import {
|
||||
DEFAULT_DAILY_NOTE_FOLDER,
|
||||
DEFAULT_DAILY_NOTE_FORMAT,
|
||||
DEFAULT_INBOX_PATH,
|
||||
DEFAULT_TASK_REGEX,
|
||||
} from './constants';
|
||||
import type YaotpPlugin from './main';
|
||||
|
||||
export interface YaotpSettings {
|
||||
inboxPath: string;
|
||||
taskFileRegex: string;
|
||||
dailyNoteFormat: string;
|
||||
dailyNoteFolder: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: YaotpSettings = {
|
||||
inboxPath: DEFAULT_INBOX_PATH,
|
||||
taskFileRegex: DEFAULT_TASK_REGEX,
|
||||
dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT,
|
||||
dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER,
|
||||
};
|
||||
|
||||
export class YaotpSettingTab extends PluginSettingTab {
|
||||
plugin: YaotpPlugin;
|
||||
|
||||
constructor(app: App, plugin: YaotpPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Inbox file path')
|
||||
.setDesc('Vault-relative path to the default Inbox task file.')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('Tasks/Inbox.md')
|
||||
.setValue(this.plugin.settings.inboxPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.inboxPath = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Task file regex')
|
||||
.setDesc(
|
||||
'Regular expression matched against vault-relative file paths to identify task files.'
|
||||
)
|
||||
.addText((text) => {
|
||||
text
|
||||
.setPlaceholder('^Tasks\\/.*\\.md$')
|
||||
.setValue(this.plugin.settings.taskFileRegex)
|
||||
.onChange(async (value) => {
|
||||
// Validate before saving
|
||||
try {
|
||||
new RegExp(value);
|
||||
text.inputEl.removeClass('yaotp-setting-error');
|
||||
this.plugin.settings.taskFileRegex = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
} catch {
|
||||
text.inputEl.addClass('yaotp-setting-error');
|
||||
}
|
||||
});
|
||||
text.inputEl.style.width = '100%';
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Daily note date format')
|
||||
.setDesc(
|
||||
'Moment.js date format for daily notes. Should match your Daily Notes plugin setting.'
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('YYYY-MM-DD')
|
||||
.setValue(this.plugin.settings.dailyNoteFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.dailyNoteFormat = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Daily note folder')
|
||||
.setDesc(
|
||||
'Folder where daily notes are stored. Leave empty for the vault root. Should match your Daily Notes plugin setting.'
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('Daily Notes')
|
||||
.setValue(this.plugin.settings.dailyNoteFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.dailyNoteFolder = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
8
src/types.ts
Normal file
8
src/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Task {
|
||||
/** The text of the checklist item (without the `- [ ] ` prefix). */
|
||||
text: string;
|
||||
/** Lines of notes associated with this task. Empty array if none. */
|
||||
notes: string[];
|
||||
/** Whether the task is completed (`- [x]`). */
|
||||
completed: boolean;
|
||||
}
|
||||
59
src/view/FileSwitcherBar.ts
Normal file
59
src/view/FileSwitcherBar.ts
Normal 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
136
src/view/TaskEditorModal.ts
Normal 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
193
src/view/TaskFileView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/view/TaskListComponent.ts
Normal file
111
src/view/TaskListComponent.ts
Normal 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 = '⋮';
|
||||
|
||||
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 = '⋮'; // 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user