From 2f2414e70d7eebde8391b8e087be66e4efdf1266 Mon Sep 17 00:00:00 2001 From: terryli710 Date: Thu, 24 Aug 2023 17:28:49 -0700 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20correctly=20valid=20task=20+=20?= =?UTF-8?q?test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODOs.md | 1 + src/autoSuggestions/Suggester.ts | 4 +- src/renderer/postProcessor.ts | 8 +--- src/taskModule/task.ts | 4 +- src/taskModule/taskParser.ts | 35 ++++++++++++--- src/taskModule/taskValidator.ts | 35 +++++++-------- tests/suggester.test.ts | 12 +++--- tests/taskFormatter.test.ts | 6 ++- tests/taskParser.test.ts | 74 +++++++++++++++++++++++++++++--- tests/taskValidator.test.ts | 3 +- 10 files changed, 136 insertions(+), 46 deletions(-) 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/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..3a2f79f 100644 --- a/src/renderer/postProcessor.ts +++ b/src/renderer/postProcessor.ts @@ -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/taskModule/task.ts b/src/taskModule/task.ts index 8e318d4..3b6d569 100644 --- a/src/taskModule/task.ts +++ b/src/taskModule/task.ts @@ -32,8 +32,8 @@ export interface TaskProperties { labels: string[]; completed: boolean; - parent?: TaskProperties | null; - children: TaskProperties[]; + parent?: TaskProperties | ObsidianTask | null; + children: TaskProperties[] | ObsidianTask[]; due?: DueDate | null; metadata?: { diff --git a/src/taskModule/taskParser.ts b/src/taskModule/taskParser.ts index 9490acd..7d8a57f 100644 --- a/src/taskModule/taskParser.ts +++ b/src/taskModule/taskParser.ts @@ -29,6 +29,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 +57,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 +68,24 @@ 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 + ); + + // Isolate the task content excluding tags + const contentElement = taskEl.querySelector('.task-list-item-checkbox')?.nextElementSibling; + if (contentElement) { + task.content = contentElement.childNodes[0].textContent?.trim() || ''; + } + const checkbox = taskEl.querySelector( '.task-list-item-checkbox' ) as HTMLInputElement; diff --git a/src/taskModule/taskValidator.ts b/src/taskModule/taskValidator.ts index 463fb5c..dd5dee5 100644 --- a/src/taskModule/taskValidator.ts +++ b/src/taskModule/taskValidator.ts @@ -61,14 +61,17 @@ export class TaskValidator { } isValidFormattedTaskMarkdown(taskMarkdown: string): boolean { + logger.debug('isValidFormattedTaskMarkdown: taskMarkdown', taskMarkdown); // at least one span element if (!this.hasSpanElement(taskMarkdown)) return false; const match = TaskValidator.formattedMarkdownPattern.exec(taskMarkdown); + logger.debug('isValidFormattedTaskMarkdown: match', match); if (match && match[1]) { const contentWithoutAttributes = match[1] .replace(this.getAttributePattern(), '') .trim(); + logger.debug('isValidFormattedTaskMarkdown: contentWithoutAttributes', contentWithoutAttributes); return this.hasIndicatorTag(contentWithoutAttributes); } return false; @@ -118,34 +121,32 @@ export class TaskValidator { private checkTaskElementClass(taskElement: HTMLElement): boolean { // Check if the element contains a child with the class 'task-list-item-checkbox' - if (!taskElement.querySelector('.task-list-item-checkbox')) { - return false; - } + if (!taskElement.querySelector('.task-list-item-checkbox')) return false; // Check if the element contains a child with the class 'list-bullet' - if (!taskElement.querySelector('.list-bullet')) { - return false; - } + if (!taskElement.querySelector('.list-bullet')) return false; // Check indicator tag - if (!this.checkTaskElementIndicatorTag(taskElement)) { - return false; - } + if (!this.checkTaskElementIndicatorTag(taskElement)) return false; return true; } private checkTaskElementIndicatorTag(taskElement: HTMLElement): boolean { - // Check if the element contains a child with the class 'tag' and the text `#${tagName}` - const tagElement = taskElement.querySelector('.tag'); - if ( - !tagElement || - !tagElement.textContent?.includes(`#${this.indicatorTag}`) - ) { - return false; + // Find all elements with the class 'tag' + const tagElements = taskElement.querySelectorAll('.tag'); + + // Loop through each tag element to see if it contains the indicator tag + for (const tagElement of tagElements) { + if (tagElement.textContent?.includes(`#${this.indicatorTag}`)) { + return true; // Found the indicator tag, so return true + } } - return true; + + // If the loop completes without finding the indicator tag, return false + return false; } + isValidTaskElement(taskElement: HTMLElement): boolean { if (!this.checkTaskElementClass(taskElement)) { diff --git a/tests/suggester.test.ts b/tests/suggester.test.ts index 01a6647..2e1f22a 100644 --- a/tests/suggester.test.ts +++ b/tests/suggester.test.ts @@ -8,7 +8,7 @@ import { import { logger } from '../src/utils/log'; describe('AttributeSuggester', () => { - let suggester; + let suggester: AttributeSuggester; let mockSettingStore; beforeEach(() => { @@ -29,10 +29,10 @@ describe('AttributeSuggester', () => { suggester = new AttributeSuggester(mockSettingStore); }); - it('initializes startingNotation and endingNotation from settingsStore', () => { - expect(suggester.startingNotation).toBe('{{'); - expect(suggester.endingNotation).toBe('}}'); - }); + // it('initializes startingNotation and endingNotation from settingsStore', () => { + // expect(suggester.startingNotation).toBe('{{'); + // expect(suggester.endingNotation).toBe('}}'); + // }); it('builds suggestions correctly', () => { const lineText = '{{ '; @@ -57,7 +57,7 @@ describe('AttributeSuggester', () => { it('gets due suggestions', () => { const lineText = '{{ due: t }}'; - const cursorPos = 8; + const cursorPos = 9; const suggestions = suggester.getDueSuggestions(lineText, cursorPos); expect(suggestions).toHaveLength(4); // Assuming 4 due suggestions are returned }); diff --git a/tests/taskFormatter.test.ts b/tests/taskFormatter.test.ts index 97fb02d..bd67e35 100644 --- a/tests/taskFormatter.test.ts +++ b/tests/taskFormatter.test.ts @@ -10,7 +10,11 @@ describe('taskToMarkdown', () => { // Mock the SettingStore with controlled settings mockSettingStore = writable({ parsingSettings: { - indicatorTag: 'TaskCard' + indicatorTag: 'TaskCard', + markdownSuffix: ' .', + }, + displaySettings: { + defaultMode: 'single-line', } }); taskFormatter = new TaskFormatter(mockSettingStore); diff --git a/tests/taskParser.test.ts b/tests/taskParser.test.ts index d4c1bc5..ccba116 100644 --- a/tests/taskParser.test.ts +++ b/tests/taskParser.test.ts @@ -1,5 +1,5 @@ import { TaskParser } from '../src/taskModule/taskParser'; -import { ObsidianTask, DateOnly } from '../src/taskModule/task'; +import { ObsidianTask, DateOnly, TaskProperties } from '../src/taskModule/task'; import { JSDOM } from 'jsdom'; import { logger } from '../src/utils/log'; import { writable } from 'svelte/store'; @@ -87,9 +87,9 @@ describe('taskParser', () => { infoSpy.mockRestore(); }); - let mockProjectModule; + let mockProjectModule: ProjectModule; let mockSettingStore; - let taskParser; + let taskParser: TaskParser; let projects: Project[]; beforeEach(() => { // Mock the SettingStore with controlled settings @@ -118,6 +118,69 @@ describe('taskParser', () => { }); describe('parseTaskEl', () => { + + it('should merge labels from both hidden span and content', () => { + const dom = new JSDOM(); + const document = dom.window.document; + const taskElement = createTestTaskElement(document); + const parsedTask = taskParser.parseTaskEl(taskElement); + + // Expecting labels to contain both 'label1', 'label2' from the hidden span and '#TaskCard' from the content + expect(parsedTask.labels).toEqual(['label1', 'label2', '#TaskCard']); + }); + + it('should filter out duplicate labels', () => { + const dom = new JSDOM(); + const document = dom.window.document; + const taskElement = createTestTaskElement(document); + + // Adding another span to introduce a duplicate label + const duplicateLabelSpan = document.createElement('span'); + duplicateLabelSpan.className = 'labels'; + duplicateLabelSpan.style.display = 'none'; + duplicateLabelSpan.textContent = '["#TaskCard"]'; + taskElement.appendChild(duplicateLabelSpan); + + const parsedTask = taskParser.parseTaskEl(taskElement); + + // Expecting labels to contain 'label1', 'label2', and '#TaskCard' without any duplicates + expect(parsedTask.labels).toEqual(['label1', 'label2', '#TaskCard']); + }); + + it('should handle only hidden span labels when no content labels are present', () => { + const dom = new JSDOM(); + const document = dom.window.document; + const taskElement = createTestTaskElement(document); + + // Removing content label + const tagElement = taskElement.querySelector('a.tag'); + if (tagElement) { + taskElement.removeChild(tagElement); + } + + const parsedTask = taskParser.parseTaskEl(taskElement); + + // Expecting labels to contain only 'label1', 'label2' from the hidden span + expect(parsedTask.labels).toEqual(['label1', 'label2']); + }); + + it('should handle only content labels when no hidden span labels are present', () => { + const dom = new JSDOM(); + const document = dom.window.document; + const taskElement = createTestTaskElement(document); + + // Removing hidden span labels + const labelsSpan = taskElement.querySelector('span.labels'); + if (labelsSpan) { + taskElement.removeChild(labelsSpan); + } + + const parsedTask = taskParser.parseTaskEl(taskElement); + + // Expecting labels to contain only '#TaskCard' from the content + expect(parsedTask.labels).toEqual(['#TaskCard']); + }); + it('should parse a task element correctly', () => { // Create a test task element using the new task HTML structure const dom = new JSDOM(); @@ -125,7 +188,7 @@ describe('taskParser', () => { const taskElement = createTestTaskElement(document); // Expected task object - const expectedTask: ObsidianTask = { + const expectedTask: TaskProperties = { id: '', content: 'An example task', priority: 4, @@ -166,7 +229,8 @@ describe('taskParser', () => { const taskElement = createTestTaskElement(document); // Expected task object without the id property - const expectedTask = { + const expectedTask: TaskProperties = { + id: '', content: 'An example task', priority: 4, description: '- A multi line description.\n- the second line.', diff --git a/tests/taskValidator.test.ts b/tests/taskValidator.test.ts index 9bbcd6f..06a15a4 100644 --- a/tests/taskValidator.test.ts +++ b/tests/taskValidator.test.ts @@ -14,7 +14,8 @@ describe('TaskValidator', () => { parsingSettings: { indicatorTag: 'TaskCard', markdownStartingNotation: '%%*', - markdownEndingNotation: '*%%' + markdownEndingNotation: '*%%', + markdownSuffix: ' .' } }); From 143b466890ceb0161f1f73a51a8e2315343a8623 Mon Sep 17 00:00:00 2001 From: terryli710 Date: Thu, 24 Aug 2023 21:48:12 -0700 Subject: [PATCH 2/6] tests and task parser updates --- jest-setup.js | 3 + src/autoSuggestions/Suggester.ts | 1 + src/taskModule/taskMonitor.ts | 11 ++- src/taskModule/taskParser.ts | 130 ++++++++++++++++++------------- src/taskModule/taskValidator.ts | 13 ++-- tests/taskFormatter.test.ts | 4 +- tests/taskParser.test.ts | 63 ++++++++------- tests/taskValidator.test.ts | 79 +++++++++---------- 8 files changed, 174 insertions(+), 130 deletions(-) create mode 100644 jest-setup.js 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 dfaf16e..1f9824a 100644 --- a/src/autoSuggestions/Suggester.ts +++ b/src/autoSuggestions/Suggester.ts @@ -137,6 +137,7 @@ export class AttributeSuggester { // Get the due date query from the captured group const dueQuery = (dueMatch[1] || '').trim(); + logger.debug(`Due query: ${dueQuery}`); const dueStringSelections = [ 'today', 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 7d8a57f..288d5c4 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; @@ -77,15 +76,33 @@ export class TaskParser { // Concatenate and filter unique labels task.labels = Array.from(new Set([...labelsFromSpan, ...labelsFromContent])).filter( - (label) => label !== this.indicatorTag + (label) => label !== `#${this.indicatorTag}` ); - // Isolate the task content excluding tags - const contentElement = taskEl.querySelector('.task-list-item-checkbox')?.nextElementSibling; - if (contentElement) { - task.content = contentElement.childNodes[0].textContent?.trim() || ''; + // 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; @@ -103,8 +120,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); @@ -154,70 +210,36 @@ export class TaskParser { continue; } + 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; } diff --git a/src/taskModule/taskValidator.ts b/src/taskModule/taskValidator.ts index dd5dee5..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*- \[[^\]]\](.*?)(