@chronicle.app/marvin 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/connectors/MarvinAnnotationsExtractor.d.ts +10 -0
- package/dist/connectors/MarvinAnnotationsExtractor.js +24 -0
- package/dist/connectors/MarvinExtractor.d.ts +11 -0
- package/dist/connectors/MarvinExtractor.js +19 -0
- package/dist/connectors/MarvinSessionsExtractor.d.ts +11 -0
- package/dist/connectors/MarvinSessionsExtractor.js +57 -0
- package/dist/connectors/marvin-transformer.d.ts +13 -0
- package/dist/connectors/marvin-transformer.js +131 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @chronicle.app/marvin
|
|
2
|
+
|
|
3
|
+
Chronicle plugin for reading sessions and annotations from Marvin, the e-reader app.
|
|
4
|
+
It reads the CSV files Marvin exports. Pass the file with `--input` and pick the
|
|
5
|
+
record type with `--type`:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
chronicle extract marvin --type reading-sessions --input sessions.csv
|
|
9
|
+
chronicle extract marvin --type annotations --input annotations.csv
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Each type reads its own export file. The reading-sessions export has one row per
|
|
13
|
+
part of a session (`Session ID`, `Book Title`, `Book Author`, `Date Created`,
|
|
14
|
+
`Part Time` in seconds). Rows are grouped by session: part times are summed and the
|
|
15
|
+
latest `Date Created` ends the session. Each session becomes a `ReadAction` whose
|
|
16
|
+
`startTime` is the end time minus the total part time, with the `Book` as its
|
|
17
|
+
`object`. Sessions come out oldest first. The limit also caps the CSV rows read
|
|
18
|
+
before grouping, so pass `--limit 0` to read a whole export.
|
|
19
|
+
|
|
20
|
+
The annotations export has one row per highlight or note (`ID`, `Title`, `Author`,
|
|
21
|
+
`Date` or `Created`, `HighlightText`, `EntryText`). A highlight becomes a
|
|
22
|
+
`QuoteAction` whose `result` is a `Quotation` that is part of the `Book`. A note
|
|
23
|
+
becomes an `AnnotateAction` whose `result` is a `Comment` about the highlight, or
|
|
24
|
+
about the book when the row has no highlight. Rows with neither are skipped.
|
|
25
|
+
|
|
26
|
+
Books have no IDs in the export, so a `Book` is keyed by its title and its author's
|
|
27
|
+
name. Marvin escapes commas in author names as `\,`; they are unescaped. The export
|
|
28
|
+
does not identify the reader, so the agent is the `marvin` self person, marked as
|
|
29
|
+
you with `sameAs: ['@me']`.
|
|
30
|
+
|
|
31
|
+
Tests use small synthetic CSV files with made-up books and quotes, and never read
|
|
32
|
+
the host's data or accounts.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { MarvinExtractor } from './MarvinExtractor.js';
|
|
3
|
+
import MarvinTransformer from './marvin-transformer.js';
|
|
4
|
+
export declare class MarvinAnnotationsExtractor extends MarvinExtractor {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static defaultTransformer: typeof MarvinTransformer;
|
|
8
|
+
extract(): AsyncGenerator<Record>;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=MarvinAnnotationsExtractor.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MarvinExtractor } from './MarvinExtractor.js';
|
|
2
|
+
import MarvinTransformer from './marvin-transformer.js';
|
|
3
|
+
export class MarvinAnnotationsExtractor extends MarvinExtractor {
|
|
4
|
+
static description = 'Highlights and annotations';
|
|
5
|
+
static recordTypes = ['annotations'];
|
|
6
|
+
static defaultTransformer = MarvinTransformer;
|
|
7
|
+
async *extract() {
|
|
8
|
+
// Read all CSV rows and yield as annotation records
|
|
9
|
+
let count = 0;
|
|
10
|
+
for await (const record of super.extract()) {
|
|
11
|
+
const annotation = record.data;
|
|
12
|
+
// Filter out entries without meaningful content
|
|
13
|
+
if (!annotation.EntryText && !annotation.HighlightText) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
// Apply limit if specified
|
|
17
|
+
if (this.shouldStopExtracting(count)) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
yield this.createRecord(annotation);
|
|
21
|
+
count++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CsvExtractor } from '@chronicle.app/etl';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for Marvin extractors providing shared functionality
|
|
4
|
+
*/
|
|
5
|
+
export declare abstract class MarvinExtractor extends CsvExtractor {
|
|
6
|
+
static source: string;
|
|
7
|
+
static delivery: "export";
|
|
8
|
+
static strategy: string;
|
|
9
|
+
constructor(config: any);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=MarvinExtractor.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CsvExtractor, configForIo } from '@chronicle.app/etl';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for Marvin extractors providing shared functionality
|
|
4
|
+
*/
|
|
5
|
+
export class MarvinExtractor extends CsvExtractor {
|
|
6
|
+
static source = 'marvin';
|
|
7
|
+
static delivery = 'export';
|
|
8
|
+
static strategy = 'csv';
|
|
9
|
+
constructor(config) {
|
|
10
|
+
// Transform input flag to IoSchema format
|
|
11
|
+
const ioConfig = configForIo(config);
|
|
12
|
+
super({
|
|
13
|
+
...config,
|
|
14
|
+
...ioConfig,
|
|
15
|
+
columns: true, // Ensure we use first line as headers
|
|
16
|
+
trimColumns: true, // Trim whitespace from column names
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { MarvinExtractor } from './MarvinExtractor.js';
|
|
3
|
+
import MarvinTransformer from './marvin-transformer.js';
|
|
4
|
+
export declare class MarvinSessionsExtractor extends MarvinExtractor {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static default: boolean;
|
|
8
|
+
static defaultTransformer: typeof MarvinTransformer;
|
|
9
|
+
extract(): AsyncGenerator<Record>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=MarvinSessionsExtractor.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { MarvinExtractor } from './MarvinExtractor.js';
|
|
2
|
+
import MarvinTransformer from './marvin-transformer.js';
|
|
3
|
+
export class MarvinSessionsExtractor extends MarvinExtractor {
|
|
4
|
+
static description = 'Reading sessions';
|
|
5
|
+
static recordTypes = ['reading-sessions'];
|
|
6
|
+
static default = true;
|
|
7
|
+
static defaultTransformer = MarvinTransformer;
|
|
8
|
+
async *extract() {
|
|
9
|
+
// Read all CSV rows first
|
|
10
|
+
const rows = [];
|
|
11
|
+
for await (const record of super.extract()) {
|
|
12
|
+
rows.push(record.data); // CSV data is in the data field
|
|
13
|
+
}
|
|
14
|
+
// Group rows by Session ID
|
|
15
|
+
const sessions = rows.reduce((acc, row) => {
|
|
16
|
+
const sessionId = row['Session ID'];
|
|
17
|
+
if (!acc[sessionId]) {
|
|
18
|
+
acc[sessionId] = [];
|
|
19
|
+
}
|
|
20
|
+
acc[sessionId].push(row);
|
|
21
|
+
return acc;
|
|
22
|
+
}, {});
|
|
23
|
+
// Aggregate session data
|
|
24
|
+
const reads = Object.values(sessions).map((session) => {
|
|
25
|
+
const aggregated = session.reduce((memo, values) => Object.entries(values).reduce((memo, [key, newValue]) => {
|
|
26
|
+
if (key === 'Part Time') {
|
|
27
|
+
// Sum all part times for the session
|
|
28
|
+
memo[key] = (Number.parseFloat(memo[key] || '0') + Number.parseFloat(newValue)).toString();
|
|
29
|
+
}
|
|
30
|
+
else if (key === 'Date Created') {
|
|
31
|
+
// Use the latest date as session end time
|
|
32
|
+
memo[key] = memo[key]
|
|
33
|
+
? new Date(Math.max(new Date(memo[key]).getTime(), new Date(newValue).getTime())).toISOString()
|
|
34
|
+
: newValue;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// For other fields, use latest value (last-wins strategy)
|
|
38
|
+
memo[key] = newValue;
|
|
39
|
+
}
|
|
40
|
+
return memo;
|
|
41
|
+
}, memo), {});
|
|
42
|
+
return aggregated;
|
|
43
|
+
});
|
|
44
|
+
// Filter out sessions without required fields and sort by Date Created
|
|
45
|
+
let finalReads = reads
|
|
46
|
+
.filter(read => read['Date Created'] && read['Session ID'])
|
|
47
|
+
.sort((a, b) => new Date(a['Date Created']).getTime() - new Date(b['Date Created']).getTime());
|
|
48
|
+
// Apply limit to final processed sessions (not raw CSV rows)
|
|
49
|
+
if (this.config.limit) {
|
|
50
|
+
finalReads = finalReads.slice(-this.config.limit);
|
|
51
|
+
}
|
|
52
|
+
// Yield each reading session as a record
|
|
53
|
+
for (const read of finalReads) {
|
|
54
|
+
yield this.createRecord(read);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class MarvinTransformer extends ChronicleTransformer {
|
|
4
|
+
static source: string;
|
|
5
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
6
|
+
private buildReadAction;
|
|
7
|
+
private buildBook;
|
|
8
|
+
private buildAuthor;
|
|
9
|
+
private buildAgent;
|
|
10
|
+
private buildAnnotationActions;
|
|
11
|
+
private buildAnnotationBook;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=marvin-transformer.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
|
|
2
|
+
export default class MarvinTransformer extends ChronicleTransformer {
|
|
3
|
+
static source = 'marvin';
|
|
4
|
+
async transform(record) {
|
|
5
|
+
const actions = [];
|
|
6
|
+
if (record.extraction.recordType === 'reading-sessions') {
|
|
7
|
+
actions.push(...this.buildReadAction(record));
|
|
8
|
+
}
|
|
9
|
+
else if (record.extraction.recordType === 'annotations') {
|
|
10
|
+
actions.push(...this.buildAnnotationActions(record));
|
|
11
|
+
}
|
|
12
|
+
return actions;
|
|
13
|
+
}
|
|
14
|
+
buildReadAction(record) {
|
|
15
|
+
const { data } = record;
|
|
16
|
+
// add "Part Time" (seconds) to "Date Created" to get "endTime"
|
|
17
|
+
const startTime = new Date(new Date(data['Date Created']).getTime() - Number.parseFloat(data['Part Time']) * 1000);
|
|
18
|
+
const obj = {
|
|
19
|
+
'@type': 'ReadAction',
|
|
20
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
21
|
+
startTime,
|
|
22
|
+
endTime: new Date(data['Date Created']),
|
|
23
|
+
source: 'marvin',
|
|
24
|
+
sourceId: data['Session ID'],
|
|
25
|
+
agent: this.buildAgent(),
|
|
26
|
+
object: this.buildBook(record),
|
|
27
|
+
};
|
|
28
|
+
return [obj];
|
|
29
|
+
}
|
|
30
|
+
buildBook(record) {
|
|
31
|
+
const { data } = record;
|
|
32
|
+
return {
|
|
33
|
+
'@type': 'Book',
|
|
34
|
+
'@key': ['@type', 'source', 'name', 'author.name'],
|
|
35
|
+
name: data['Book Title'],
|
|
36
|
+
source: 'marvin',
|
|
37
|
+
sourceId: data['Book Title'], // Use title as sourceId since no unique book ID
|
|
38
|
+
author: [this.buildAuthor(record)],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
buildAuthor(record) {
|
|
42
|
+
const { data } = record;
|
|
43
|
+
// Properly unescape the author name (Marvin exports with comma escaping)
|
|
44
|
+
const authorName = data['Book Author'].replaceAll('\\,', ',');
|
|
45
|
+
return {
|
|
46
|
+
'@type': 'Person',
|
|
47
|
+
'@key': ['@type', 'source', 'name'],
|
|
48
|
+
name: authorName,
|
|
49
|
+
source: 'marvin',
|
|
50
|
+
sourceId: authorName, // Use cleaned name as sourceId
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
buildAgent() {
|
|
54
|
+
// The Marvin export carries no owner identity at all, so the self is the
|
|
55
|
+
// per-source singleton — merged into the real person through "@me",
|
|
56
|
+
// never a fabricated name.
|
|
57
|
+
return selfAgent({ source: 'marvin' });
|
|
58
|
+
}
|
|
59
|
+
buildAnnotationActions(record) {
|
|
60
|
+
const { data } = record;
|
|
61
|
+
const actions = [];
|
|
62
|
+
// Parse the date
|
|
63
|
+
const timestamp = new Date(data.Date || data.Created);
|
|
64
|
+
// Unescape author name
|
|
65
|
+
const authorName = data.Author ? data.Author.replaceAll('\\,', ',') : 'Unknown Author';
|
|
66
|
+
// Create the book that contains the content
|
|
67
|
+
const sourceBook = this.buildAnnotationBook(data, authorName);
|
|
68
|
+
// Create quotation first if it exists (so comment can reference it)
|
|
69
|
+
let quotation;
|
|
70
|
+
if (data.HighlightText) {
|
|
71
|
+
quotation = {
|
|
72
|
+
'@type': 'Quotation',
|
|
73
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
74
|
+
body: data.HighlightText,
|
|
75
|
+
source: 'marvin',
|
|
76
|
+
sourceId: data.ID,
|
|
77
|
+
isPartOf: [sourceBook],
|
|
78
|
+
};
|
|
79
|
+
const quoteAction = {
|
|
80
|
+
'@type': 'QuoteAction',
|
|
81
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
82
|
+
timestamp,
|
|
83
|
+
source: 'marvin',
|
|
84
|
+
sourceId: data.ID,
|
|
85
|
+
agent: this.buildAgent(),
|
|
86
|
+
result: quotation,
|
|
87
|
+
};
|
|
88
|
+
actions.push(quoteAction);
|
|
89
|
+
}
|
|
90
|
+
// Handle annotation (EntryText)
|
|
91
|
+
if (data.EntryText) {
|
|
92
|
+
const comment = {
|
|
93
|
+
'@type': 'Comment',
|
|
94
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
95
|
+
description: data.EntryText,
|
|
96
|
+
source: 'marvin',
|
|
97
|
+
sourceId: data.ID,
|
|
98
|
+
about: quotation ? [quotation] : [sourceBook], // Comment about quote if it exists, otherwise about book
|
|
99
|
+
};
|
|
100
|
+
const annotateAction = {
|
|
101
|
+
'@type': 'AnnotateAction',
|
|
102
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
103
|
+
timestamp,
|
|
104
|
+
source: 'marvin',
|
|
105
|
+
sourceId: data.ID,
|
|
106
|
+
agent: this.buildAgent(),
|
|
107
|
+
result: comment,
|
|
108
|
+
};
|
|
109
|
+
actions.push(annotateAction);
|
|
110
|
+
}
|
|
111
|
+
return actions;
|
|
112
|
+
}
|
|
113
|
+
buildAnnotationBook(data, authorName) {
|
|
114
|
+
return {
|
|
115
|
+
'@type': 'Book',
|
|
116
|
+
'@key': ['@type', 'source', 'name', 'author.name'],
|
|
117
|
+
name: data.Title,
|
|
118
|
+
source: 'marvin',
|
|
119
|
+
sourceId: data.Title,
|
|
120
|
+
author: [
|
|
121
|
+
{
|
|
122
|
+
'@type': 'Person',
|
|
123
|
+
'@key': ['@type', 'source', 'name'],
|
|
124
|
+
name: authorName,
|
|
125
|
+
source: 'marvin',
|
|
126
|
+
sourceId: authorName,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Export extractors for plugin scanning. MarvinExtractor is the abstract CSV
|
|
2
|
+
// base the two below share — not a strategy, so it stays unexported.
|
|
3
|
+
export { MarvinAnnotationsExtractor } from './connectors/MarvinAnnotationsExtractor.js';
|
|
4
|
+
export { MarvinSessionsExtractor } from './connectors/MarvinSessionsExtractor.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/marvin",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"chronicle"
|
|
18
|
+
],
|
|
19
|
+
"author": "Andrew Louis",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"description": "Chronicle plugin for reading sessions and annotations from Marvin",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@chronicle.app/etl": "0.3.0",
|
|
25
|
+
"@chronicle.app/schema": "0.3.0",
|
|
26
|
+
"@chronicle.app/tsconfig": "0.3.0",
|
|
27
|
+
"@types/node": "^22.10.1",
|
|
28
|
+
"typescript": "^5.7.2"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://chronicle.app",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
34
|
+
"directory": "plugins/marvin"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"csv-parse": "^5.6.0",
|
|
42
|
+
"zod": "^3.24.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@chronicle.app/etl": ">=0.1.0 <1.0.0",
|
|
46
|
+
"@chronicle.app/schema": ">=0.1.0 <1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.13.0"
|
|
50
|
+
},
|
|
51
|
+
"exports": {
|
|
52
|
+
".": {
|
|
53
|
+
"types": "./dist/index.d.ts",
|
|
54
|
+
"import": "./dist/index.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"chronicle": {
|
|
58
|
+
"plugin": true,
|
|
59
|
+
"sources": {
|
|
60
|
+
"marvin": {
|
|
61
|
+
"strategies": {
|
|
62
|
+
"csv": {
|
|
63
|
+
"delivery": "export",
|
|
64
|
+
"recordTypes": [
|
|
65
|
+
"annotations",
|
|
66
|
+
"reading-sessions"
|
|
67
|
+
],
|
|
68
|
+
"default": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|