Skip to content

Commit fa11bf7

Browse files
authored
Merge pull request #3682 from ilandikov/feat-copy-tree-results
feat: copy tree results
2 parents 5342a35 + 1272963 commit fa11bf7

File tree

5 files changed

+396
-5
lines changed

5 files changed

+396
-5
lines changed

src/Query/QueryResult.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export class QueryResult {
5050
return result;
5151
}
5252

53+
/**
54+
* This doesn't support nested results and list items.
55+
* TODO reimplement this with {@link MarkdownQueryResultsRenderer}
56+
*
57+
*/
5358
public asMarkdown(): string {
5459
let markdown = '';
5560

src/Renderer/HtmlQueryResultsRenderer.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { type App, type Component, Notice, type TFile } from 'obsidian';
22
import { postponeButtonTitle, shouldShowPostponeButton } from '../DateTime/Postponer';
33
import { QueryLayout } from '../Layout/QueryLayout';
44
import { TaskLayout } from '../Layout/TaskLayout';
5+
import { State } from '../Obsidian/Cache';
56
import type { GroupDisplayHeading } from '../Query/Group/GroupDisplayHeading';
67
import type { QueryResult } from '../Query/QueryResult';
78
import type { ListItem } from '../Task/ListItem';
89
import type { Task } from '../Task/Task';
910
import { PostponeMenu } from '../ui/Menus/PostponeMenu';
1011
import { showMenu } from '../ui/Menus/TaskEditingMenu';
12+
import { MarkdownQueryResultsRenderer } from './MarkdownQueryResultsRenderer';
1113
import type { QueryRendererParameters } from './QueryResultsRenderer';
1214
import { QueryResultsRendererBase, type QueryResultsRendererGetters } from './QueryResultsRendererBase';
1315
import { TaskLineRenderer, type TextRenderer, createAndAppendElement } from './TaskLineRenderer';
@@ -38,6 +40,8 @@ export class HtmlQueryResultsRenderer extends QueryResultsRendererBase {
3840

3941
private readonly queryRendererParameters: QueryRendererParameters;
4042

43+
private readonly markdownRenderer: MarkdownQueryResultsRenderer;
44+
4145
constructor(
4246
renderMarkdown: (
4347
app: App,
@@ -67,6 +71,12 @@ export class HtmlQueryResultsRenderer extends QueryResultsRendererBase {
6771
taskLayoutOptions: this.getters.query().taskLayoutOptions,
6872
queryLayoutOptions: this.getters.query().queryLayoutOptions,
6973
});
74+
75+
this.markdownRenderer = new MarkdownQueryResultsRenderer(getters);
76+
}
77+
78+
protected beginRender(): void {
79+
return;
7080
}
7181

7282
protected renderSearchResultsHeader(queryResult: QueryResult): void {
@@ -77,12 +87,12 @@ export class HtmlQueryResultsRenderer extends QueryResultsRendererBase {
7787
this.addTaskCount(queryResult);
7888
}
7989

80-
protected renderErrorMessage(errorMessage: string) {
90+
protected renderErrorMessage(errorMessage: string): void {
8191
const container = createAndAppendElement('div', this.content);
8292
container.innerHTML = '<pre>' + `Tasks query: ${errorMessage.replace(/\n/g, '<br>')}` + '</pre>';
8393
}
8494

85-
protected renderLoadingMessage() {
95+
protected renderLoadingMessage(): void {
8696
this.content.textContent = 'Loading Tasks ...';
8797
}
8898

@@ -92,12 +102,14 @@ export class HtmlQueryResultsRenderer extends QueryResultsRendererBase {
92102
explanationsBlock.textContent = explanation;
93103
}
94104

95-
private addCopyButton(queryResult: QueryResult) {
105+
private addCopyButton(_queryResult: QueryResult) {
96106
const copyButton = createAndAppendElement('button', this.content);
97107
copyButton.textContent = 'Copy results';
98108
copyButton.classList.add('plugin-tasks-copy-button');
99109
copyButton.addEventListener('click', async () => {
100-
await navigator.clipboard.writeText(queryResult.asMarkdown());
110+
// TODO reimplement this using QueryResult.asMarkdown() when it supports trees and list items.
111+
await this.markdownRenderer.renderQuery(State.Warm, this.queryRendererParameters.allTasks());
112+
await navigator.clipboard.writeText(this.markdownRenderer.markdown);
101113
new Notice('Results copied to clipboard');
102114
});
103115
}
@@ -126,7 +138,7 @@ export class HtmlQueryResultsRenderer extends QueryResultsRendererBase {
126138
this.ulElementStack.pop();
127139
}
128140

129-
protected beginListItem() {
141+
protected beginListItem(): void {
130142
const taskList = this.currentULElement();
131143
this.lastLIElement = createAndAppendElement('li', taskList);
132144
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { GroupDisplayHeading } from '../Query/Group/GroupDisplayHeading';
2+
import type { QueryResult } from '../Query/QueryResult';
3+
import type { ListItem } from '../Task/ListItem';
4+
import type { Task } from '../Task/Task';
5+
import { QueryResultsRendererBase, type QueryResultsRendererGetters } from './QueryResultsRendererBase';
6+
7+
/**
8+
* @example
9+
* const markdownRenderer = new MarkdownQueryResultsRenderer(getters);
10+
* await markdownRenderer.renderQuery(State.Warm, allTasks);
11+
* const markdown = markdownRenderer.markdown;
12+
*
13+
*/
14+
export class MarkdownQueryResultsRenderer extends QueryResultsRendererBase {
15+
private readonly markdownLines: string[] = [];
16+
private taskIndentationLevel = 0;
17+
18+
constructor(getters: QueryResultsRendererGetters) {
19+
super(getters);
20+
}
21+
22+
get markdown(): string {
23+
return this.markdownLines.join('\n');
24+
}
25+
26+
protected beginRender(): void {
27+
this.markdownLines.length = 0;
28+
this.taskIndentationLevel = 0;
29+
}
30+
31+
protected renderSearchResultsHeader(_queryResult: QueryResult): void {
32+
return;
33+
}
34+
35+
protected renderSearchResultsFooter(_queryResult: QueryResult): void {
36+
return;
37+
}
38+
39+
protected renderLoadingMessage(): void {
40+
return;
41+
}
42+
43+
protected renderExplanation(_explanation: string | null): void {
44+
return;
45+
}
46+
47+
protected renderErrorMessage(_errorMessage: string): void {
48+
return;
49+
}
50+
51+
protected beginTaskList(): void {
52+
this.taskIndentationLevel += 1;
53+
}
54+
55+
protected endTaskList(): void {
56+
this.taskIndentationLevel -= 1;
57+
58+
const isOutermostList = this.taskIndentationLevel === 0;
59+
if (isOutermostList) {
60+
this.addEmptyLine();
61+
}
62+
}
63+
64+
private addEmptyLine() {
65+
this.markdownLines.push('');
66+
}
67+
68+
protected beginListItem(): void {
69+
return;
70+
}
71+
72+
protected addTask(task: Task, _taskIndex: number): Promise<void> {
73+
this.markdownLines.push(this.formatTask(task));
74+
return Promise.resolve();
75+
}
76+
77+
/**
78+
* This is a duplicate of Task.toFileLineString() because tasks rendered in search results
79+
* do not necessarily have the same indentation and list markers as the source task lines.
80+
*
81+
* @param task
82+
*/
83+
private formatTask(task: Task): string {
84+
return `${this.listItemIndentation()}- [${task.status.symbol}] ${task.toString()}`;
85+
}
86+
87+
protected addListItem(listItem: ListItem, _listItemIndex: number): Promise<void> {
88+
this.markdownLines.push(this.formatListItem(listItem));
89+
return Promise.resolve();
90+
}
91+
92+
/**
93+
* This is based on ListItem.toFileLineString() because tasks rendered in search results
94+
* do not necessarily have the same indentation and list markers as the source lines.
95+
*
96+
* @param listItem
97+
*/
98+
private formatListItem(listItem: ListItem): string {
99+
const statusCharacterToString = listItem.statusCharacter ? `[${listItem.statusCharacter}] ` : '';
100+
return `${this.listItemIndentation()}- ${statusCharacterToString}${listItem.description}`;
101+
}
102+
103+
private listItemIndentation() {
104+
const indentationLevel = Math.max(0, this.taskIndentationLevel - 1);
105+
return ' '.repeat(indentationLevel);
106+
}
107+
108+
protected addGroupHeading(group: GroupDisplayHeading): Promise<void> {
109+
const headingPrefix = '#'.repeat(Math.min(4 + group.nestingLevel, 6));
110+
this.markdownLines.push(`${headingPrefix} ${group.displayName}`);
111+
this.addEmptyLine();
112+
return Promise.resolve();
113+
}
114+
}

src/Renderer/QueryResultsRendererBase.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export abstract class QueryResultsRendererBase {
3535
}
3636

3737
public async renderQuery(state: State | State.Warm, tasks: Task[]) {
38+
this.beginRender();
39+
3840
// Don't log anything here, for any state, as it generates huge amounts of
3941
// console messages in large vaults, if Obsidian was opened with any
4042
// notes with tasks code blocks in Reading or Live Preview mode.
@@ -50,6 +52,13 @@ export abstract class QueryResultsRendererBase {
5052
}
5153
}
5254

55+
/**
56+
* This is called at the start of every render, implement this if you want to reset some state for each render.
57+
*
58+
* @protected
59+
*/
60+
protected abstract beginRender(): void;
61+
5362
private async renderQuerySearchResults(tasks: Task[]) {
5463
const queryResult = this.explainAndPerformSearch(tasks);
5564

0 commit comments

Comments
 (0)