@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 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
+ }
@@ -0,0 +1,3 @@
1
+ export { MarvinAnnotationsExtractor } from './connectors/MarvinAnnotationsExtractor.js';
2
+ export { MarvinSessionsExtractor } from './connectors/MarvinSessionsExtractor.js';
3
+ //# sourceMappingURL=index.d.ts.map
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
+ }