Skip to content

Commit 541d28c

Browse files
committed
[us40] Run all rules automatically after adding a new rule, a new file or updating an existing rule
1 parent 51b87d5 commit 541d28c

9 files changed

+227
-88
lines changed

src/app/database/database-backup-and-restore.service.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe('DatabaseBackupAndRestoreService', () => {
165165
mockFilesCacheService([dbBackupFile]);
166166

167167
// Act
168-
let restorePromise = lastValueFrom(databaseBackupAndRestoreService.restore(), {defaultValue: undefined});
168+
let restorePromise = lastValueFrom(databaseBackupAndRestoreService.restore());
169169

170170
// Assert
171171
tick();

src/app/database/database-backup-and-restore.service.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class DatabaseBackupAndRestoreService {
5050
finalize(() => this.updateLastDbBackupTime())
5151
);
5252
} else {
53-
return of();
53+
return of(undefined);
5454
}
5555
}
5656

src/app/rules/rule.repository.ts

+9
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ import {Injectable} from '@angular/core';
22
import {db} from "../database/db";
33
import {DatabaseBackupAndRestoreService} from "../database/database-backup-and-restore.service";
44

5+
interface FileRun {
6+
id: string;
7+
value: boolean;
8+
}
9+
510
export interface Rule {
611
id?: number;
712
name: string;
813
category: string[];
914
script: string;
15+
// List of all file ids the rule was already run on to avoid running the rule twice in the same condition
16+
fileRuns?: FileRun[];
1017
}
1118

1219
@Injectable()
@@ -34,6 +41,8 @@ export class RuleRepository {
3441
async update(rule: Rule) {
3542
if (rule.id) {
3643
await db.rules.update(rule.id, rule);
44+
// TODO: Postpone backup until the end of running all rules? Or don't show backup progress?
45+
// Or don't upload it to google drive after each local backup? (and then only show message when uploading to google drive)
3746
this.databaseBackupAndRestoreService.backup().subscribe();
3847
}
3948
}

src/app/rules/rule.service.spec.ts

+83-1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ function mockElectricityBillSample(file: FileElement, fileService: FileService)
5656
when(() => ruleRepository.findAll())
5757
.thenResolve(getSampleRules());
5858

59+
// The first rule should be flagged as matching
60+
let ruleAfterRun = getSampleRules()[0];
61+
ruleAfterRun.fileRuns = [{id: file.id, value: true}];
62+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
63+
5964
mockFilesCacheService([file], true);
6065

6166
return service;
@@ -117,6 +122,41 @@ describe('RuleService', () => {
117122
// No unexpected calls to fileService.setCategory
118123
}));
119124

