diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..1632a73dd4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.js text eol=lf +*.ts text eol=lf diff --git a/esbuild.config.mjs b/esbuild.config.mjs index d77842aa68..45ceab8758 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,11 +1,10 @@ -import esbuild from "esbuild"; -import process from "process"; -import builtins from 'builtin-modules' -import esbuildSvelte from "esbuild-svelte"; -import sveltePreprocess from "svelte-preprocess"; - -const banner = -`/* +import process from 'process'; +import esbuild from 'esbuild'; +import builtins from 'builtin-modules'; +import esbuildSvelte from 'esbuild-svelte'; +import sveltePreprocess from 'svelte-preprocess'; + +const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source visit the plugins github repository */ @@ -130,44 +129,48 @@ THE SOFTWARE. */ `; -const prod = (process.argv[2] === 'production'); - -esbuild.build({ - banner: { - js: banner, - }, - bundle: true, - entryPoints: ['main.ts'], - external: [ - 'obsidian', - 'electron', - 'codemirror', - '@codemirror/autocomplete', - '@codemirror/closebrackets', - '@codemirror/commands', - '@codemirror/fold', - '@codemirror/gutter', - '@codemirror/history', - '@codemirror/language', - '@codemirror/rangeset', - '@codemirror/rectangular-selection', - '@codemirror/search', - '@codemirror/state', - '@codemirror/stream-parser', - '@codemirror/text', - '@codemirror/view', - ...builtins - ], - format: 'cjs', - logLevel: "info", - outfile: 'main.js', - plugins: [ - esbuildSvelte({ - preprocess: sveltePreprocess() - }), - ], - sourcemap: prod ? false : 'inline', - target: 'es2016', - treeShaking: true, - watch: !prod, -}).catch(() => process.exit(1)); +const prod = process.argv[2] === 'production'; +const dev = process.argv[2] === 'development'; + +esbuild + .build({ + banner: { + js: banner, + }, + bundle: true, + entryPoints: ['main.ts'], + external: [ + 'obsidian', + 'electron', + 'codemirror', + '@codemirror/autocomplete', + '@codemirror/closebrackets', + '@codemirror/commands', + '@codemirror/fold', + '@codemirror/gutter', + '@codemirror/history', + '@codemirror/language', + '@codemirror/rangeset', + '@codemirror/rectangular-selection', + '@codemirror/search', + '@codemirror/state', + '@codemirror/stream-parser', + '@codemirror/text', + '@codemirror/view', + ...builtins, + ], + format: 'cjs', + logLevel: 'info', + minify: prod ? true : false, + outfile: 'main.js', + plugins: [ + esbuildSvelte({ + preprocess: sveltePreprocess(), + }), + ], + sourcemap: prod ? false : 'inline', + target: 'es2016', + treeShaking: true, + watch: !prod && !dev, + }) + .catch(() => process.exit(1)); diff --git a/package.json b/package.json index 0264022256..c60a46f7cd 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "scripts": { "dev": "node esbuild.config.mjs", "build": "node esbuild.config.mjs production", + "build:dev": "node esbuild.config.mjs development", "lint": "eslint ./src --fix && eslint ./tests --fix && tsc --noEmit --pretty && svelte-check", "test": "jest --ci", - "test:dev": "jest --watch" + "test:dev": "jest --watch", + "test:obsidian": "pwsh -ExecutionPolicy Unrestricted -NoProfile -File ./scripts/Test-TasksInLocalObsidian.ps1" }, "keywords": [ "obsidian", diff --git a/scripts/Test-TasksInLocalObsidian.ps1 b/scripts/Test-TasksInLocalObsidian.ps1 new file mode 100644 index 0000000000..caf4de58ca --- /dev/null +++ b/scripts/Test-TasksInLocalObsidian.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param ( + [Parameter(HelpMessage = 'The path to the plugins folder uner the .obsidian directory.')] + [String] + $ObsidianPluginRoot = $env:OBSIDIAN_PLUGIN_ROOT, + [Parameter(HelpMessage = 'The folder name of the plugin to copy the files to.')] + [String] + $PluginFolderName = 'obsidian-tasks-plugin' +) + +$repoRoot = (Resolve-Path -Path $(git rev-parse --show-toplevel)).Path + +if (-not (Test-Path $ObsidianPluginRoot)) { + Write-Error "Obsidian plugin root not found: $ObsidianPluginRoot" + return +} else { + Write-Host "Obsidian plugin root found: $ObsidianPluginRoot" +} + +Push-Location $repoRoot +Write-Host "Repo root: $repoRoot" + +yarn run build:dev + +if ($?) { + Write-Output 'Build successful' + + $filesToLink = @('main.js', 'styles.css', 'manifest.json') + + foreach ($file in $filesToLink ) { + if ((Get-Item "$ObsidianPluginRoot/$PluginFolderName/$file" ).LinkType -ne 'SymbolicLink') { + Write-Output "Removing $file from plugin folder and linking" + Remove-Item "$ObsidianPluginRoot/$PluginFolderName/$file" -Force + New-Item -ItemType SymbolicLink -Path "$ObsidianPluginRoot/$PluginFolderName/$file" -Target "$repoRoot/$file" + } else { + (Get-Item "$ObsidianPluginRoot/$PluginFolderName/$file" ).LinkType + } + } + + $hasHotReload = Test-Path "$ObsidianPluginRoot/$PluginFolderName/.hotreload" + + if (!$hasHotReload) { + Write-Output 'Creating hotreload file' + '' | Set-Content "$ObsidianPluginRoot/$PluginFolderName/.hotreload" + } + + yarn run dev + +} else { + Write-Error 'Build failed' +} + +Pop-Location diff --git a/src/Commands/CreateOrEdit.ts b/src/Commands/CreateOrEdit.ts index cfc970d025..629c3b0d2f 100644 --- a/src/Commands/CreateOrEdit.ts +++ b/src/Commands/CreateOrEdit.ts @@ -1,6 +1,8 @@ import { App, Editor, MarkdownView, View } from 'obsidian'; +import { StatusRegistry } from 'StatusRegistry'; import { TaskModal } from '../TaskModal'; -import { Priority, Status, Task } from '../Task'; +import { Status } from '../Status'; +import { Priority, Task } from '../Task'; export const createOrEdit = ( checking: boolean, @@ -64,11 +66,10 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => { // Should never happen; everything in the regex is optional. console.error('Tasks: Cannot create task on line:', line); return new Task({ - status: Status.Todo, + status: Status.TODO, description: '', path, indentation: '', - originalStatusCharacter: ' ', priority: Priority.None, startDate: null, scheduledDate: null, @@ -86,7 +87,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => { const indentation: string = nonTaskMatch[1]; const statusString: string = nonTaskMatch[3] ?? ' '; - const status = statusString === ' ' ? Status.Todo : Status.Done; + const status = StatusRegistry.getInstance().byIndicator(statusString); let description: string = nonTaskMatch[4]; const blockLinkMatch = line.match(Task.blockLinkRegex); @@ -101,7 +102,6 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => { description, path, indentation, - originalStatusCharacter: statusString, blockLink, priority: Priority.None, startDate: null, diff --git a/src/Commands/SelectStatus.ts.txt b/src/Commands/SelectStatus.ts.txt new file mode 100644 index 0000000000..6895f4258c --- /dev/null +++ b/src/Commands/SelectStatus.ts.txt @@ -0,0 +1,120 @@ +// import { Editor, MarkdownView, View } from 'obsidian'; + +// import { Task } from '../Task'; + +// import { promptForMark } from '../ui/TaskMarkModal'; +// export const selectStatus = (checking: boolean, editor: Editor, view: View) => { +// const mark = await promptForMark(this.app, this.plugin); +// if (mark) { +// this.markTaskOnLines(mark, this.getCurrentLinesFromEditor(editor)); +// } + +// if (checking) { +// if (!(view instanceof MarkdownView)) { +// // If we are not in a markdown view, the command shouldn't be shown. +// return false; +// } + +// // The command should always trigger in a markdown view: +// // - Convert lines to list items. +// // - Convert list items to tasks. +// // - Toggle tasks' status. +// return true; +// } + +// if (!(view instanceof MarkdownView)) { +// // Should never happen due to check above. +// return; +// } + +// // We are certain we are in the editor due to the check above. +// const path = view.file?.path; +// if (path === undefined) { +// return; +// } + +// const cursorPosition = editor.getCursor(); +// const lineNumber = cursorPosition.line; +// const line = editor.getLine(lineNumber); + +// const toggledLine = toggleLine({ line, path }); +// editor.setLine(lineNumber, toggledLine); + +// // The cursor is moved to the end of the line by default. +// // If there is text on the line, put the cursor back where it was on the line. +// if (/[^ [\]*-]/.test(toggledLine)) { +// editor.setCursor({ +// line: cursorPosition.line, +// // Need to move the cursor by the distance we added to the beginning. +// ch: cursorPosition.ch + toggledLine.length - line.length, +// }); +// } +// }; + +// const toggleLine = ({ line, path }: { line: string; path: string }): string => { +// let toggledLine: string = line; + +// const task = Task.fromLine({ +// line, +// path, +// sectionStart: 0, // We don't need this to toggle it here in the editor. +// sectionIndex: 0, // We don't need this to toggle it here in the editor. +// precedingHeader: null, // We don't need this to toggle it here in the editor. +// }); +// if (task !== null) { +// toggledLine = toggleTask({ task }); +// } else { +// // If the task is null this means that we have one of: +// // 1. a regular checklist item +// // 2. a list item +// // 3. a simple text line + +// // The task regex will match checklist items. +// const regexMatch = line.match(Task.taskRegex); +// if (regexMatch !== null) { +// toggledLine = toggleChecklistItem({ regexMatch }); +// } else { +// // This is not a checklist item. It is one of: +// // 1. a list item +// // 2. a simple text line + +// const listItemRegex = /^([\s\t]*)([-*])/; +// if (listItemRegex.test(line)) { +// // Let's convert the list item to a checklist item. +// toggledLine = line.replace(listItemRegex, '$1$2 [ ]'); +// } else { +// // Let's convert the line to a list item. +// toggledLine = line.replace(/^([\s\t]*)/, '$1- '); +// } +// } +// } + +// return toggledLine; +// }; + +// const toggleTask = ({ task }: { task: Task }): string => { +// // Toggle a regular task. +// const toggledTasks = task.toggle(); +// const serialized = toggledTasks +// .map((task: Task) => task.toFileLineString()) +// .join('\n'); + +// return serialized; +// }; + +// const toggleChecklistItem = ({ +// regexMatch, +// }: { +// regexMatch: RegExpMatchArray; +// }): string => { +// // It's a checklist item, let's toggle it. +// const indentation = regexMatch[1]; +// const statusString = regexMatch[2].toLowerCase(); +// const body = regexMatch[3]; + +// const toggledStatusString = statusString === ' ' ? 'x' : ' '; + +// const toggledLine = `${indentation}- [${toggledStatusString}] ${body}`; + +// return toggledLine; +// }; diff --git a/src/Commands/index.ts b/src/Commands/index.ts index fb9fa93303..008fb7fe7d 100644 --- a/src/Commands/index.ts +++ b/src/Commands/index.ts @@ -1,5 +1,7 @@ import type { App, Editor, Plugin, View } from 'obsidian'; + import { createOrEdit } from './CreateOrEdit'; +//import { selectStatus } from './SelectStatus'; import { toggleDone } from './ToggleDone'; @@ -32,5 +34,12 @@ export class Commands { icon: 'check-in-circle', editorCheckCallback: toggleDone, }); + + // plugin.addCommand({ + // id: 'select-status-modal', + // name: 'Select Status', + // icon: 'check-in-circle', + // editorCheckCallback: selectStatus, + // }); } } diff --git a/src/Events.ts b/src/Events.ts index ae80a0ef12..bf87d834ad 100644 --- a/src/Events.ts +++ b/src/Events.ts @@ -16,8 +16,8 @@ interface CacheUpdateData { export class Events { private obsidianEvents: ObsidianEvents; - constructor({ obsidianEents }: { obsidianEents: ObsidianEvents }) { - this.obsidianEvents = obsidianEents; + constructor({ obsidianEvents }: { obsidianEvents: ObsidianEvents }) { + this.obsidianEvents = obsidianEvents; } public onCacheUpdate( diff --git a/src/Feature.ts b/src/Feature.ts new file mode 100644 index 0000000000..2096320f6b --- /dev/null +++ b/src/Feature.ts @@ -0,0 +1,80 @@ +export type FeatureFlag = { + [internalName: string]: boolean; +}; + +/** + * @todo documentation + * + * @since {date} + */ +export class Feature { + static readonly TASK_STATUS_MENU = new Feature( + 'TASK_STATUS_MENU', + 0, + 'Enables a right click menu for each task to allow you to select the task Status from the available next transition states.', + 'Task Status Menu', + false, + false, + ); + + static readonly APPEND_GLOBAL_FILTER = new Feature( + 'APPEND_GLOBAL_FILTER', + 0, + 'Enabling this places the global filter at the end of the task description. Some plugins, such as Day Planner,\n' + + 'might require this, or you might prefer how it looks. If you change this when tasks are modified using the\n' + + 'Task edit box they will have the tag moved to the beginning or end of the description.', + 'Creates / Supports tasks with the global filter at end', + false, + false, + ); + + static get values(): Feature[] { + return [this.TASK_STATUS_MENU, this.TASK_STATUS_MENU]; + } + + static get settingsFlags(): FeatureFlag { + const featureFlags: { [internalName: string]: boolean } = {}; + + Feature.values.forEach((feature) => { + featureFlags[feature.internalName] = feature.enabledByDefault; + }); + return featureFlags; + } + + /** + * Converts a string to its corresponding default Feature instance. + * + * @param string the string to convert to Feature + * @throws RangeError, if a string that has no corresponding Feature value was passed. + * @returns the matching Feature + */ + static fromString(string: string): Feature { + const value = (this as any)[string]; + if (value) { + return value; + } + + throw new RangeError( + `Illegal argument passed to fromString(): ${string} does not correspond to any available Feature ${ + (this as any).prototype.constructor.name + }`, + ); + } + + private constructor( + public readonly internalName: string, + public readonly index: number, + public readonly description: string, + public readonly displayName: string, + public readonly enabledByDefault: boolean, + public readonly stable: boolean, + ) {} + + /** + * Called when converting the Feature value to a string using JSON.Stringify. + * Compare to the fromString() method, which deserializes the object. + */ + public toJSON() { + return this.internalName; + } +} diff --git a/src/Query.ts b/src/Query.ts index 82e6d36649..46271d4ae4 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -5,7 +5,9 @@ import type { TaskGroups } from './Query/TaskGroups'; import { getSettings } from './Settings'; import { LayoutOptions } from './LayoutOptions'; import { Sort } from './Sort'; -import { Priority, Status, Task } from './Task'; +import { Status } from './Status'; +import { Priority, Task } from './Task'; +import { StatusRegistry } from './StatusRegistry'; export type SortingProperty = | 'urgency' @@ -62,6 +64,8 @@ export class Query { private readonly notDoneString = 'not done'; private readonly doneRegexp = /^done (before|after|on)? ?(.*)/; + private readonly statusRegexp = /^status (is not|is) (.*)/; + private readonly pathRegexp = /^path (includes|does not include) (.*)/; private readonly descriptionRegexp = /^description (includes|does not include) (.*)/; @@ -103,12 +107,12 @@ export class Query { break; case line === this.doneString: this._filters.push( - (task) => task.status === Status.Done, + (task) => task.status === Status.DONE, ); break; case line === this.notDoneString: this._filters.push( - (task) => task.status !== Status.Done, + (task) => task.status !== Status.DONE, ); break; case line === this.recurringString: @@ -163,6 +167,9 @@ export class Query { case this.doneRegexp.test(line): this.parseDoneFilter({ line }); break; + case this.statusRegexp.test(line): + this.parseStatusFilter({ line }); + break; case this.pathRegexp.test(line): this.parsePathFilter({ line }); break; @@ -469,6 +476,40 @@ export class Query { } } + /** + * Parses the status query, will fail if the status is not registered. + * Uses the RegEx: '^status (is|is not) (.*)' + * + * @private + * @param {{ line: string }} { line } + * @return {*} {void} + * @memberof Query + */ + private parseStatusFilter({ line }: { line: string }): void { + const statusMatch = line.match(this.statusRegexp); + if (statusMatch !== null) { + const filterStatus = statusMatch[2]; + + if ( + StatusRegistry.getInstance().byIndicator(filterStatus) === + Status.EMPTY + ) { + this._error = + 'status you are searching for is not registered in configuration.'; + return; + } + + let filter; + if (statusMatch[1] === 'is') { + filter = (task: Task) => task.status.indicator === filterStatus; + } else { + filter = (task: Task) => task.status.indicator !== filterStatus; + } + + this._filters.push(filter); + } + } + private parsePathFilter({ line }: { line: string }): void { const pathMatch = line.match(this.pathRegexp); if (pathMatch !== null) { diff --git a/src/Query/Group.ts b/src/Query/Group.ts index 32bd9efc59..6d35cdcba4 100644 --- a/src/Query/Group.ts +++ b/src/Query/Group.ts @@ -109,7 +109,7 @@ export class Group { } private static groupByStatus(task: Task): string { - return task.status; + return task.status.name; } private static groupByHeading(task: Task): string { diff --git a/src/Settings.ts b/src/Settings.ts index e28923657a..99bacede3e 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -1,18 +1,31 @@ +import { Feature, FeatureFlag } from './Feature'; + export interface Settings { globalFilter: string; removeGlobalFilter: boolean; setDoneDate: boolean; + status_types: Array<[string, string, string]>; + features: FeatureFlag; } const defaultSettings: Settings = { globalFilter: '', removeGlobalFilter: false, setDoneDate: true, + status_types: [['', '', '']], + features: Feature.settingsFlags, }; let settings: Settings = { ...defaultSettings }; export const getSettings = (): Settings => { + // Check to see if there is a new flag and if so add it to the users settings. + for (const flag in Feature.settingsFlags) { + if (settings.features[flag] === undefined) { + settings.features[flag] = Feature.settingsFlags[flag]; + } + } + return { ...settings }; }; @@ -21,3 +34,15 @@ export const updateSettings = (newSettings: Partial): Settings => { return getSettings(); }; + +export const isFeatureEnabled = (internalName: string): boolean => { + return settings.features[internalName] ?? false; +}; + +export const toggleFeature = ( + internalName: string, + enabled: boolean, +): FeatureFlag => { + settings.features[internalName] = enabled; + return settings.features; +}; diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 79ee75935f..5e621961c4 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -1,6 +1,12 @@ -import { PluginSettingTab, Setting } from 'obsidian'; +import { Notice, PluginSettingTab, Setting } from 'obsidian'; +import { Feature } from './Feature'; import type TasksPlugin from './main'; -import { getSettings, updateSettings } from './Settings'; +import { + getSettings, + isFeatureEnabled, + toggleFeature, + updateSettings, +} from './Settings'; export class SettingsTab extends PluginSettingTab { private readonly plugin: TasksPlugin; @@ -13,6 +19,7 @@ export class SettingsTab extends PluginSettingTab { public display(): void { const { containerEl } = this; + const { status_types } = getSettings(); containerEl.empty(); containerEl.createEl('h2', { text: 'Tasks Settings' }); @@ -78,5 +85,252 @@ export class SettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + + /* -------------------------------------------------------------------------- */ + /* Settings for Custom Task Status */ + /* -------------------------------------------------------------------------- */ + + containerEl.createEl('hr'); + containerEl.createEl('h3', { text: 'Tasks Status Types' }); + const customStatusIntro = containerEl.createEl('p', { + text: 'If you want to have the tasks support additional statuses outside of the default ones add them here with the status indicator. ', + }); + customStatusIntro.insertAdjacentHTML( + 'beforeend', + 'By default the following statuses are supported:\n' + + '\n', + ); + + status_types.forEach((status_type) => { + new Setting(this.containerEl) + .addExtraButton((extra) => { + extra + .setIcon('cross') + .setTooltip('Delete') + .onClick(async () => { + const index = status_types.indexOf(status_type); + if (index > -1) { + status_types.splice(index, 1); + updateSettings({ + status_types: status_types, + }); + await this.plugin.saveSettings(); + // Force refresh + this.display(); + } + }); + }) + .addText((text) => { + const t = text + .setPlaceholder('Status symbol') + .setValue(status_type[0]) + .onChange(async (new_symbol) => { + // Check to see if they are adding in defaults and block. UI provides this information already. + if ([' ', 'x', '-', '/'].includes(new_symbol)) { + new Notice( + `The symbol ${new_symbol} is already in use.`, + ); + updateSettings({ + status_types: status_types, + }); + await this.plugin.saveSettings(); + // Force refresh + this.display(); + return; + } + + await this.updateStatusSetting( + status_types, + status_type, + 0, + new_symbol, + ); + }); + + return t; + }) + .addText((text) => { + const t = text + .setPlaceholder('Status name') + .setValue(status_type[1]) + .onChange(async (new_name) => { + await this.updateStatusSetting( + status_types, + status_type, + 1, + new_name, + ); + }); + return t; + }) + .addText((text) => { + const t = text + .setPlaceholder('Next status symbol') + .setValue(status_type[2]) + .onChange(async (new_symbol) => { + await this.updateStatusSetting( + status_types, + status_type, + 2, + new_symbol, + ); + }); + + return t; + }); + }); + + containerEl.createEl('div'); + + const setting = new Setting(this.containerEl).addButton((button) => { + button + .setButtonText('Add New Task Status') + .setCta() + .onClick(async () => { + status_types.push(['', '', '']); + updateSettings({ + status_types: status_types, + }); + await this.plugin.saveSettings(); + // Force refresh + this.display(); + }); + }); + setting.infoEl.remove(); + + const addStatusesSupportedByMinimalTheme = new Setting( + this.containerEl, + ).addButton((button) => { + button + .setButtonText( + 'Add all Status types supported by Minimal Theme', + ) + .setCta() + .onClick(async () => { + const minimalSupportedStatuses: Array< + [string, string, string] + > = [ + ['>', 'Forwarded', 'x'], + ['<', 'Schedule', 'x'], + ['?', 'Question', 'x'], + ['/', 'Incomplete', 'x'], + ['!', 'Important', 'x'], + ['"', 'Quote', 'x'], + ['-', 'Canceled', 'x'], + ['*', 'Star', 'x'], + ['l', 'Location', 'x'], + ['i', 'Info', 'x'], + ['S', 'Amount/savings/money', 'x'], + ['I', 'Idea/lightbulb', 'x'], + ['f', 'Fire', 'x'], + ['k', 'Key', 'x'], + ['u', 'Up', 'x'], + ['d', 'Down', 'x'], + ['w', 'Win', 'x'], + ['p', 'Pros', 'x'], + ['c', 'Cons', 'x'], + ['b', 'Bookmark', 'x'], + ]; + + minimalSupportedStatuses.forEach((importedStatus) => { + console.log(importedStatus); + const hasStatus = status_types.find((element) => { + return ( + element[0] == importedStatus[0] && + element[1] == importedStatus[1] && + element[2] == importedStatus[2] + ); + }); + if (!hasStatus) { + status_types.push(importedStatus); + } else { + new Notice( + `The status ${importedStatus[1]} (${importedStatus[0]}) is already added.`, + ); + } + }); + + updateSettings({ + status_types: status_types, + }); + await this.plugin.saveSettings(); + // Force refresh + this.display(); + }); + }); + addStatusesSupportedByMinimalTheme.infoEl.remove(); + + containerEl.createEl('hr'); + containerEl.createEl('h3', { text: 'Documentation and Support' }); + const supportAndInfoDiv = containerEl.createEl('div'); + + supportAndInfoDiv.insertAdjacentHTML( + 'beforeend', + '

If you need help with this plugin, please check out the Tasks documentation. Click on issues below if you find something that the documentation does not explain or if you find something now working as expected.

\n' + + 'GitHub issues\n' + + 'GitHub package.json version\n' + + 'GitHub all releases\n' + + '', + ); + + containerEl.createEl('hr'); + containerEl.createEl('h3', { + text: 'Optional or in development features', + }); + const featureFlagEnablement = containerEl.createEl('div'); + + featureFlagEnablement.insertAdjacentHTML( + 'beforeend', + '

The following features are in development or optional, stability is indicated \n' + + 'next to the feature. While we try to make sure there is good test coverage and validation \n' + + 'for every change there is always a chance of bugs.\n' + + '

', + ); + Feature.values.forEach((feature) => { + new Setting(containerEl) + .setName(feature.displayName) + .setDesc(feature.description + ' Is Stable? ' + feature.stable) + .addToggle((toggle) => { + toggle + .setValue(isFeatureEnabled(feature.internalName)) + .onChange(async (value) => { + const updatedFeatures = toggleFeature( + feature.internalName, + value, + ); + updateSettings({ features: updatedFeatures }); + + await this.plugin.saveSettings(); + // Force refresh + this.display(); + }); + }); + }); + } + + private async updateStatusSetting( + status_types: [string, string, string][], + status_type: [string, string, string], + valueIndex: number, + newValue: string, + ) { + const index = status_types.findIndex((element) => { + element[0] === status_type[0] && + element[1] === status_type[1] && + element[2] === status_type[2]; + }); + + if (index > -1) { + status_types[index][valueIndex] = newValue; + updateSettings({ + status_types: status_types, + }); + await this.plugin.saveSettings(); + } } } diff --git a/src/Sort.ts b/src/Sort.ts index 42cc7fcba2..1ac0aa07c6 100644 --- a/src/Sort.ts +++ b/src/Sort.ts @@ -75,9 +75,9 @@ export class Sort { } private static compareByStatus(a: Task, b: Task): -1 | 0 | 1 { - if (a.status < b.status) { + if (a.status.indicator > b.status.indicator) { return 1; - } else if (a.status > b.status) { + } else if (a.status.indicator < b.status.indicator) { return -1; } else { return 0; diff --git a/src/Status.ts b/src/Status.ts new file mode 100644 index 0000000000..6d6edc3b10 --- /dev/null +++ b/src/Status.ts @@ -0,0 +1,117 @@ +import type { StatusRegistry } from 'StatusRegistry'; + +/** + * Tracks the possible states that a task can be in. + * + * @export + * @class Status + */ +export class Status { + /** + * The indicator used between the two square brackets in the markdown task. + * + * @type {string} + * @memberof Status + */ + public readonly indicator: string; + + /** + * Returns the name of the status for display purposes. + * + * @type {string} + * @memberof Status + */ + public readonly name: string; + + /** + * Returns the next status for a task when toggled. + * + * @type {string} + * @memberof Status + */ + public readonly nextStatusIndicator: string; + + /** + * Returns the next status for a task when toggled. + * + * @type {StatusRegistry} + * @memberof Status + */ + public statusRegistry: StatusRegistry | undefined; + + /** + * The default Done status. Goes to Todo when toggled. + * + * @static + * @type {Status} + * @memberof Status + */ + public static DONE: Status = new Status('x', 'Done', ' '); + + /** + * A default status of empty, used when things go wrong. + * + * @static + * @memberof Status + */ + public static EMPTY: Status = new Status('', 'EMPTY', ''); + + /** + * The default Todo status. Goes to In Progress when toggled. + * + * @static + * @type {Status} + * @memberof Status + */ + public static TODO: Status = new Status(' ', 'Todo', '/'); + + /** + * The default Cancelled status. Goes to Todo when toggled. + * + * @static + * @type {Status} + * @memberof Status + */ + public static CANCELLED: Status = new Status('-', 'Cancelled', ' '); + + /** + * The default In Progress status. Goes to Done when toggled. + * + * @static + * @type {Status} + * @memberof Status + */ + public static IN_PROGRESS: Status = new Status('/', 'In Progress', 'x'); + + /** + * Creates an instance of Status. The registry will be added later in the case + * of the default statuses. + * + * @param {string} indicator + * @param {string} name + * @param {Status} nextStatusIndicator + * @memberof Status + */ + constructor( + indicator: string, + name: string, + nextStatusIndicator: string, + statusRegistry: StatusRegistry | undefined = undefined, + ) { + this.indicator = indicator; + this.name = name; + this.nextStatusIndicator = nextStatusIndicator; + this.statusRegistry = statusRegistry; + } + + /** + * Returns the completion status for a task, this is only supported + * when the task is done/x. + * + * @return {*} {boolean} + * @memberof Status + */ + public isCompleted(): boolean { + return this.indicator === 'x'; + } +} diff --git a/src/StatusRegistry.ts b/src/StatusRegistry.ts new file mode 100644 index 0000000000..558250ec9c --- /dev/null +++ b/src/StatusRegistry.ts @@ -0,0 +1,177 @@ +import { Status } from './Status'; + +/** + * Tracks all the registered statuses a task can have. + * + * @export + * @class StatusRegistry + */ +export class StatusRegistry { + private static instance: StatusRegistry; + + private _registeredStatuses: Status[] = []; + + /** + * Returns all the registered statuses minus the empty status. + * + * @readonly + * @type {Status[]} + * @memberof StatusRegistry + */ + public get registeredStatuses(): Status[] { + return this._registeredStatuses.filter( + ({ indicator }) => indicator !== Status.EMPTY.indicator, + ); + } + + /** + * Creates an instance of Status and registers it for use. It will also check to see + * if the default todo and done are registered and if not handle it internally. + * + * @memberof StatusRegistry + */ + private constructor() { + this.clearStatuses(); + } + + /** + * The static method that controls the access to the StatusRegistry instance. + * + * @static + * @return {*} {StatusRegistry} + * @memberof StatusRegistry + */ + public static getInstance(): StatusRegistry { + if (!StatusRegistry.instance) { + StatusRegistry.instance = new StatusRegistry(); + } + + return StatusRegistry.instance; + } + + /** + * Adds a new Status to the registry if not already registered. + * + * @param {Status} status + * @memberof StatusRegistry + */ + public add(status: Status): void { + if (!this.hasIndicator(status.indicator)) { + status.statusRegistry = this; + this._registeredStatuses.push(status); + } + } + + /** + * Returns the registered status by the indicator between the + * square braces in the markdown task. + * + * @param {string} indicator + * @return {*} {Status} + * @memberof StatusRegistry + */ + public byIndicator(indicator: string): Status { + if (this.hasIndicator(indicator)) { + return this.getIndicator(indicator); + } + + return Status.EMPTY; + } + + /** + * Returns the registered status by the name assigned by the user. + * + * @param {string} nameToFind + * @return {*} {Status} + * @memberof StatusRegistry + */ + public byName(nameToFind: string): Status { + if ( + this._registeredStatuses.filter(({ name }) => name === nameToFind) + .length > 0 + ) { + return this._registeredStatuses.filter( + ({ name }) => name === nameToFind, + )[0]; + } + + return Status.EMPTY; + } + + /** + * Resets the array os Status types to be empty. + * + * @memberof StatusRegistry + */ + public clearStatuses(): void { + this._registeredStatuses = []; + this.addDefaultStatusTypes(); + } + + /** + * To allow custom progression of task status each status knows + * which status can come after it as a state transition. + * + * @return {*} {Status} + * @memberof StatusRegistry + */ + public getNextStatus(status: Status): Status { + if (status.nextStatusIndicator !== '') { + const nextStatus = this.byIndicator(status.nextStatusIndicator); + if (nextStatus !== null) { + return nextStatus; + } + } + return Status.EMPTY; + } + + /** + * Filters the Status types by the indicator and returns the first one found. + * + * @private + * @param {string} indicatorToFind + * @return {*} {Status} + * @memberof StatusRegistry + */ + private getIndicator(indicatorToFind: string): Status { + return this._registeredStatuses.filter( + ({ indicator }) => indicator === indicatorToFind, + )[0]; + } + + /** + * Filters all the Status types by the indicator and returns true if found. + * + * @private + * @param {string} indicatorToFind + * @return {*} {boolean} + * @memberof StatusRegistry + */ + private hasIndicator(indicatorToFind: string): boolean { + return ( + this._registeredStatuses.filter( + ({ indicator }) => indicator === indicatorToFind, + ).length > 0 + ); + } + + /** + * Checks the registry and adds the default status types. + * + * @private + * @memberof StatusRegistry + */ + private addDefaultStatusTypes(): void { + const defaultStatuses = [ + Status.TODO, + Status.IN_PROGRESS, + Status.DONE, + Status.CANCELLED, + Status.EMPTY, + ]; + + defaultStatuses.forEach((status) => { + this.add(status); + }); + } +} diff --git a/src/Task.ts b/src/Task.ts index 572368f499..910d7a8940 100644 --- a/src/Task.ts +++ b/src/Task.ts @@ -1,21 +1,13 @@ import type { Moment } from 'moment'; import { Component, MarkdownRenderer } from 'obsidian'; +import { StatusRegistry } from './StatusRegistry'; +import type { Status } from './Status'; import { replaceTaskWithTasks } from './File'; import { LayoutOptions } from './LayoutOptions'; import { Recurrence } from './Recurrence'; -import { getSettings } from './Settings'; +import { getSettings, isFeatureEnabled } from './Settings'; import { Urgency } from './Urgency'; - -/** - * Collection of status types supported by the plugin. - * TODO: Make this a class so it can support other types and easier mapping to status character. - * @export - * @enum {number} - */ -export enum Status { - Todo = 'Todo', - Done = 'Done', -} +import { Feature } from './Feature'; /** * When sorting, make sure low always comes after none. This way any tasks with low will be below any exiting @@ -48,11 +40,6 @@ export class Task { public readonly sectionStart: number; /** The index of the nth task in its section. */ public readonly sectionIndex: number; - /** - * The original character from within `[]` in the document. - * Required to be added to the LI the same way obsidian does as a `data-task` attribute. - */ - public readonly originalStatusCharacter: string; public readonly precedingHeader: string | null; public readonly tags: string[]; @@ -106,7 +93,6 @@ export class Task { indentation, sectionStart, sectionIndex, - originalStatusCharacter, precedingHeader, priority, startDate, @@ -123,7 +109,6 @@ export class Task { indentation: string; sectionStart: number; sectionIndex: number; - originalStatusCharacter: string; precedingHeader: string | null; priority: Priority; startDate: moment.Moment | null; @@ -140,7 +125,6 @@ export class Task { this.indentation = indentation; this.sectionStart = sectionStart; this.sectionIndex = sectionIndex; - this.originalStatusCharacter = originalStatusCharacter; this.precedingHeader = precedingHeader; this.tags = tags; @@ -197,18 +181,21 @@ export class Task { return null; } - let description = body; + // Global filter is applied via edit or to string and no + // longer needs to be on the description. If this happens + // there may be a double space. So all double spaces are made + // single like the UI processing. + let description = body + .replace(globalFilter, '') + .replace(' ', ' ') + .trim(); const indentation = regexMatch[1]; - // Get the status of the task, only todo and done supported. + // Get the status of the task. const statusString = regexMatch[2].toLowerCase(); - let status: Status; - switch (statusString) { - case ' ': - status = Status.Todo; - break; - default: - status = Status.Done; + const status = StatusRegistry.getInstance().byIndicator(statusString); + if (status === null) { + throw new Error(`Missing status indicator: ${statusString}`); } // Match for block link and remove if found. Always expected to be @@ -331,7 +318,6 @@ export class Task { indentation, sectionStart, sectionIndex, - originalStatusCharacter: statusString, precedingHeader, priority, startDate, @@ -346,6 +332,24 @@ export class Task { return task; } + /** + * Renders a list item the same way Obsidian does. Note that anything between the + * square brackets means it is 'checked' this is not the same as done. + * + * @param {{ + * parentUlElement: HTMLElement; + * listIndex: number; + * layoutOptions?: LayoutOptions; + * isFilenameUnique?: boolean; + * }} { + * parentUlElement, + * listIndex, + * layoutOptions, + * isFilenameUnique, + * } + * @return {*} {Promise} + * @memberof Task + */ public async toLi({ parentUlElement, listIndex, @@ -358,15 +362,23 @@ export class Task { layoutOptions?: LayoutOptions; isFilenameUnique?: boolean; }): Promise { - const li: HTMLLIElement = parentUlElement.createEl('li'); - li.addClasses(['task-list-item', 'plugin-tasks-list-item']); - let taskAsString = this.toString(layoutOptions); const { globalFilter, removeGlobalFilter } = getSettings(); + + // Hide the global filter when rendering the query results. if (removeGlobalFilter) { taskAsString = taskAsString.replace(globalFilter, '').trim(); } + // Generate top level list item. + const li: HTMLLIElement = parentUlElement.createEl('li'); + li.setAttr('data-line', listIndex); + li.setAttr('data-task', this.status.indicator.trim()); // Trim to ensure empty attribute for space. Same way as obsidian. + li.addClasses(['task-list-item', 'plugin-tasks-list-item']); + if (this.status.indicator !== ' ') { + li.addClass('is-checked'); + } + const textSpan = li.createSpan(); textSpan.addClass('tasks-list-text'); @@ -405,12 +417,13 @@ export class Task { }); const checkbox = li.createEl('input'); - checkbox.addClass('task-list-item-checkbox'); - checkbox.type = 'checkbox'; - if (this.status !== Status.Todo) { + checkbox.setAttr('data-line', listIndex); + if (this.status.indicator !== ' ') { checkbox.checked = true; - li.addClass('is-checked'); } + checkbox.type = 'checkbox'; + checkbox.addClass('task-list-item-checkbox'); + checkbox.onClickEvent((event: MouseEvent) => { event.preventDefault(); // It is required to stop propagation so that obsidian won't write the file with the @@ -428,11 +441,6 @@ export class Task { li.prepend(checkbox); - // Set these to be compatible with stock obsidian lists: - li.setAttr('data-task', this.originalStatusCharacter.trim()); // Trim to ensure empty attribute for space. Same way as obsidian. - li.setAttr('data-line', listIndex); - checkbox.setAttr('data-line', listIndex); - if (layoutOptions?.shortMode) { this.addTooltip({ element: textSpan, isFilenameUnique }); } @@ -441,7 +449,8 @@ export class Task { } /** - * + * Returns a string representation of the task. This is the entire body after the + * markdown task prefix. ( - [ ] ) * * @param {LayoutOptions} [layoutOptions] * @return {*} {string} @@ -449,7 +458,16 @@ export class Task { */ public toString(layoutOptions?: LayoutOptions): string { layoutOptions = layoutOptions ?? new LayoutOptions(); - let taskString = this.description; + + let taskString = this.description.trim(); + const { globalFilter } = getSettings(); + + if (isFeatureEnabled(Feature.APPEND_GLOBAL_FILTER.internalName)) { + taskString = `${taskString} ${globalFilter}`.trim(); + } else { + // Default is to have filter at front. + taskString = `${globalFilter} ${taskString}`.trim(); + } if (!layoutOptions.hidePriority) { let priority: string = ''; @@ -514,8 +532,8 @@ export class Task { */ public toFileLineString(): string { return `${this.indentation}- [${ - this.originalStatusCharacter - }] ${this.toString()}`; + this.status.indicator + }] ${this.toString().trim()}`; } /** @@ -527,8 +545,9 @@ export class Task { * task is not recurring, it will return `[toggled]`. */ public toggle(): Task[] { - const newStatus: Status = - this.status === Status.Todo ? Status.Done : Status.Todo; + const newStatus = StatusRegistry.getInstance().getNextStatus( + this.status, + ); let newDoneDate = null; @@ -538,7 +557,7 @@ export class Task { dueDate: Moment | null; } | null = null; - if (newStatus !== Status.Todo) { + if (newStatus.isCompleted()) { // Set done date only if setting value is true const { setDoneDate } = getSettings(); if (setDoneDate) { @@ -555,7 +574,6 @@ export class Task { ...this, status: newStatus, doneDate: newDoneDate, - originalStatusCharacter: newStatus === Status.Done ? 'x' : ' ', }); const newTasks: Task[] = []; diff --git a/src/main.ts b/src/main.ts index ab602633f9..41c92a7f45 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { Plugin } from 'obsidian'; +import { Status } from './Status'; import { Cache } from './Cache'; import { Commands } from './Commands'; @@ -9,14 +10,18 @@ import { newLivePreviewExtension } from './LivePreviewExtension'; import { QueryRenderer } from './QueryRenderer'; import { getSettings, updateSettings } from './Settings'; import { SettingsTab } from './SettingsTab'; +import { StatusRegistry } from './StatusRegistry'; export default class TasksPlugin extends Plugin { private cache: Cache | undefined; public inlineRenderer: InlineRenderer | undefined; public queryRenderer: QueryRenderer | undefined; + public statusRegistry: StatusRegistry | undefined; async onload() { - console.log('loading plugin "tasks"'); + console.log( + `loading plugin "${this.manifest.name}" v${this.manifest.version}`, + ); await this.loadSettings(); this.addSettingTab(new SettingsTab({ plugin: this })); @@ -26,7 +31,7 @@ export default class TasksPlugin extends Plugin { vault: this.app.vault, }); - const events = new Events({ obsidianEents: this.app.workspace }); + const events = new Events({ obsidianEvents: this.app.workspace }); this.cache = new Cache({ metadataCache: this.app.metadataCache, vault: this.app.vault, @@ -34,13 +39,34 @@ export default class TasksPlugin extends Plugin { }); this.inlineRenderer = new InlineRenderer({ plugin: this }); this.queryRenderer = new QueryRenderer({ plugin: this, events }); + this.statusRegistry = StatusRegistry.getInstance(); + + await this.loadTaskStatuses(); this.registerEditorExtension(newLivePreviewExtension()); new Commands({ plugin: this }); } + async loadTaskStatuses() { + const { status_types } = getSettings(); + + // Reset the registry as this may also come from a settings add/delete. + this.statusRegistry?.clearStatuses(); + + status_types.forEach((status_type) => { + console.log( + `${this.manifest.name}: Adding custom status - [${status_type[0]}] ${status_type[1]} -> ${status_type[2]} `, + ); + this.statusRegistry?.add( + new Status(status_type[0], status_type[1], status_type[2]), + ); + }); + } + onunload() { - console.log('unloading plugin "tasks"'); + console.log( + `unloading plugin "${this.manifest.name}" v${this.manifest.version}`, + ); this.cache?.unload(); } @@ -51,5 +77,6 @@ export default class TasksPlugin extends Plugin { async saveSettings() { await this.saveData(getSettings()); + await this.loadTaskStatuses(); } } diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte index d224504752..4b981d4864 100644 --- a/src/ui/EditTask.svelte +++ b/src/ui/EditTask.svelte @@ -1,9 +1,12 @@