Permalink
Cannot retrieve contributors at this time
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
283 lines (242 sloc)
8.61 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| App, | |
| EventRef, | |
| MarkdownPostProcessorContext, | |
| MarkdownRenderChild, | |
| Plugin, | |
| TFile, | |
| } from 'obsidian'; | |
| import { State } from './Cache'; | |
| import { replaceTaskWithTasks } from './File'; | |
| import { Query } from './Query'; | |
| import { Sort } from './Sort'; | |
| import { TaskModal } from './TaskModal'; | |
| import type { Events } from './Events'; | |
| import type { Task } from './Task'; | |
| export class QueryRenderer { | |
| private readonly app: App; | |
| private readonly events: Events; | |
| constructor({ plugin, events }: { plugin: Plugin; events: Events }) { | |
| this.app = plugin.app; | |
| this.events = events; | |
| plugin.registerMarkdownCodeBlockProcessor( | |
| 'tasks', | |
| this._addQueryRenderChild.bind(this), | |
| ); | |
| } | |
| public addQueryRenderChild = this._addQueryRenderChild.bind(this); | |
| private async _addQueryRenderChild( | |
| source: string, | |
| element: HTMLElement, | |
| context: MarkdownPostProcessorContext, | |
| ) { | |
| context.addChild( | |
| new QueryRenderChild({ | |
| app: this.app, | |
| events: this.events, | |
| container: element, | |
| source, | |
| }), | |
| ); | |
| } | |
| } | |
| class QueryRenderChild extends MarkdownRenderChild { | |
| private readonly app: App; | |
| private readonly events: Events; | |
| private readonly source: string; | |
| private query: Query; | |
| private renderEventRef: EventRef | undefined; | |
| private queryReloadTimeout: NodeJS.Timeout | undefined; | |
| constructor({ | |
| app, | |
| events, | |
| container, | |
| source, | |
| }: { | |
| app: App; | |
| events: Events; | |
| container: HTMLElement; | |
| source: string; | |
| }) { | |
| super(container); | |
| this.app = app; | |
| this.events = events; | |
| this.source = source; | |
| this.query = new Query({ source }); | |
| } | |
| onload() { | |
| // Process the current cache state: | |
| this.events.triggerRequestCacheUpdate(this.render.bind(this)); | |
| // Listen to future cache changes: | |
| this.renderEventRef = this.events.onCacheUpdate(this.render.bind(this)); | |
| this.reloadQueryAtMidnight(); | |
| } | |
| onunload() { | |
| if (this.renderEventRef !== undefined) { | |
| this.events.off(this.renderEventRef); | |
| } | |
| if (this.queryReloadTimeout !== undefined) { | |
| clearTimeout(this.queryReloadTimeout); | |
| } | |
| } | |
| /** | |
| * Reloads the query after midnight to update results from relative date queries. | |
| * | |
| * For example, the query `due today` changes every day. This makes sure that all query results | |
| * are re-rendered after midnight every day to ensure up-to-date results without having to | |
| * reload obsidian. Creating a new query object from the source re-applies the relative dates | |
| * to "now". | |
| */ | |
| private reloadQueryAtMidnight(): void { | |
| const midnight = new Date(); | |
| midnight.setHours(24, 0, 0, 0); | |
| const now = new Date(); | |
| const millisecondsToMidnight = midnight.getTime() - now.getTime(); | |
| this.queryReloadTimeout = setTimeout(() => { | |
| this.query = new Query({ source: this.source }); | |
| // Process the current cache state: | |
| this.events.triggerRequestCacheUpdate(this.render.bind(this)); | |
| this.reloadQueryAtMidnight(); | |
| }, millisecondsToMidnight + 1000); // Add buffer to be sure to run after midnight. | |
| } | |
| private async render({ tasks, state }: { tasks: Task[]; state: State }) { | |
| const content = this.containerEl.createEl('div'); | |
| if (state === State.Warm && this.query.error === undefined) { | |
| const { taskList, tasksCount } = await this.createTasksList({ | |
| tasks, | |
| content, | |
| }); | |
| content.appendChild(taskList); | |
| if (!this.query.layoutOptions.hideTaskCount) { | |
| content.createDiv({ | |
| text: `${tasksCount} task${tasksCount !== 1 ? 's' : ''}`, | |
| cls: 'tasks-count', | |
| }); | |
| } | |
| } else if (this.query.error !== undefined) { | |
| content.setText(`Tasks query: ${this.query.error}`); | |
| } else { | |
| content.setText('Loading Tasks ...'); | |
| } | |
| this.containerEl.firstChild?.replaceWith(content); | |
| } | |
| private async createTasksList({ | |
| tasks, | |
| content, | |
| }: { | |
| tasks: Task[]; | |
| content: HTMLDivElement; | |
| }): Promise<{ taskList: HTMLUListElement; tasksCount: number }> { | |
| this.query.filters.forEach((filter) => { | |
| tasks = tasks.filter(filter); | |
| }); | |
| const tasksSortedLimited = Sort.by(this.query, tasks).slice( | |
| 0, | |
| this.query.limit, | |
| ); | |
| const tasksCount = tasksSortedLimited.length; | |
| const taskList = content.createEl('ul'); | |
| taskList.addClasses([ | |
| 'contains-task-list', | |
| 'plugin-tasks-query-result', | |
| ]); | |
| for (let i = 0; i < tasksCount; i++) { | |
| const task = tasksSortedLimited[i]; | |
| const isFilenameUnique = this.isFilenameUnique({ task }); | |
| const listItem = await task.toLi({ | |
| parentUlElement: taskList, | |
| listIndex: i, | |
| layoutOptions: this.query.layoutOptions, | |
| isFilenameUnique, | |
| }); | |
| // Remove all footnotes. They don't re-appear in another document. | |
| const footnotes = listItem.querySelectorAll('[data-footnote-id]'); | |
| footnotes.forEach((footnote) => footnote.remove()); | |
| const postInfo = listItem.createSpan(); | |
| const shortMode = this.query.layoutOptions.shortMode; | |
| if (!this.query.layoutOptions.hideBacklinks) { | |
| this.addBacklinks(postInfo, task, shortMode, isFilenameUnique); | |
| } | |
| if (!this.query.layoutOptions.hideEditButton) { | |
| this.addEditButton(postInfo, task); | |
| } | |
| taskList.appendChild(listItem); | |
| } | |
| return { taskList, tasksCount }; | |
| } | |
| private addEditButton(postInfo: HTMLSpanElement, task: Task) { | |
| const editTaskPencil = postInfo.createEl('a', { | |
| cls: 'tasks-edit', | |
| }); | |
| editTaskPencil.onClickEvent((event: MouseEvent) => { | |
| event.preventDefault(); | |
| const onSubmit = (updatedTasks: Task[]): void => { | |
| replaceTaskWithTasks({ | |
| originalTask: task, | |
| newTasks: updatedTasks, | |
| }); | |
| }; | |
| // Need to create a new instance every time, as cursor/task can change. | |
| const taskModal = new TaskModal({ | |
| app: this.app, | |
| task, | |
| onSubmit, | |
| }); | |
| taskModal.open(); | |
| }); | |
| } | |
| private addBacklinks( | |
| postInfo: HTMLSpanElement, | |
| task: Task, | |
| shortMode: boolean, | |
| isFilenameUnique: boolean | undefined, | |
| ) { | |
| postInfo.addClass('tasks-backlink'); | |
| if (!shortMode) { | |
| postInfo.append(' ('); | |
| } | |
| const link = postInfo.createEl('a'); | |
| link.href = task.path; | |
| link.setAttribute('data-href', task.path); | |
| link.rel = 'noopener'; | |
| link.target = '_blank'; | |
| link.addClass('internal-link'); | |
| if (shortMode) { | |
| link.addClass('internal-link-short-mode'); | |
| } | |
| if (task.precedingHeader !== null) { | |
| link.href = link.href + '#' + task.precedingHeader; | |
| link.setAttribute( | |
| 'data-href', | |
| link.getAttribute('data-href') + '#' + task.precedingHeader, | |
| ); | |
| } | |
| let linkText: string; | |
| if (shortMode) { | |
| linkText = ' 🔗'; | |
| } else { | |
| linkText = task.getLinkText({ isFilenameUnique }) ?? ''; | |
| } | |
| link.setText(linkText); | |
| if (!shortMode) { | |
| postInfo.append(')'); | |
| } | |
| } | |
| private isFilenameUnique({ task }: { task: Task }): boolean | undefined { | |
| // Will match the filename without extension (the file's "basename"). | |
| const filenameMatch = task.path.match(/([^/]*)\..+$/i); | |
| if (filenameMatch === null) { | |
| return undefined; | |
| } | |
| const filename = filenameMatch[1]; | |
| const allFilesWithSameName = this.app.vault | |
| .getMarkdownFiles() | |
| .filter((file: TFile) => { | |
| if (file.basename === filename) { | |
| // Found a file with the same name (it might actually be the same file, but we'll take that into account later.) | |
| return true; | |
| } | |
| }); | |
| return allFilesWithSameName.length < 2; | |
| } | |
| } |