125+
it('should not run a rule for a file it was already run on', fakeAsync(async () => {
126+
// Arrange
127+
let backgroundTaskService = mockBackgroundTaskService();
128+
let progress = mock<BehaviorSubject<Progress>>();
129+
when(() => backgroundTaskService.showProgress("Running all rules", "", 2))
130+
.thenReturn(progress);
131+
when(() => progress.next({
132+
index: 2,
133+
value: 100,
134+
})).thenReturn();
135+
136+
let file = mockFileElement('electricity_bill.txt');
137+
138+
const service = MockRender(RuleService).point.componentInstance;
139+
140+
let ruleRepository = ngMocks.findInstance(RuleRepository);
141+
when(() => ruleRepository.findAll())
142+
.thenResolve([{
143+
name: 'Electric bill',
144+
category: ['Electricity', 'Bills'],
145+
script: 'return fileContent.startsWith("Electricity Bill");',
146+
fileRuns: [{id: file.id, value: false}]
147+
}]);
148+
149+
mockFilesCacheService([file]);
150+
151+
// Act
152+
let runAllPromise = lastValueFrom(service.runAll(), {defaultValue: undefined});
153+
154+
// Assert
155+
tick();
156+
await runAllPromise;
157+
// No failure in mock setup
158+
}));
159+
120160
it('should automatically categorize a file (using txt file content)', fakeAsync(async () => {
121161
// Arrange
122162
let backgroundTaskService = mockBackgroundTaskService();
@@ -183,6 +223,31 @@ describe('RuleService', () => {
183223
script: 'return false'
184224
}]);
185225

226+
// The first rule should be flagged as matching
227+
let ruleAfterRun = {
228+
name: 'Electric bill',
229+
category: ['Electricity', 'Bills'],
230+
script: 'return fileContent.startsWith("Electricity Bill");',
231+
fileRuns: [{id: file.id, value: true}]
232+
};
233+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
234+
235+
// Both rules should be flagged as not matching for the other file
236+
ruleAfterRun = {
237+
name: 'Electric bill',
238+
category: ['Electricity', 'Bills'],
239+
script: 'return fileContent.startsWith("Electricity Bill");',
240+
fileRuns: [{id: file.id, value: true}, {id: otherFile.id, value: false}]
241+
};
242+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
243+
ruleAfterRun = {
244+
name: 'Dumb rule',
245+
category: ['Dumb'],
246+
script: 'return false',
247+
fileRuns: [{id: otherFile.id, value: false}]
248+
};
249+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
250+
186251
mockFilesCacheService([file, otherFile], true);
187252

188253
// Act
@@ -225,7 +290,15 @@ describe('RuleService', () => {
225290
script: 'return fileContent.includes("test")'
226291
}]);
227292

228-
mockFilesCacheService([file], true);
293+
let ruleAfterRun = {
294+
name: 'Dumb file content rule',
295+
category: ['Dumb'],
296+
script: 'return fileContent.includes("test")',
297+
fileRuns: [{id: file.id, value: false}]
298+
};
299+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
300+
301+
mockFilesCacheService([file]);
229302

230303
// Act
231304
let runAllPromise = lastValueFrom(service.runAll(), {defaultValue: undefined});
@@ -283,6 +356,15 @@ describe('RuleService', () => {
283356
script: 'return fileContent.startsWith("Dummy");'
284357
}]);
285358

359+
// The first rule should be flagged as matching
360+
let ruleAfterRun = {
361+
name: 'Dummy',
362+
category: ['Dummy'],
363+
script: 'return fileContent.startsWith("Dummy");',
364+
fileRuns: [{id: file.id, value: true}]
365+
};
366+
when(() => ruleRepository.update(ruleAfterRun)).thenResolve();
367+
286368
mockFilesCacheService([file], true);
287369

288370
// Act

src/app/rules/rule.service.ts

+103-72
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Injectable} from '@angular/core';
22
import {FileService} from "../file-list/file.service";
3-
import {BehaviorSubject, concatMap, filter, find, from, last, map, mergeMap, Observable, of, tap, zip} from "rxjs";
3+
import {BehaviorSubject, concatMap, filter, find, from, map, mergeMap, Observable, of, tap, zip} from "rxjs";
44
import {FileElement, isFileElement} from "../file-list/file-list.component";
55
import {Rule, RuleRepository} from "./rule.repository";
66
import {FilesCacheService} from "../files-cache/files-cache.service";
@@ -34,25 +34,42 @@ export class RuleService {
3434
this.worker = new Worker(new URL('./rule.worker', import.meta.url));
3535
const pdfWorkerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
3636
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
37+
38+
//TODO: mark each file to know which rules have already been run and when, then every time we load the page,
39+
// we check for all pair of rules/files which have not run or are outdated
40+
}
41+
42+
private static isRuleRunNeeded(rules: Rule[], file: FileElement) {
43+
for (const rule of rules) {
44+
let previousFileRun = rule.fileRuns?.find(fileRun => fileRun.id === file.id);
45+
if (previousFileRun && previousFileRun.value) {
46+
// We already know the matching rule
47+
return false;
48+
}
49+
if (!previousFileRun) {
50+
// There is a rule we need to run which has not been run before
51+
return true;
52+
}
53+
}
54+
// All rules have already been run
55+
return false;
3756
}
3857

