diff --git a/TODOs.md b/TODOs.md index b123b53..3e23ef9 100644 --- a/TODOs.md +++ b/TODOs.md @@ -149,6 +149,7 @@ # Task adding and editing and deletion - dropdown menu in preview mode to do attribute adding and editing etc. +- TODO: BUG: the autosuggest filter doesn't work. ## Task Adding diff --git a/jest-setup.js b/jest-setup.js new file mode 100644 index 0000000..fa31841 --- /dev/null +++ b/jest-setup.js @@ -0,0 +1,3 @@ +jest.mock('obsidian', () => ({ + Notice: jest.fn().mockImplementation((message) => console.log(`Mock Notice: ${message}`)) +})); \ No newline at end of file diff --git a/src/autoSuggestions/Suggester.ts b/src/autoSuggestions/Suggester.ts index 6ce2bc8..dfaf16e 100644 --- a/src/autoSuggestions/Suggester.ts +++ b/src/autoSuggestions/Suggester.ts @@ -130,7 +130,7 @@ export class AttributeSuggester { let suggestions: SuggestInformation[] = []; // Modify regex to capture the due date query - const dueRegexText = `${escapeRegExp(this.startingNotation)}\\s?due:(\\s*)${escapeRegExp(this.endingNotation)}`; + const dueRegexText = `${escapeRegExp(this.startingNotation)}\\s?due:(.*?)${escapeRegExp(this.endingNotation)}`; const dueRegex = new RegExp(dueRegexText, 'g'); const dueMatch = matchByPositionAndGroup(lineText, dueRegex, cursorPos, 1); if (!dueMatch) return suggestions; // No match @@ -179,7 +179,7 @@ export class AttributeSuggester { let suggestions: SuggestInformation[] = []; // Modify regex to capture the project name query - const projectRegexText = `${escapeRegExp(this.startingNotation)}\\s?project:(\\s*)${escapeRegExp(this.endingNotation)}`; + const projectRegexText = `${escapeRegExp(this.startingNotation)}\\s?project:(.*?)${escapeRegExp(this.endingNotation)}`; const projectRegex = new RegExp( projectRegexText, 'g'); const projectMatch = matchByPositionAndGroup( lineText, diff --git a/src/renderer/postProcessor.ts b/src/renderer/postProcessor.ts index 4d653a1..2f69d3b 100644 --- a/src/renderer/postProcessor.ts +++ b/src/renderer/postProcessor.ts @@ -10,9 +10,9 @@ import { } from '../taskModule/taskSyncManager'; import { logger } from '../utils/log'; -export type TaskMode = 'single-line' | 'multi-line'; -export interface TaskItemParams { - mode: TaskMode | null; +export type TaskDisplayMode = 'single-line' | 'multi-line'; +export interface TaskDisplayParams { + mode: TaskDisplayMode | null; } export class TaskItemSvelteAdapter extends MarkdownRenderChild { @@ -20,18 +20,12 @@ export class TaskItemSvelteAdapter extends MarkdownRenderChild { taskSyncManager: ObsidianTaskSyncManager; svelteComponent: SvelteComponent; plugin: TaskCardPlugin; - // params: TaskItemParams; - + constructor(taskSync: ObsidianTaskSyncProps, plugin: TaskCardPlugin) { super(taskSync.taskItemEl); this.taskSync = taskSync; this.taskSyncManager = new ObsidianTaskSyncManager(plugin, taskSync); this.plugin = plugin; - // if (taskSync.obsidianTask.metadata.taskItemParams) { - // this.params = taskSync.obsidianTask.metadata.taskItemParams; - // } else { - // this.params = { mode: get(SettingStore).displaySettings.defaultMode as TaskMode }; - // } } onload() { diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 5c51f0d..b89abfa 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -1,22 +1,22 @@ import { Writable, writable } from 'svelte/store'; -import { TaskMode } from './postProcessor'; +import { TaskDisplayMode } from './postProcessor'; import { SettingStore } from '../settings'; import { MarkdownView, Workspace, WorkspaceLeaf } from 'obsidian'; import { logger } from '../utils/log'; import { ObsidianTaskSyncProps } from '../taskModule/taskSyncManager'; export class TaskStore { - private taskModes: Writable<{ [key: string]: TaskMode }>; + private taskModes: Writable<{ [key: string]: TaskDisplayMode }>; public readonly subscribe: Function; private filePath: string = ''; - private defaultMode: TaskMode = 'single-line'; // Default value + private defaultMode: TaskDisplayMode = 'single-line'; // Default value constructor() { this.taskModes = writable({}); this.subscribe = this.taskModes.subscribe; SettingStore.subscribe((settings) => { - this.defaultMode = settings.displaySettings.defaultMode as TaskMode; + this.defaultMode = settings.displaySettings.defaultMode as TaskDisplayMode; }); } @@ -26,44 +26,44 @@ export class TaskStore { if (!view.file) { return; } const newFilePath = view.file.path; const mode = view.getMode(); - if (mode !== 'preview') { this.clearTaskModes(); } + if (mode !== 'preview') { this.clearTaskDisplayModes(); } this.setFilePath(newFilePath); } - private clearTaskModes(): void { + private clearTaskDisplayModes(): void { this.taskModes.set({}); } private setFilePath(newFilePath: string): void { if (newFilePath !== this.filePath) { this.filePath = newFilePath; - this.clearTaskModes(); + this.clearTaskDisplayModes(); } } - getDefaultMode(): TaskMode { + getDefaultMode(): TaskDisplayMode { return this.defaultMode; } // Mode-related CRUD Operations (By Line Numbers) - setModeByLine(startLine: number, endLine: number, newMode: TaskMode = this.defaultMode): void { + setModeByLine(startLine: number, endLine: number, newMode: TaskDisplayMode = this.defaultMode): void { this.updateMode(this.generateKey(startLine, endLine), newMode); } - getModeByLine(startLine: number, endLine: number): TaskMode | null { + getModeByLine(startLine: number, endLine: number): TaskDisplayMode | null { return this.getModeByKey(this.generateKey(startLine, endLine)); } - updateModeByLine(startLine: number, endLine: number, newMode: TaskMode): void { + updateModeByLine(startLine: number, endLine: number, newMode: TaskDisplayMode): void { this.ensureMode(this.generateKey(startLine, endLine), newMode); } // Mode-related CRUD Operations (By Key) - setModeByKey(key: string, newMode: TaskMode = this.defaultMode): void { + setModeByKey(key: string, newMode: TaskDisplayMode = this.defaultMode): void { this.updateMode(key, newMode); } - getModeByKey(key: string): TaskMode | null { + getModeByKey(key: string): TaskDisplayMode | null { let mode = null; this.taskModes.subscribe((modes) => { mode = modes[key] || null; @@ -71,25 +71,25 @@ export class TaskStore { return mode; } - updateModeByKey(key: string, newMode: TaskMode): void { + updateModeByKey(key: string, newMode: TaskDisplayMode): void { this.ensureMode(key, newMode); } // Mode-related CRUD Operations (By Task Sync) - setModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskMode = this.defaultMode): void { + setModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskDisplayMode = this.defaultMode): void { this.updateMode(this.generateKeyFromSync(taskSync), newMode); } - getModeBySync(taskSync: ObsidianTaskSyncProps): TaskMode | null { + getModeBySync(taskSync: ObsidianTaskSyncProps): TaskDisplayMode | null { return this.getModeByKey(this.generateKeyFromSync(taskSync)); } - updateModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskMode): void { + updateModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskDisplayMode): void { this.ensureMode(this.generateKeyFromSync(taskSync), newMode); } // Ensure Mode Exists - private ensureMode(key: string, newMode: TaskMode): void { + private ensureMode(key: string, newMode: TaskDisplayMode): void { this.taskModes.update((modes) => { if (modes[key]) { modes[key] = newMode; @@ -99,7 +99,7 @@ export class TaskStore { } // Get All Modes - getAllModes(): { [key: string]: TaskMode } { + getAllModes(): { [key: string]: TaskDisplayMode } { let modes; this.taskModes.subscribe((currentModes) => { modes = { ...currentModes }; @@ -112,7 +112,7 @@ export class TaskStore { return `${startLine}-${endLine}`; } - private updateMode(key: string, newMode: TaskMode): void { + private updateMode(key: string, newMode: TaskDisplayMode): void { this.taskModes.update((modes) => { modes[key] = newMode; return modes; diff --git a/src/taskModule/project/index.ts b/src/taskModule/project/index.ts index 46a276f..bc5be3f 100644 --- a/src/taskModule/project/index.ts +++ b/src/taskModule/project/index.ts @@ -28,7 +28,7 @@ export class ProjectModule { private sortProjectsByName(): void { this.projects = new Map([...this.projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name))); - logger.debug(`projects are sorted: ${JSON.stringify([...this.projects.values()])}`); + // logger.debug(`projects are sorted: ${JSON.stringify([...this.projects.values()])}`); } diff --git a/src/taskModule/task.ts b/src/taskModule/task.ts index 8e318d4..15d83f9 100644 --- a/src/taskModule/task.ts +++ b/src/taskModule/task.ts @@ -2,7 +2,8 @@ import type { Static } from 'runtypes'; import { String } from 'runtypes'; import { v4 as uuidv4 } from 'uuid'; import { Project } from './project'; -import { TaskItemParams } from '../renderer/postProcessor'; +import { TaskDisplayParams } from '../renderer/postProcessor'; +import { logger } from '../utils/log'; export const DateOnly = String.withConstraint((s) => /^\d{4}-\d{2}-\d{2}$/.test(s) @@ -32,12 +33,12 @@ export interface TaskProperties { labels: string[]; completed: boolean; - parent?: TaskProperties | null; - children: TaskProperties[]; + parent?: TaskProperties | ObsidianTask | null; + children: TaskProperties[] | ObsidianTask[]; due?: DueDate | null; metadata?: { - taskItemParams?: TaskItemParams | null; + taskDisplayParams?: TaskDisplayParams | null; [key: string]: any; }; } @@ -60,7 +61,7 @@ export class ObsidianTask implements TaskProperties { public due?: DueDate | null; public metadata?: { - taskItemParams?: TaskItemParams | null; + taskDisplayParams?: TaskDisplayParams | null; [key: string]: any; }; @@ -111,14 +112,14 @@ export class ObsidianTask implements TaskProperties { return !!this.due.string; } - setTaskItemParams(key: string, value: any): void { - this.metadata.taskItemParams = { - ...this.metadata.taskItemParams, + setTaskDisplayParams(key: string, value: any): void { + this.metadata.taskDisplayParams = { + ...this.metadata.taskDisplayParams, [key]: value }; } - clearTaskItemParams(): void { - this.metadata.taskItemParams = null; + clearTaskDisplayParams(): void { + this.metadata.taskDisplayParams = null; } } diff --git a/src/taskModule/taskFormatter.ts b/src/taskModule/taskFormatter.ts index db257de..64ad7f7 100644 --- a/src/taskModule/taskFormatter.ts +++ b/src/taskModule/taskFormatter.ts @@ -1,4 +1,4 @@ -import { TaskMode } from '../renderer/postProcessor'; +import { TaskDisplayMode } from '../renderer/postProcessor'; import { SettingStore } from '../settings'; import { logger } from '../utils/log'; import { camelToKebab } from '../utils/stringCaseConverter'; @@ -8,6 +8,7 @@ export class TaskFormatter { indicatorTag: string; markdownSuffix: string; defaultMode: string; + specialAttributes: string[] = ['completed', 'content', 'labels']; constructor(settingsStore: typeof SettingStore) { // Subscribe to the settings store @@ -19,18 +20,20 @@ export class TaskFormatter { } taskToMarkdown(task: ObsidianTask): string { - let markdownLine = `- [${task.completed ? 'x' : ' '}] ${task.content} #${ - this.indicatorTag - }\n`; - - // add TaskItemParams to task - if (!task.metadata.taskItemParams) { - task.metadata.taskItemParams = { mode: this.defaultMode as TaskMode }; + const taskPrefix = `- [${task.completed ? 'x' : ' '}]`; + const labelMarkdown = task.labels.join(' '); + let markdownLine = `${taskPrefix} ${task.content} ${labelMarkdown} #${this.indicatorTag}`; + markdownLine = markdownLine.replace(/\s+/g, ' '); // remove multiple spaces + markdownLine += '\n'; + + // add TaskDisplayParams to task + if (!task.metadata.taskDisplayParams) { + task.metadata.taskDisplayParams = { mode: this.defaultMode as TaskDisplayMode }; } - // Iterate over keys in task, but exclude 'completed' and 'content' + // Iterate over keys in task, but exclude special attributes for (let key in task) { - if (key === 'completed' || key === 'content') continue; + if (this.specialAttributes.includes(key)) continue; let value = task[key]; if (value === undefined) { diff --git a/src/taskModule/taskMonitor.ts b/src/taskModule/taskMonitor.ts index b717bea..8486f65 100644 --- a/src/taskModule/taskMonitor.ts +++ b/src/taskModule/taskMonitor.ts @@ -1,5 +1,8 @@ import { App, MarkdownView, TFile, WorkspaceLeaf } from 'obsidian'; import TaskCardPlugin from '..'; +import { Notice } from 'obsidian'; +import { logger } from '../utils/log'; + export class TaskMonitor { plugin: TaskCardPlugin; @@ -38,8 +41,14 @@ export class TaskMonitor { } updateTaskInLine(line: string, index: number): string { + function announceError(errorMsg: string): void { + // Show a notice popup + new Notice(errorMsg); + // Log the error + logger.error(errorMsg); + } if (this.plugin.taskValidator.isValidUnformattedTaskMarkdown(line)) { - const task = this.plugin.taskParser.parseTaskMarkdown(line); + const task = this.plugin.taskParser.parseTaskMarkdown(line, announceError); return this.plugin.taskFormatter.taskToMarkdownOneLine(task); } return line; diff --git a/src/taskModule/taskParser.ts b/src/taskModule/taskParser.ts index 9490acd..d5ee706 100644 --- a/src/taskModule/taskParser.ts +++ b/src/taskModule/taskParser.ts @@ -6,7 +6,6 @@ import { DueDate, ObsidianTask, TaskProperties } from './task'; import { Project, ProjectModule } from './project'; import Sugar from 'sugar'; import { SettingStore } from '../settings'; -import { Notice } from 'obsidian'; export class TaskParser { indicatorTag: string; @@ -29,6 +28,18 @@ export class TaskParser { this.projectModule = projectModule; } + + // New method to parse labels from task content + parseLabelsFromContent(taskEl: Element): string[] { + const tags = taskEl.querySelectorAll("a.tag"); + const labels: string[] = []; + tags.forEach((tagElement) => { + const tagContent = tagElement.textContent || ""; + if (tagContent) labels.push(tagContent); + }); + return labels; + } + parseTaskEl(taskEl: Element): ObsidianTask { function parseQuery(queryName: string, defaultValue: string = '') { try { @@ -45,10 +56,6 @@ export class TaskParser { const task = new ObsidianTask(); task.id = parseQuery('id', '') as string; - task.content = - taskEl - .querySelector('.task-list-item-checkbox') - ?.nextSibling?.textContent?.trim() || ''; task.priority = parseQuery('priority', '1') as TaskProperties['priority']; task.description = parseQuery( 'description', @@ -60,7 +67,47 @@ export class TaskParser { 'section-id', '' ) as TaskProperties['sectionID']; - task.labels = parseQuery('labels', '[]') as TaskProperties['labels']; + + // Get labels from span + let labelsFromSpan = parseQuery('labels', '[]') as TaskProperties['labels']; + + // Get labels from content + let labelsFromContent = this.parseLabelsFromContent(taskEl); + + // Concatenate and filter unique labels + task.labels = Array.from(new Set([...labelsFromSpan, ...labelsFromContent])).filter( + (label) => label !== `#${this.indicatorTag}` + ); + + // Make sure the each label starts with exactly one "#" + task.labels = task.labels.map(label => { + // Remove all leading '#' characters + const cleanedLabel = label.replace(/^#+/, ''); + // Add a single '#' at the beginning + return '#' + cleanedLabel; + }); + + + // Isolate the task content excluding tags// Get reference to the input checkbox element + const checkboxElement = taskEl.querySelector('input.task-list-item-checkbox'); + + if (checkboxElement) { + let currentNode: Node | null = checkboxElement; + let content = ''; + + // Traverse through next siblings to accumulate text content + while ((currentNode = currentNode.nextSibling) !== null) { + if (currentNode.nodeType === 3) { // Node.TEXT_NODE + content += currentNode.textContent?.trim() + ' '; + } + if (currentNode.nodeType === 1 && (currentNode as Element).tagName === 'A') { // Node.ELEMENT_NODE + break; + } + } + + task.content = content.trim(); + } + const checkbox = taskEl.querySelector( '.task-list-item-checkbox' ) as HTMLInputElement; @@ -78,8 +125,47 @@ export class TaskParser { return task; } - parseTaskMarkdown(taskMarkdown: string): ObsidianTask { // TODO: optimize this function + parseTaskMarkdown(taskMarkdown: string, noticeFunc: (msg: string) => void = null): ObsidianTask { const task: ObsidianTask = new ObsidianTask(); + const errors: string[] = []; + + const tryParseAttribute = ( + attributeName: string, + parseFunc: (val: string) => any, + value: string, + type: string + ) => { + try { + let parsedValue = parseFunc(value); + if (parsedValue === null) { + throw new Error(`Failed to parse ${attributeName}: ${value}`); + } + + console.log(`Parsed ${attributeName}: ${parsedValue}`); + + // Type specific parsing if needed + switch (type) { + case 'array': + parsedValue = toArray(parsedValue); + break; + case 'boolean': + parsedValue = toBoolean(parsedValue); + break; + case 'string': + break; + case 'other': + break; + default: + parsedValue = JSON.parse(parsedValue); + break; + } + return parsedValue; + } catch (e) { + errors.push(`${attributeName} attribute error: ${e.message}`); + return null; + } + }; + // Splitting the content and the attributes const contentEndIndex = taskMarkdown.indexOf(this.markdownStartingNotation); @@ -94,7 +180,9 @@ export class TaskParser { // Extracting labels from the content line const [contentLabels, remainingContent] = extractTags(contentWithLabels); task.content = remainingContent; - task.labels = contentLabels.filter((label) => label !== this.indicatorTag); + task.labels = contentLabels.filter((label) => label !== `#${this.indicatorTag}`); + + // logger.debug(` parse markdown labels: ${JSON.stringify(task.labels)}, content: ${task.content}`); // Parsing attributes const attributesString = taskMarkdown.slice(contentEndIndex); @@ -131,68 +219,33 @@ export class TaskParser { switch (attributeName) { case 'due': - try { - const parsedDue = this.parseDue(attributeValue); - if (!parsedDue) { - throw new Error(`Failed to parse due date: ${attributeValue}`); - } - task.due = parsedDue; - parsedAttributeNames.push('due'); - } catch (e) { - console.error(`Failed to parse due date: ${e.message}`); - new Notice(`[TaskCard] Failed to parse due date: ${e.message}`); - } + task.due = tryParseAttribute('due', this.parseDue.bind(this), attributeValue, 'other'); break; case 'project': - try { - const parsedProject = this.parseProject(attributeValue); - if (!parsedProject) { - throw new Error(`Failed to parse project: ${attributeValue}`); - } - task.project = parsedProject; - parsedAttributeNames.push('project'); - } catch (e) { - console.error( - `Cannot find project: ${attributeValue}, error: ${e.message}` - ); - new Notice(`[TaskCard] Failed to parse project: ${e.message}`); - } + task.project = tryParseAttribute('project', this.parseProject.bind(this), attributeValue, 'string'); break; case 'metadata': - try { - task.metadata = JSON.parse(attributeValue); - parsedAttributeNames.push('metadata'); - } catch (e) { - console.error(`Failed to parse metadata attribute: ${e.message}`); - } + task.metadata = tryParseAttribute('metadata', JSON.parse, attributeValue, 'other'); break; default: - // Explicitly assert the type of the key const taskKey = attributeName as keyof ObsidianTask; - - // Only assign the value if the key exists on ObsidianTask and parse it with the correct type if (taskKey in task) { - try { - if (Array.isArray(task[taskKey])) { - (task[taskKey] as any) = toArray(attributeValue); - } else if (typeof task[taskKey] === 'boolean') { - (task[taskKey] as any) = toBoolean(attributeValue); - } else if (typeof task[taskKey] === 'string') { - (task[taskKey] as any) = attributeValue; - } else { - (task[taskKey] as any) = JSON.parse(attributeValue); - } + const type = typeof task[taskKey]; + (task as any)[taskKey] = tryParseAttribute(attributeName, (val) => val, attributeValue, type); + if (task[taskKey] !== null) { parsedAttributeNames.push(taskKey); - } catch (e) { - logger.error( - `Failed to convert value for key ${taskKey}: ${e.message}` - ); } } break; } } + if (noticeFunc && errors.length > 0) { + for (const error of errors) { + noticeFunc(error); + } + } + return task; } @@ -200,14 +253,14 @@ export class TaskParser { const parsedDateTime = Sugar.Date.create(dueString); // Check if the parsedDateTime is a valid date - if (!parsedDateTime) { + if (!parsedDateTime || !Sugar.Date.isValid(parsedDateTime)) { return null; } const parsedDate = Sugar.Date.format(parsedDateTime, '{yyyy}-{MM}-{dd}'); const parsedTime = Sugar.Date.format(parsedDateTime, '{HH}:{mm}'); - const isDateOnly = parsedTime === '00:00'; + const isDateOnly = ['00:00', '23:59'].includes(parsedTime); if (isDateOnly) { return { diff --git a/src/taskModule/taskSyncManager.ts b/src/taskModule/taskSyncManager.ts index b47e97e..4936521 100644 --- a/src/taskModule/taskSyncManager.ts +++ b/src/taskModule/taskSyncManager.ts @@ -72,7 +72,7 @@ export class ObsidianTaskSyncManager implements ObsidianTaskSyncProps { async updateMarkdownTaskToFile(markdownTask: string): Promise { const [docLineStart, docLineEnd] = this.getDocLineStartEnd(); - logger.debug(`updating markdownTask from ${docLineStart} to ${docLineEnd}`); + // logger.debug(`updating markdownTask from ${docLineStart} to ${docLineEnd}`); await this.plugin.fileOperator.updateFile( this.taskMetadata.sourcePath, markdownTask, @@ -87,13 +87,13 @@ export class ObsidianTaskSyncManager implements ObsidianTaskSyncProps { this.updateTaskToFile(); } - updateObsidianTaskItemParams(key: string, value: any): void { - this.obsidianTask.setTaskItemParams(key, value); + updateObsidianTaskDisplayParams(key: string, value: any): void { + this.obsidianTask.setTaskDisplayParams(key, value); this.updateTaskToFile(); } - clearObsidianTaskItemParams(): void { - this.obsidianTask.clearTaskItemParams(); + clearObsidianTaskDisplayParams(): void { + this.obsidianTask.clearTaskDisplayParams(); this.updateTaskToFile(); } diff --git a/src/taskModule/taskValidator.ts b/src/taskModule/taskValidator.ts index 463fb5c..7728b84 100644 --- a/src/taskModule/taskValidator.ts +++ b/src/taskModule/taskValidator.ts @@ -7,8 +7,6 @@ import { camelToKebab } from '../utils/stringCaseConverter'; export type SpanElements = Record; export class TaskValidator { - private static formattedMarkdownPattern: RegExp = - /^\s*- \[[^\]]\](.*?)(