Skip to content

Commit ed925f9

Browse files
authored
Merge pull request #3281 from SeedCompany/pnp-validation
Expose PnP Problems on progress upload
2 parents a81116d + 4ff3171 commit ed925f9

32 files changed

+1097
-357
lines changed

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PartnershipProducingMediumModule } from './components/partnership-produ
2424
import { PartnershipModule } from './components/partnership/partnership.module';
2525
import { PeriodicReportModule } from './components/periodic-report/periodic-report.module';
2626
import { PinModule } from './components/pin/pin.module';
27+
import { PnpExtractionResultModule } from './components/pnp/extraction-result/pnp-extraction-result.module';
2728
import { PostModule } from './components/post/post.module';
2829
import { ProductProgressModule } from './components/product-progress/product-progress.module';
2930
import { ProductModule } from './components/product/product.module';
@@ -87,6 +88,7 @@ if (process.env.NODE_ENV !== 'production') {
8788
PartnershipProducingMediumModule,
8889
ProgressReportModule,
8990
PromptsModule,
91+
PnpExtractionResultModule,
9092
],
9193
})
9294
export class AppModule {}

src/common/markdown.scalar.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { CustomScalar, Scalar } from '@nestjs/graphql';
2+
import { GraphQLError, Kind, ValueNode } from 'graphql';
3+
4+
@Scalar('InlineMarkdown')
5+
export class InlineMarkdownScalar
6+
implements CustomScalar<string, string | null>
7+
{
8+
description = 'A string that holds inline Markdown formatted text';
9+
10+
parseLiteral(ast: ValueNode): string | null {
11+
if (ast.kind !== Kind.STRING) {
12+
throw new GraphQLError(
13+
`Can only validate strings as InlineMarkdown but got a: ${ast.kind}`,
14+
);
15+
}
16+
return ast.value;
17+
}
18+
parseValue(value: any) {
19+
return value;
20+
}
21+
serialize(value: any) {
22+
return value;
23+
}
24+
}

src/common/scalars.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CustomScalar } from '@nestjs/graphql';
33
import { GraphQLScalarType } from 'graphql';
44
import UploadScalar from 'graphql-upload/GraphQLUpload.mjs';
55
import { DateScalar, DateTimeScalar } from './luxon.graphql';
6+
import { InlineMarkdownScalar } from './markdown.scalar';
67
import { RichTextScalar } from './rich-text.scalar';
78
import { UrlScalar } from './url.field';
89

@@ -15,4 +16,5 @@ export const getRegisteredScalars = (): Scalar[] => [
1516
RichTextScalar,
1617
UploadScalar,
1718
UrlScalar,
19+
InlineMarkdownScalar,
1820
];

src/common/xlsx.util.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,15 @@ export class Cell<TSheet extends Sheet = Sheet> {
252252
: undefined;
253253
}
254254

255-
toString() {
255+
get fqn() {
256+
return `${this.sheet.name}!${this.ref}`;
257+
}
258+
get ref() {
256259
return `${this.column.a1}${this.row.a1}`;
257260
}
261+
toString() {
262+
return this.ref;
263+
}
258264
}
259265

260266
abstract class Rangable<TSheet extends Sheet = Sheet> {
@@ -301,9 +307,12 @@ abstract class Rangable<TSheet extends Sheet = Sheet> {
301307
);
302308
}
303309

304-
toString() {
310+
get ref() {
305311
return `${this.sheet.name}!${this.start.toString()}:${this.end.toString()}`;
306312
}
313+
toString() {
314+
return this.ref;
315+
}
307316
}
308317