3958
runAll(): Observable<void> {
4059
return from(this.ruleRepository.findAll())
4160
.pipe(mergeMap(rules => {
4261
let fileOrFolders = this.filesCacheService.getAll()
4362
// Get all files
44-
let files = fileOrFolders.filter(file => isFileElement(file))
45-
.map(value => value as FileElement);
63+
let files = fileOrFolders
64+
.filter((file): file is FileElement => isFileElement(file));
65+
4666

4767
// Run the script for each file to get the associated category
4868
// The amount of step is one download per file and one per rule running for each file
4969
let stepAmount = files.length * (1 + rules.length);
5070
let progress = this.backgroundTaskService.showProgress('Running all rules', '', stepAmount);
51-
return this.computeFileToCategoryMap(files, rules, progress)
52-
.pipe(mergeMap(fileToCategoryMap => {
53-
// Set the computed category for each files
54-
return this.setAllFileCategory(fileToCategoryMap);
55-
}), tap({complete: () => progress.next({value: 100, index: stepAmount})}))
71+
return this.runAllAndSetCategories(files, rules, progress)
72+
.pipe(tap({complete: () => progress.next({value: 100, index: stepAmount})}));
5673
}));
5774
}
5875

@@ -73,63 +90,97 @@ export class RuleService {
7390
}
7491

