-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
executable file
·244 lines (234 loc) · 8.18 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#!/usr/bin/env node
const assert = require('assert')
const fs = require('fs')
const kindleClippingsParser = require('kindle-clippings')
const { Duplex, PassThrough, Transform } = require('stream')
const uuid = require('uuid').v4
const parser = require('./parser')
// write in chunks of your "My Clippings File"
const ClippingTextToWebAnnotation = exports.ClippingTextToWebAnnotation = class ClippingTextToWebAnnotation extends Duplex {
constructor () {
// @TODO - I think this might not work so well with only one clipping
const clippingObjects = kindleClippingsParser()
const clippingObjectsToOA = new ClippingObjectToWebAnnotation()
clippingObjects
.pipe(clippingObjectsToOA)
super({
objectMode: true,
write (chunk, encoding, callback) {
clippingObjects.write(chunk, callback)
},
read () {
clippingObjectsToOA.once('readable', () => {
const clipping = clippingObjectsToOA.read()
this.push(clipping)
})
}
})
this.on('finish', () => clippingObjects.end())
}
}
const clippingsDoCorrelate = exports.clippingsDoCorrelate = (...clippings) => {
if (clippings.length === 1) return true
// if all the titles + times are the same?
const {title, details: { time }} = clippings[0]
const ret = clippings.slice(1).every(c => c.title === title && c.details.time.getTime() === time.getTime())
return ret
}
// custom types other than those strings created by kindle-clippings
const clippingTypes = {
highlightWithNote: Symbol('clippingTypes.highlightWithNote')
}
const mergeClippings = exports.mergeClippings = (...clippings) => {
if (clippings.length === 1) return clippings[0]
assert.equal(clippings.length, 2) // dont know how to merge 3
const note = clippings.find((c) => c.details.type === 'note')
const highlight = clippings.find((c) => c.details.type === 'highlight')
assert(note)
assert(highlight)
const merged = Object.assign({}, highlight, {
note: note.snippet,
details: Object.assign({}, highlight.details, {
type: clippingTypes.highlightWithNote
})
})
return merged
}
/**
* When you highlight some text and that add a note,
* The Kindle Clippings file records this as two separate entries, and kindle-clippings parser emits two separate objects one-after-another.
* This stream takes in a stream of kindle-clippings objects, merges any that correlate into a clipping of type `clippingTypes.highlightWithNote`,
* and pushes along the rest untouched.
*/
const ClippingObjectCorrelatingStream = class extends Duplex {
constructor () {
// we'll process clippings one at a time, and if the new one correlates with all of these,
// we'll push the new one here.
// once a new one doesn't correlate, we'll merge all those correlates and reset this `correlatesSoFar`
let correlatesSoFar = []
const mergeAndPush = (...clippings) => {
this.push(mergeClippings(...clippings))
}
super({
objectMode: true,
write (clipping, encoding, callback) {
const potentialCorrelates = correlatesSoFar.concat([clipping])
if (clippingsDoCorrelate(...potentialCorrelates)) {
correlatesSoFar = potentialCorrelates
} else {
// ok we've gotten to a wholly new thing. Push the old correlatesSoFar along
const fullSet = correlatesSoFar
correlatesSoFar = [clipping]
mergeAndPush(...fullSet)
}
callback()
},
read () {
// .push happens up in write()
},
final () {
mergeAndPush(...correlatesSoFar)
}
})
}
}
/**
* Convert a kindle-clippings object to a Web Annotation
*/
exports.clippingObjectToWebAnnotation = function (clipping) {
/* clipping like
{ title: 'Relativity (Albert Einstein)',
details:
{ type: 'highlight',
page: { from: 77, to: 77 },
location: { from: 1173, to: 1174 },
time: 2017-09-17T18:28:04.000Z },
snippet: '21-In What Respects are the Foundations of Classical Mechanics and of the Special Theory of Relativity Unsatisfactory?' }
*/
// const locationSelector; //@TODO hmm wtf is a location?
// const pageSelector; // @TODO - How to represent this? Range Selector?
const { title, author } = parseClippingTitle(clipping.title)
const clippingTypeToMotivation = new Map([].concat(
Object.entries({
highlight: 'highlighting',
bookmark: 'bookmarking',
note: 'commenting'
}),
[[clippingTypes.highlightWithNote, 'commenting']]
))
const oa = {
'@context': [
'http://www.w3.org/ns/anno.jsonld',
{
title: 'as:title',
author: 'as:author'
}
],
id: `urn:uuid:${uuid()}`,
type: 'Annotation',
motivation: clippingTypeToMotivation.get(clipping.details.type),
created: clipping.details.time,
body: (clipping.details.type === clippingTypes.highlightWithNote) && {
type: 'TextualBody',
value: clipping.note
},
target: {
// source is the book
source: {
type: 'http://schema.org/CreativeWork',
title: title,
author: author
// @TODO - Use title and rest of kindle filesystem to look up actual document and try to get more info, e.g. ISBN
},
selector: {
// https://www.w3.org/TR/annotation-model/#text-quote-selector
type: 'TextQuoteSelector',
exact: clipping.snippet
// @TODO - If possible, correlate kindle 'locations' with actual document texts to add { prefix, suffix }
// Would likely require involved code specific to each document format (e.g. mobi, epub, etc)
}
}
}
return oa
}
// write in objects like those that come from 'kindle-clippings' parser
const ClippingObjectToWebAnnotation = class ClippingObjectToWebAnnotation extends Duplex {
constructor () {
// @TODO - I think this might not work so well with only one clipping
const clipsIn = new PassThrough({ objectMode: true })
const webAnnotations = clipsIn
.pipe(new ClippingObjectCorrelatingStream())
.pipe(new Transform({
objectMode: true,
transform (clipping, encoding, callback) {
this.push(exports.clippingObjectToWebAnnotation(clipping))
callback()
}
}))
super({
objectMode: true,
write (chunk, encoding, callback) {
clipsIn.write(chunk, callback)
},
read () {
webAnnotations.once('readable', () => {
const annotation = webAnnotations.read()
this.push(annotation)
})
}
})
this.on('finish', () => clipsIn.end())
}
}
// e.g. "Zero to One: Notes on Startups, or How to Build the Future (5th edition) (Peter Thiel (srs dude);Blake Masters)"
function parseClippingTitle (titlePlusAuthors) {
// the authors are in the last set of parentheses, ';'-delimited
// use a parser to handle nested parentheses. regex cant very easily
const { parse, serialize } = parser('(', ')')
const parsed = parse(titlePlusAuthors)
const [titleTree, authorTree] = [parsed.slice(0, -1), parsed.slice(-1)[0]]
const title = serialize(titleTree).trim()
const authors = serialize(authorTree).split(';')
const author = authors && (authors.length === 1 ? authors[0] : authors)
return {
title,
author
}
}
if (require.main === module) {
main(...process.argv.slice(2))
.catch(error => {
console.error('main() error. exiting')
console.error(error)
process.exit(1)
})
.then(() => {
process.exit()
})
}
async function main (clippingsFile) {
const clippings = kindleClippingsParser()
const clippingsFileStream = clippingsFile
? fs.createReadStream(clippingsFile)
: process.stdin.isTTY
? false
: process.stdin
if (!clippingsFileStream) { throw new Error(`Provide a kindle 'My Clippings' file as first argument or pipe to stdin`) }
clippingsFileStream
.pipe(new ClippingTextToWebAnnotation())
.pipe(new Transform({
writableObjectMode: true,
transform (chunk, encoding, callback) {
try {
this.push(JSON.stringify(chunk))
} catch (error) { return callback(error) }
callback()
}
}))
.pipe(process.stdout)
return new Promise((resolve, reject) => {
clippings
.on('error', reject)
.on('end', resolve)
})
}