309318
export class Range<TSheet extends Sheet = Sheet> extends Rangable<TSheet> {
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { LazyGetter as Once } from 'lazy-get-decorator';
12
import { Session } from '~/common';
2-
import { Downloadable, FileNode } from '../../file/dto';
3+
import { Downloadable, FileVersion } from '../../file/dto';
4+
import { PnpProgressExtractionResult } from '../../pnp/extraction-result';
35
import { PeriodicReport } from '../dto';
46

57
/**
@@ -8,7 +10,13 @@ import { PeriodicReport } from '../dto';
810
export class PeriodicReportUploadedEvent {
911
constructor(
1012
readonly report: PeriodicReport,
11-
readonly file: Downloadable<FileNode>,
13+
readonly file: Downloadable<FileVersion>,
1214
readonly session: Session,
1315
) {}
16+
17+
pnpResultUsed = false;
18+
@Once() get pnpResult() {
19+
this.pnpResultUsed = true;
20+
return new PnpProgressExtractionResult(this.file.id);
21+
}
1422
}

src/components/pnp/extract-scripture.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,88 @@
11
import { parseScripture, tryParseScripture } from '@seedcompany/scripture';
22
import { Row } from '~/common/xlsx.util';
33
import { ScriptureRange } from '../scripture/dto';
4+
import { PnpExtractionResult } from './extraction-result';
5+
import { addProblemMismatchScriptureAndVerseCount } from './isGoalRow';
46
import { WrittenScripturePlanningSheet } from './planning-sheet';
57

6-
export const extractScripture = (row: Row<WrittenScripturePlanningSheet>) => {
8+
export const extractScripture = (
9+
row: Row<WrittenScripturePlanningSheet>,
10+
result: PnpExtractionResult,
11+
) => {
712
const sheet = row.sheet;
8-
const totalVerses = sheet.totalVerses(row)!;
9-
const scriptureFromBookCol = parseScripture(sheet.bookName(row));
13+
14+
const totalVersesCell = sheet.totalVerses(row)!;
15+
const totalVerses = totalVersesCell.asNumber!;
16+
17+
const bookCell = sheet.bookName(row);
18+
const scriptureFromBookCol = parseScripture(bookCell.asString);
19+
const book = scriptureFromBookCol[0].start.book;
1020

1121
const common = {
12-
bookName: scriptureFromBookCol[0].start.book.name,
22+
bookName: book.name,
1323
totalVerses,
1424
};
25+
let mismatchError = false;
1526

1627
// If scripture from book column matches total count, use it.
17-
if (ScriptureRange.totalVerses(...scriptureFromBookCol) === totalVerses) {
28+
const totalVersesInBookCol = ScriptureRange.totalVerses(
29+
...scriptureFromBookCol,
30+
);
31+
if (totalVersesInBookCol === totalVerses) {
1832
return {
1933
...common,
2034
scripture: scriptureFromBookCol.map(ScriptureRange.fromVerses),
2135
};
36+
// If it is more than just the book name (aka not just the book name) then
37+
// the verse count will be less and if it doesn't match the total, there is a problem
38+
} else if (totalVersesInBookCol < book.totalVerses) {
39+
mismatchError = true;
40+
// TODO I think this is a redundant check.
41+
// I don't think we will ever get here because the row is filtered out with
42+
// the isGoalRow function.
43+
addProblemMismatchScriptureAndVerseCount(
44+
result,
45+
totalVersesInBookCol,
46+
bookCell,
47+
totalVersesCell,
48+
);
2249
}
2350

2451
// Otherwise, if note column has scripture that matches the total count use it.
25-
const scriptureFromNoteCol = tryParseScripture(sheet.myNote(row));
26-
if (
27-
scriptureFromNoteCol &&
28-
ScriptureRange.totalVerses(...scriptureFromNoteCol) === totalVerses
29-
) {
30-
return {
31-
...common,
32-
scripture: scriptureFromNoteCol.map(ScriptureRange.fromVerses),
33-
};
52+
const noteCell = sheet.myNote(row);
53+
const scriptureFromNoteCol = tryParseScripture(noteCell.asString);
54+
if (scriptureFromNoteCol) {
55+
const totalVersesFromNoteCol = ScriptureRange.totalVerses(
56+
...scriptureFromNoteCol,
57+
);
58+
if (totalVersesFromNoteCol === totalVerses) {
59+
return {
60+
...common,
61+
scripture: scriptureFromNoteCol.map(ScriptureRange.fromVerses),
62+
};
63+
}
64+
mismatchError = true;
65+
result.addProblem({
66+
severity: 'Error',
67+
groups:
68+
'Mismatch between the planned scripture in _My Notes_ column and the number of verses to translate',
69+
message: `"${noteCell.asString!}" \`${
70+
noteCell.ref
71+
}\` is **${totalVersesFromNoteCol}** verses, but the goal declares **${totalVerses}** verses to translate \`${
72+
totalVersesCell.ref
73+
}\``,
74+
source: noteCell,
75+
});
3476
}
3577

3678
// Otherwise, fallback to unspecified scripture.
79+
!mismatchError &&
80+
result.addProblem({
81+
severity: 'Warning',
82+
groups: 'Unspecified scripture reference',
83+
message: `"${book.name}" \`${bookCell.ref}\` does not a have specified scripture reference (either in the _Books_ or _My Notes_ column)`,
84+
source: totalVersesCell,
85+
});
3786
return {
3887
...common,
3988
scripture: [],
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
2+
import { many, Many } from '@seedcompany/common';
3+
import { stripIndent } from 'common-tags';
4+
import * as uuid from 'uuid';
5+
import { EnumType, ID, IdField, makeEnum } from '~/common';
6+
import { InlineMarkdownScalar } from '~/common/markdown.scalar';
7+
import { Cell } from '~/common/xlsx.util';
8+
9+
export type PnpProblemSeverity = EnumType<typeof PnpProblemSeverity>;
10+
export const PnpProblemSeverity = makeEnum({
11+
name: 'PnpProblemSeverity',
12+
values: ['Error', 'Warning', 'Notice'],
13+
});
14+
15+
@ObjectType()
16+
export class PnpProblem {
17+
@IdField()
18+
readonly id: ID;
19+
20+
@Field(() => PnpProblemSeverity)
21+
readonly severity: PnpProblemSeverity;
22+
23+
@Field(() => InlineMarkdownScalar, {
24+
description: 'The message describing this specific problem',
25+
})
26+
readonly message: string;
27+
28+
@Field(() => String, {
29+
description: 'Sheet!A1',
30+
})
31+
readonly source: string;
32+
33+
@Field(() => [InlineMarkdownScalar], {
34+
description: stripIndent`
35+
Groupings for this problem.
36+
Order least specific to most.
37+
Formatted as human labels.
38+
`,
39+
})
40+
readonly groups: readonly string[];
41+
}
42+
43+
@InterfaceType()
44+
export abstract class PnpExtractionResult {
45+
constructor(private readonly fileVersionId: ID<'FileVersion'>) {}
46+
47+
@Field(() => [PnpProblem])
48+
readonly problems: PnpProblem[] = [];
49+
50+
addProblem(
51+
problem: Omit<PnpProblem, 'id' | 'groups' | 'source'> & {
52+
id?: string;
53+
groups?: Many<string>;
54+
source: Cell;
55+
},
56+
) {
57+
const id = (problem.id ??
58+
uuid.v5(
59+
[this.fileVersionId, problem.message, problem.source.fqn].join('\0'),
60+
ID_NS,
61+
)) as ID;
62+
63+
// Ignore dupes
64+
if (this.problems.some((p) => p.id === id)) return;
65+
66+
this.problems.push({
67+
...problem,
68+
id,
69+
groups: [problem.source.sheet.name, ...many(problem.groups ?? [])],
70+
source: problem.source.fqn,
71+
});
72+
}
73+
}
74+
@ObjectType({ implements: PnpExtractionResult })
75+
export class PnpPlanningExtractionResult extends PnpExtractionResult {}
76+
@ObjectType({ implements: PnpExtractionResult })
77+
export class PnpProgressExtractionResult extends PnpExtractionResult {}
78+
79+
const ID_NS = 'bab2666a-a0f5-4168-977d-7ef6399503f9';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './extraction-result.dto';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
2+
import { Loader, LoaderOf } from '@seedcompany/data-loader';
3+
import { ProgressReport } from '../../progress-report/dto';
4+
import { PnpProgressExtractionResult } from './extraction-result.dto';
5+
import { PnpExtractionResultLoader } from './pnp-extraction-result.loader';
6+
7+
@Resolver(ProgressReport)
8+
export class PnpExtractionResultProgressReportConnectionResolver {
9+
@ResolveField(() => PnpProgressExtractionResult, {
10+
nullable: true,
11+
})
12+
async pnpExtractionResult(
13+
@Parent() report: ProgressReport,
14+
@Loader(() => PnpExtractionResultLoader)
15+
loader: LoaderOf<PnpExtractionResultLoader>,
16+
): Promise<PnpProgressExtractionResult | null> {
17+
const fileId = report.reportFile.value;
18+
if (!fileId) {
19+
return null;
20+
}
21+
const { result } = await loader.load(fileId);
22+
return result;
23+
}
24+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ID, NotImplementedException } from '~/common';
3+
import { CommonRepository } from '~/core/edgedb';
4+
import { PnpExtractionResult } from './extraction-result.dto';
5+
import { PnpExtractionResultLoadResult } from './pnp-extraction-result.loader';
6+
7+
@Injectable()
8+
export class PnpExtractionResultRepository extends CommonRepository {
9+
async read(
10+
files: ReadonlyArray<ID<'File'>>,
11+
): Promise<readonly PnpExtractionResultLoadResult[]> {
12+
throw new NotImplementedException().with(files);
13+
}
14+
15+
async save(
16+
file: ID<'FileVersion'>,
17+
result: PnpExtractionResult,
18+
): Promise<void> {
19+
throw new NotImplementedException().with(file, result);
20+
}
21+
}

0 commit comments

Comments
 (0)