7592
/**
76-
* Run the given rules on the given files and return the associated category for each file that got a matching rule
93+
* Run the given rules on the given files and set the associated category for each file that got a matching rule
7794
*/
78-
private computeFileToCategoryMap(files: FileElement[], rules: Rule[], progress: BehaviorSubject<Progress>) {
79-
let fileToCategoryMap = new Map<FileElement, string[]>();
95+
private runAllAndSetCategories(files: FileElement[], rules: Rule[], progress: BehaviorSubject<Progress>) {
8096
return zip(from(files)
8197
.pipe(concatMap((file, fileIndex) => {
8298
let progressIndex = 1 + fileIndex * (rules.length + 1);
83-
84-
let fileContentObservable: Observable<string>;
85-
if (this.isFileContentReadable(file)) {
86-
progress.next({
87-
index: progressIndex,
88-
value: 0,
89-
description: "Downloading file content of '" + file.name + "'"
90-
});
91-
fileContentObservable = this.fileService.downloadFile(file, progress)
92-
.pipe(mergeMap(blobContent => {
93-
if (file.mimeType === 'application/pdf') {
94-
return fromPromise(blobContent.arrayBuffer()
95-
.then(arrayBuffer => pdfjs.getDocument(arrayBuffer).promise)
96-
.then(pdfDocument => pdfDocument.getPage(1))
97-
.then(firstPage => firstPage.getTextContent())
98-
.then(textContent => textContent.items
99-
.filter((item): item is TextItem => item !== undefined)
100-
.map(item => "" + item.str).join()));
101-
} else {
102-
return fromPromise(blobContent.text());
103-
}
104-
}));
105-
} else {
106-
fileContentObservable = of("");
99+
if (!RuleService.isRuleRunNeeded(rules, file)) {
100+
return of(undefined);
107101
}
108-
return fileContentObservable.pipe(
102+
return this.getFileContent(file, progress, progressIndex).pipe(
109103
mergeMap(fileContent => {
110104
// Find the first rule which matches
111-
return from(rules).pipe(concatMap((rule, ruleIndex) => {
112-
progress.next({
113-
index: progressIndex + 1 + ruleIndex,
114-
value: 0,
115-
description: "Running rule '" + rule.name + "' for '" + file.name + "'"
116-
});
117-
return this.run(rule, file, fileContent, progress, progressIndex + 1 + ruleIndex);
118-
}),
119-
// Find will stop running further scripts once we got a match
120-
find(result => {
121-
return result.value;
122-
}),
123-
map(result => {
105+
return this.runAllRules(rules, progress, progressIndex, file, fileContent)
106+
.pipe(mergeMap(result => {
107+
// TODO: do the call to change the category immediately instead of constructing this map
108+
// TODO: How to handle rules that have not run due to finding another matching rule? flag the matching files?
124109
if (result) {
125-
fileToCategoryMap.set(file, result.rule.category);
110+
return this.findOrCreateCategories(Object.assign([], result.rule.category), this.filesCacheService.getBaseFolder())
111+
// There is no need to set the category if the current category is correct
112+
.pipe(filter(categoryId => file.parentId !== categoryId),
113+
mergeMap(categoryId => {
114+
return this.fileService.setCategory(file.id, categoryId);
115+
}));
116+
} else {
117+
return of();
126118
}
127119
}));
128120
}));
129121
})))
130-
.pipe(last(),
131-
map(() => fileToCategoryMap));
122+
.pipe(map(() => {
123+
}));
124+
125+
}
132126

127+
private runAllRules(rulesToRun: Rule[], progress: BehaviorSubject<Progress>, progressIndex: number, file: FileElement, fileContent: string) {
128+
return from(rulesToRun).pipe(concatMap((rule, ruleIndex) => {
129+
let previousFileRun = rule.fileRuns?.find(fileRun => fileRun.id === file.id);
130+
if (previousFileRun) {
131+
// The rule was run previously, so we already know the result
132+
let result: RuleResult = {
133+
rule: rule,
134+
value: previousFileRun.value
135+
};
136+
return of(result);
137+
}
138+
progress.next({
139+
index: progressIndex + 1 + ruleIndex,
140+
value: 0,
141+
description: "Running rule '" + rule.name + "' for '" + file.name + "'"
142+
});
143+
return this.run(rule, file, fileContent, progress, progressIndex + 1 + ruleIndex)
144+
.pipe(tap((result) => {
145+
// Add this file run to the rule fileRuns to avoid doing the same run again
146+
let rule = result.rule;
147+
if (!rule.fileRuns) {
148+
rule.fileRuns = [];
149+
}
150+
rule.fileRuns.push({id: file.id, value: result.value});
151+
this.ruleRepository.update(rule);
152+
}));
153+
}),
154+
// Find will stop running further scripts once we got a match
155+
find(result => {
156+
return result.value;
157+
}));
158+
}
159+
160+
private getFileContent(file: FileElement, progress: BehaviorSubject<Progress>, progressIndex: number) {
161+
if (this.isFileContentReadable(file)) {
162+
progress.next({
163+
index: progressIndex,
164+
value: 0,
165+
description: "Downloading file content of '" + file.name + "'"
166+
});
167+
return this.fileService.downloadFile(file, progress)
168+
.pipe(mergeMap(blobContent => {
169+
if (file.mimeType === 'application/pdf') {
170+
return fromPromise(blobContent.arrayBuffer()
171+
.then(arrayBuffer => pdfjs.getDocument(arrayBuffer).promise)
172+
.then(pdfDocument => pdfDocument.getPage(1))
173+
.then(firstPage => firstPage.getTextContent())
174+
.then(textContent => textContent.items
175+
.filter((item): item is TextItem => item !== undefined)
176+
.map(item => "" + item.str).join()));
177+
} else {
178+
return fromPromise(blobContent.text());
179+
}
180+
}));
181+
} else {
182+
return of("");
183+
}
133184
}
134185

135186
private isFileContentReadable(file: FileElement) {
@@ -151,27 +202,7 @@ export class RuleService {
151202
});
152203
}
153204

154-
/**
155-
* Find or create the categories for each file and associate them
156-
*/
157-
private setAllFileCategory(fileToCategoryMap: Map<FileElement, string[]>): Observable<void> {
158-
let baseFolderId = this.filesCacheService.getBaseFolder();
159-
let categoryRequests: Observable<void>[] = [];
160-
fileToCategoryMap
161-
.forEach((category, file) => {
162-
categoryRequests.push(this.findOrCreateCategories(category, baseFolderId)
163-
// There is no need to set the category if the current category is correct
164-
.pipe(filter(categoryId => file.parentId !== categoryId),
165-
mergeMap(categoryId => {
166-
return this.fileService.setCategory(file.id, categoryId);
167-
})));
168-
});
169-
return zip(categoryRequests).pipe(map(() => {
170-
}));
171-
}
172-
173205
// TODO: move and refactor duplicate to FileService
174-
175206
private findOrCreateCategories(categories: string[], categoryId: string): Observable<string> {
176207
let categoryName = categories.shift();
177208
if (categoryName !== undefined) {

0 commit comments

Comments
 (0)