Skip to content

Commit 4a7a530

Browse files
authored
Merge pull request #3224 from obsidian-tasks-group/refactor-FileParser
refactor: Introduce FileParser class
2 parents 0493a15 + c2a07d9 commit 4a7a530

File tree

7 files changed

+408
-108
lines changed

7 files changed

+408
-108
lines changed
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# multiple_headings
22

3+
## Level 2 heading with no taks
4+
35
## Level 2 heading
46

7+
- [ ] #task Task 1 in Level 2 heading - in 'multiple_headings' - list 1
8+
- [ ] #task task 2 in Level 2 heading - in 'multiple_headings' - list 1
9+
10+
Divider to create a new section
11+
12+
- [ ] #task task 3 in Level 2 heading - in 'multiple_headings' - list 2
13+
514
### Level 3 heading
615

7-
- [ ] #task Task in 'multiple_headings'
16+
- [ ] #task Task 4 in 'multiple_headings' - in 'multiple_headings' - list 3
17+
- [ ] #task Task 5 in 'multiple_headings' - in 'multiple_headings' - list 3

src/Obsidian/Cache.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getSettings } from '../Config/Settings';
1010
import { Lazy } from '../lib/Lazy';
1111
import { Logger, logging } from '../lib/logging';
1212
import type { TasksEvents } from './TasksEvents';
13-
import { parseFileContent } from './FileParser';
13+
import { FileParser } from './FileParser';
1414

1515
export enum State {
1616
Cold = 'Cold',
@@ -307,7 +307,8 @@ export class Cache {
307307
errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void,
308308
logger: Logger,
309309
): Task[] {
310-
return parseFileContent(filePath, fileContent, listItems, logger, fileCache, errorReporter);
310+
const fileParser = new FileParser(filePath, fileContent, listItems, logger, fileCache, errorReporter);
311+
return fileParser.parseFileContent();
311312
}
312313

313314
private reportTaskParsingErrorToUser(e: any, filePath: string, listItem: ListItemCache, line: string) {

src/Obsidian/FileParser.ts

Lines changed: 112 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,118 +8,151 @@ import { ListItem } from '../Task/ListItem';
88
import { TaskLocation } from '../Task/TaskLocation';
99
import { Cache } from './Cache';
1010

11-
export function parseFileContent(
12-
filePath: string,
13-
fileContent: string,
14-
listItems: ListItemCache[] | undefined,
15-
logger: Logger,
16-
fileCache: CachedMetadata,
17-
errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void,
18-
) {
19-
const tasks: Task[] = [];
20-
if (listItems === undefined) {
21-
// When called via Cache, this function would never be called or files without list items.
22-
// It is useful for tests to be act gracefully on sample Markdown files with no list items, however.
23-
return tasks;
11+
export class FileParser {
12+
private readonly filePath: string;
13+
private readonly fileContent: string;
14+
private readonly listItems: ListItemCache[];
15+
private readonly logger: Logger;
16+
private readonly fileCache: CachedMetadata;
17+
private readonly errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void;
18+
19+
private readonly fileLines: string[];
20+
private readonly line2ListItem: Map<number, ListItem> = new Map();
21+
private readonly tasks: Task[] = [];
22+
private readonly dateFromFileName: Lazy<moment.Moment | null>;
23+
24+
constructor(
25+
filePath: string,
26+
fileContent: string,
27+
listItems: ListItemCache[],
28+
logger: Logger,
29+
fileCache: CachedMetadata,
30+
errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void,
31+
) {
32+
this.filePath = filePath;
33+
this.fileContent = fileContent;
34+
this.listItems = listItems;
35+
this.logger = logger;
36+
this.fileCache = fileCache;
37+
this.errorReporter = errorReporter;
38+
this.fileLines = this.fileContent.split('\n');
39+
40+
// Lazily store date extracted from filename to avoid parsing more than needed
41+
this.dateFromFileName = new Lazy(() => DateFallback.fromPath(this.filePath));
2442
}
2543

26-
const tasksFile = new TasksFile(filePath, fileCache);
27-
const fileLines = fileContent.split('\n');
28-
const linesInFile = fileLines.length;
29-
30-
// Lazily store date extracted from filename to avoid parsing more than needed
31-
// this.logger.debug(`getTasksFromFileContent() reading ${file.path}`);
32-
const dateFromFileName = new Lazy(() => DateFallback.fromPath(filePath));
33-
34-
// We want to store section information with every task so
35-
// that we can use that when we post process the markdown
36-
// rendered lists.
37-
let currentSection: SectionCache | null = null;
38-
let sectionIndex = 0;
39-
const line2ListItem: Map<number, ListItem> = new Map();
40-
for (const listItem of listItems) {
41-
const lineNumber = listItem.position.start.line;
42-
if (lineNumber >= linesInFile) {
43-
/*
44-
Obsidian CachedMetadata has told us that there is a task on lineNumber, but there are
45-
not that many lines in the file.
46-
47-
This was the underlying cause of all the 'Stuck on "Loading Tasks..."' messages,
48-
as it resulted in the line 'undefined' being parsed.
49-
50-
Somehow the file had been shortened whilst Obsidian was closed, meaning that
51-
when Obsidian started up, it got the new file content, but still had the old cached
52-
data about locations of list items in the file.
53-
*/
54-
logger.debug(
55-
`${filePath} Obsidian gave us a line number ${lineNumber} past the end of the file. ${linesInFile}.`,
56-
);
57-
return tasks;
58-
}
59-
if (currentSection === null || currentSection.position.end.line < lineNumber) {
60-
// We went past the current section (or this is the first task).
61-
// Find the section that is relevant for this task and the following of the same section.
62-
currentSection = Cache.getSection(lineNumber, fileCache.sections);
63-
sectionIndex = 0;
44+
/**
45+
* **Warning**: This is designed to only be called **once per {@link FileParser} instance**.
46+
*/
47+
public parseFileContent() {
48+
if (this.listItems === undefined) {
49+
// When called via Cache, this function would never be called or files without list items.
50+
// It is useful for tests to be act gracefully on sample Markdown files with no list items, however.
51+
return this.tasks;
6452
}
6553

66-
if (currentSection === null) {
67-
// Cannot process a task without a section.
68-
continue;
69-
}
54+
const tasksFile = new TasksFile(this.filePath, this.fileCache);
55+
const linesInFile = this.fileLines.length;
56+
57+
// this.logger.debug(`FileParser.parseFileContent() reading ${this.filePath}`);
7058

71-
const line = fileLines[lineNumber];
72-
if (line === undefined) {
73-
logger.debug(`${filePath}: line ${lineNumber} - ignoring 'undefined' line.`);
74-
continue;
59+
// We want to store section information with every task so
60+
// that we can use that when we post process the markdown
61+
// rendered lists.
62+
let currentSection: SectionCache | null = null;
63+
let sectionIndex = 0;
64+
for (const listItem of this.listItems) {
65+
const lineNumber = listItem.position.start.line;
66+
if (lineNumber >= linesInFile) {
67+
/*
68+
Obsidian CachedMetadata has told us that there is a task on lineNumber, but there are
69+
not that many lines in the file.
70+
71+
This was the underlying cause of all the 'Stuck on "Loading Tasks..."' messages,
72+
as it resulted in the line 'undefined' being parsed.
73+
74+
Somehow the file had been shortened whilst Obsidian was closed, meaning that
75+
when Obsidian started up, it got the new file content, but still had the old cached
76+
data about locations of list items in the file.
77+
*/
78+
this.logger.debug(
79+
`${this.filePath} Obsidian gave us a line number ${lineNumber} past the end of the file. ${linesInFile}.`,
80+
);
81+
return this.tasks;
82+
}
83+
if (currentSection === null || currentSection.position.end.line < lineNumber) {
84+
// We went past the current section (or this is the first task).
85+
// Find the section that is relevant for this task and the following of the same section.
86+
currentSection = Cache.getSection(lineNumber, this.fileCache.sections);
87+
sectionIndex = 0;
88+
}
89+
90+
if (currentSection === null) {
91+
// Cannot process a task without a section.
92+
continue;
93+
}
94+
95+
const line = this.fileLines[lineNumber];
96+
if (line === undefined) {
97+
this.logger.debug(`${this.filePath}: line ${lineNumber} - ignoring 'undefined' line.`);
98+
continue;
99+
}
100+
101+
const taskLocation = new TaskLocation(
102+
tasksFile,
103+
lineNumber,
104+
currentSection.position.start.line,
105+
sectionIndex,
106+
Cache.getPrecedingHeader(lineNumber, this.fileCache.headings),
107+
);
108+
sectionIndex = this.parseLine(listItem, line, taskLocation, lineNumber, sectionIndex);
75109
}
76110

77-
const taskLocation = new TaskLocation(
78-
tasksFile,
79-
lineNumber,
80-
currentSection.position.start.line,
81-
sectionIndex,
82-
Cache.getPrecedingHeader(lineNumber, fileCache.headings),
83-
);
111+
return this.tasks;
112+
}
113+
114+
private parseLine(
115+
listItem: ListItemCache,
116+
line: string,
117+
taskLocation: TaskLocation,
118+
lineNumber: number,
119+
sectionIndex: number,
120+
) {
84121
if (listItem.task !== undefined) {
85122
let task;
86123
try {
87124
task = Task.fromLine({
88125
line,
89126
taskLocation: taskLocation,
90-
fallbackDate: dateFromFileName.value,
127+
fallbackDate: this.dateFromFileName.value,
91128
});
92129

93130
if (task !== null) {
94131
// listItem.parent could be negative if the parent is not found (in other words, it is a root task).
95132
// That is not a problem, as we never put a negative number in line2ListItem map, so parent will be null.
96-
const parentListItem: ListItem | null = line2ListItem.get(listItem.parent) ?? null;
133+
const parentListItem: ListItem | null = this.line2ListItem.get(listItem.parent) ?? null;
97134
if (parentListItem !== null) {
98135
task = new Task({
99136
...task,
100137
parent: parentListItem,
101138
});
102139
}
103140

104-
line2ListItem.set(lineNumber, task);
141+
this.line2ListItem.set(lineNumber, task);
105142
}
106143
} catch (e) {
107-
errorReporter(e, filePath, listItem, line);
108-
continue;
144+
this.errorReporter(e, this.filePath, listItem, line);
145+
return sectionIndex;
109146
}
110147

111148
if (task !== null) {
112149
sectionIndex++;
113-
tasks.push(task);
150+
this.tasks.push(task);
114151
}
115152
} else {
116-
const lineNumber = listItem.position.start.line;
117-
118-
const parentListItem: ListItem | null = line2ListItem.get(listItem.parent) ?? null;
119-
120-
line2ListItem.set(lineNumber, new ListItem(fileLines[lineNumber], parentListItem));
153+
const parentListItem: ListItem | null = this.line2ListItem.get(listItem.parent) ?? null;
154+
this.line2ListItem.set(lineNumber, new ListItem(this.fileLines[lineNumber], parentListItem));
121155
}
156+
return sectionIndex;
122157
}
123-
124-
return tasks;
125158
}

src/Task/TaskLocation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,9 @@ export class TaskLocation {
8585
public get hasKnownPath(): boolean {
8686
return this.path !== '';
8787
}
88+
89+
public allFieldsExceptTasksFileForTesting() {
90+
const { _tasksFile, ...rest } = { ...this };
91+
return rest;
92+
}
8893
}

tests/Obsidian/FileParser.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { readTasksFromSimulatedFile } from './SimulatedFile';
2+
import multiple_headings from './__test_data__/multiple_headings.json';
3+
4+
describe('FileParser', () => {
5+
it('should set all non-TasksFile data in TaskLocation', () => {
6+
// This test exists to detect accidental changes to the task-reading code.
7+
// I found that intentionally breaking the setting of _sectionIndex was
8+
// not caught by any other tests.
9+
10+
const tasks = readTasksFromSimulatedFile(multiple_headings);
11+
const locationDataExceptTasksFile = tasks.map((task) => task.taskLocation.allFieldsExceptTasksFileForTesting());
12+
expect(locationDataExceptTasksFile).toMatchInlineSnapshot(`
13+
[
14+
{
15+
"_lineNumber": 6,
16+
"_precedingHeader": "Level 2 heading",
17+
"_sectionIndex": 0,
18+
"_sectionStart": 6,
19+
},
20+
{
21+
"_lineNumber": 7,
22+
"_precedingHeader": "Level 2 heading",
23+
"_sectionIndex": 1,
24+
"_sectionStart": 6,
25+
},
26+
{
27+
"_lineNumber": 11,
28+
"_precedingHeader": "Level 2 heading",
29+
"_sectionIndex": 0,
30+
"_sectionStart": 11,
31+
},
32+
{
33+
"_lineNumber": 15,
34+
"_precedingHeader": "Level 3 heading",
35+
"_sectionIndex": 0,
36+
"_sectionStart": 15,
37+
},
38+
{
39+
"_lineNumber": 16,
40+
"_precedingHeader": "Level 3 heading",
41+
"_sectionIndex": 1,
42+
"_sectionStart": 15,
43+
},
44+
]
45+
`);
46+
});
47+
});

tests/Obsidian/SimulatedFile.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CachedMetadata } from 'obsidian';
22
import { logging } from '../../src/lib/logging';
3-
import { parseFileContent } from '../../src/Obsidian/FileParser';
3+
import { FileParser } from '../../src/Obsidian/FileParser';
44
import { setCurrentCacheFile } from '../__mocks__/obsidian';
55

66
export interface SimulatedFile {
@@ -18,14 +18,15 @@ export interface SimulatedFile {
1818
export function readTasksFromSimulatedFile(testData: SimulatedFile) {
1919
const logger = logging.getLogger('testCache');
2020
setCurrentCacheFile(testData);
21-
return parseFileContent(
21+
const fileParser = new FileParser(
2222
testData.filePath,
2323
testData.fileContents,
2424
testData.cachedMetadata.listItems!,
2525
logger,
2626
testData.cachedMetadata,
2727
errorReporter,
2828
);
29+
return fileParser.parseFileContent();
2930
}
3031

3132
function errorReporter() {

0 commit comments

Comments
 (0)