nbk2pdf 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/COPYING +674 -0
- data/README.md +490 -0
- data/exe/nbk2pdf +8 -0
- data/lib/kindle_notebook/cli.rb +274 -0
- data/lib/kindle_notebook/container.rb +82 -0
- data/lib/kindle_notebook/converter.rb +112 -0
- data/lib/kindle_notebook/device.rb +145 -0
- data/lib/kindle_notebook/document.rb +133 -0
- data/lib/kindle_notebook/document_renderer.rb +247 -0
- data/lib/kindle_notebook/ion.rb +243 -0
- data/lib/kindle_notebook/krds.rb +109 -0
- data/lib/kindle_notebook/notebook.rb +442 -0
- data/lib/kindle_notebook/pdf_renderer.rb +232 -0
- data/lib/kindle_notebook/stroke_decoder.rb +90 -0
- data/lib/kindle_notebook/titles.rb +90 -0
- data/lib/kindle_notebook/version.rb +10 -0
- data/lib/nbk2pdf.rb +22 -0
- metadata +109 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Sergey Avseyev
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "optparse"
|
|
7
|
+
require "pathname"
|
|
8
|
+
|
|
9
|
+
module KindleNotebook
|
|
10
|
+
# The `nbk2pdf` command line.
|
|
11
|
+
class CLI
|
|
12
|
+
SUCCESS = 0
|
|
13
|
+
FAILURE = 1
|
|
14
|
+
|
|
15
|
+
VERSION_NOTICE = <<~NOTICE.freeze
|
|
16
|
+
#{PROGRAM} #{VERSION}
|
|
17
|
+
Copyright (C) 2026 Sergey Avseyev
|
|
18
|
+
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>
|
|
19
|
+
This is free software: you are free to change and redistribute it.
|
|
20
|
+
There is NO WARRANTY, to the extent permitted by law.
|
|
21
|
+
NOTICE
|
|
22
|
+
|
|
23
|
+
SYNC = "sync"
|
|
24
|
+
|
|
25
|
+
# Where sync puts things inside its destination.
|
|
26
|
+
NOTEBOOKS_DIR = "notebooks"
|
|
27
|
+
DOCUMENTS_DIR = "documents"
|
|
28
|
+
|
|
29
|
+
BANNER = <<~BANNER.freeze
|
|
30
|
+
Convert Kindle Scribe notebooks to PDF.
|
|
31
|
+
|
|
32
|
+
Usage: #{PROGRAM} [options] PATH...
|
|
33
|
+
#{PROGRAM} #{SYNC} [options] [DIR]
|
|
34
|
+
|
|
35
|
+
PATH may be an `nbk` file, a notebook directory, or any directory
|
|
36
|
+
containing notebooks (they are found recursively). It may also be a
|
|
37
|
+
PDF on the Kindle, to get a copy with what was written on it.
|
|
38
|
+
|
|
39
|
+
#{SYNC} converts every notebook on a Kindle attached over USB into
|
|
40
|
+
DIR/#{NOTEBOOKS_DIR}, and every PDF on it that has been written on into
|
|
41
|
+
DIR/#{DOCUMENTS_DIR}, in the folders it has under documents/ on the
|
|
42
|
+
Kindle. DIR is the current directory when left out. The device is
|
|
43
|
+
mounted over MTP first if it is not mounted already, and is left
|
|
44
|
+
mounted afterwards.
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
BANNER
|
|
48
|
+
|
|
49
|
+
def self.start(argv, out: $stdout, err: $stderr) = new(out: out, err: err).run(argv)
|
|
50
|
+
|
|
51
|
+
def initialize(out: $stdout, err: $stderr, device: nil)
|
|
52
|
+
@out = out
|
|
53
|
+
@err = err
|
|
54
|
+
@device = device
|
|
55
|
+
@options = { template: true, quiet: false, annotations: false }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runs the command and returns the process exit status.
|
|
59
|
+
def run(argv)
|
|
60
|
+
catch(:halt) do
|
|
61
|
+
argv = argv.dup
|
|
62
|
+
sync = argv.first == SYNC && argv.shift
|
|
63
|
+
arguments = parser.parse(argv)
|
|
64
|
+
next sync_device(arguments) if sync
|
|
65
|
+
|
|
66
|
+
arguments.empty? ? fail_with(parser.help) : convert(arguments)
|
|
67
|
+
end
|
|
68
|
+
rescue OptionParser::ParseError, Error => e
|
|
69
|
+
@err.puts(e.message)
|
|
70
|
+
FAILURE
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
attr_reader :options
|
|
76
|
+
|
|
77
|
+
def device = @device ||= Device.new
|
|
78
|
+
|
|
79
|
+
def parser
|
|
80
|
+
@parser ||= OptionParser.new do |opts|
|
|
81
|
+
opts.banner = BANNER
|
|
82
|
+
define_output_options(opts)
|
|
83
|
+
define_rendering_options(opts)
|
|
84
|
+
opts.on("-h", "--help", "Show this message") { halt(opts.help) }
|
|
85
|
+
opts.on("-v", "--version", "Show the version") { halt(VERSION_NOTICE.chomp) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def define_output_options(opts)
|
|
90
|
+
opts.on("-o", "--output PATH", "Output file, or directory for several notebooks") do |path|
|
|
91
|
+
options[:output] = path
|
|
92
|
+
end
|
|
93
|
+
opts.on("-n", "--name NAME", "Override the output file name (single notebook only)") do |name|
|
|
94
|
+
options[:name] = name
|
|
95
|
+
end
|
|
96
|
+
opts.on("-t", "--titles FILE", "JSON/YAML map of notebook uuid to the name you gave it") do |file|
|
|
97
|
+
options[:titles] = file
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def define_rendering_options(opts)
|
|
102
|
+
opts.on("--[no-]template", "Draw the notebook's ruled background (default: yes)") do |draw|
|
|
103
|
+
options[:template] = draw
|
|
104
|
+
end
|
|
105
|
+
opts.on("-p", "--paper SIZE", "Page size: letter (default), a4, native, ...") do |size|
|
|
106
|
+
options[:paper] = size
|
|
107
|
+
end
|
|
108
|
+
opts.on("-a", "--annotations", "Add ink to a PDF as annotations, not drawn into the page") do
|
|
109
|
+
options[:annotations] = true
|
|
110
|
+
end
|
|
111
|
+
opts.on("-q", "--quiet", "Only report errors") { options[:quiet] = true }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def convert(paths)
|
|
115
|
+
sources = paths.flat_map { |path| Converter.document?(path) ? [File.expand_path(path)] : Notebook.find(path) }
|
|
116
|
+
sources = with_documents(sources)
|
|
117
|
+
return fail_with("no notebooks found in #{paths.join(", ")}") if sources.empty?
|
|
118
|
+
|
|
119
|
+
if options[:name] && sources.size > 1
|
|
120
|
+
return fail_with("--name needs a single notebook, but #{sources.size} were found")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
convert_each(sources, options[:output])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `sync` takes its destination as a positional argument, since the notebooks
|
|
127
|
+
# come from the device rather than from a path.
|
|
128
|
+
def sync_device(arguments)
|
|
129
|
+
return fail_with("#{SYNC} takes one directory at most") if arguments.size > 1
|
|
130
|
+
return fail_with("--name cannot be used with #{SYNC}") if options[:name]
|
|
131
|
+
|
|
132
|
+
destination = arguments.first || options[:output] || "."
|
|
133
|
+
targets = sync_targets(device_notebooks, destination)
|
|
134
|
+
return fail_with("the Kindle holds no notebooks") if targets.empty?
|
|
135
|
+
|
|
136
|
+
convert_targets(targets) { device_vanished? }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# [source, output directory] for everything on the device: notebooks side
|
|
140
|
+
# by side, and each PDF under the same folders it sits in on the Kindle.
|
|
141
|
+
# The directories are created here, so that each is written into rather
|
|
142
|
+
# than taken for the name of an output file.
|
|
143
|
+
def sync_targets(notebooks, destination)
|
|
144
|
+
targets = notebooks.map do |notebook|
|
|
145
|
+
pdf = Document.pdf_for(notebook)
|
|
146
|
+
next [notebook, File.join(destination, NOTEBOOKS_DIR)] unless pdf
|
|
147
|
+
|
|
148
|
+
library = Pathname(File.join(File.dirname(notebook, 3), DOCUMENTS_DIR))
|
|
149
|
+
[pdf, File.join(destination, DOCUMENTS_DIR, Pathname(File.dirname(pdf)).relative_path_from(library).to_s)]
|
|
150
|
+
end.uniq
|
|
151
|
+
targets.map(&:last).uniq.each { |directory| FileUtils.mkdir_p(directory) }
|
|
152
|
+
targets
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A Kindle can drop off the bus part way through, which otherwise shows up
|
|
156
|
+
# as every remaining notebook having gone missing.
|
|
157
|
+
def device_vanished?
|
|
158
|
+
return false if device.mounted?
|
|
159
|
+
|
|
160
|
+
@err.puts("the Kindle disconnected part way through; #{Device::RECONNECT}")
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The ink written on a PDF is kept in a notebook of its own, which is swapped
|
|
165
|
+
# for the PDF it belongs to so that the ink ends up on a copy of it.
|
|
166
|
+
def with_documents(sources) = sources.map { |source| Document.pdf_for(source) || source }.uniq
|
|
167
|
+
|
|
168
|
+
def device_notebooks
|
|
169
|
+
@out.puts("mounting the Kindle over MTP...") unless options[:quiet] || device.mounted?
|
|
170
|
+
device.notebooks
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def convert_each(sources, output)
|
|
174
|
+
output = destination_for(sources, output)
|
|
175
|
+
convert_targets(sources.map { |source| [source, output] })
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def convert_targets(targets, &)
|
|
179
|
+
failures = count_failures(targets, &)
|
|
180
|
+
report_missing_names
|
|
181
|
+
failures.zero? ? SUCCESS : FAILURE
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Notebooks the device could not name are offered for filling in, since the
|
|
185
|
+
# Kindle keeps its names where MTP cannot reach them.
|
|
186
|
+
def report_missing_names
|
|
187
|
+
return if options[:quiet] || titles.missing.zero?
|
|
188
|
+
|
|
189
|
+
pending = titles.missing
|
|
190
|
+
added = titles.record_missing
|
|
191
|
+
@out.puts(
|
|
192
|
+
if added.positive?
|
|
193
|
+
"#{pluralize(added, "notebook")} had no name; added to #{titles.path} to fill in"
|
|
194
|
+
else
|
|
195
|
+
"#{pluralize(pending, "notebook")} had no name; pass --titles FILE to record them"
|
|
196
|
+
end
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Several notebooks cannot share one file name, so a destination that does
|
|
201
|
+
# not exist yet is created as a directory rather than written to as a file.
|
|
202
|
+
def destination_for(sources, output)
|
|
203
|
+
return output unless output && sources.size > 1
|
|
204
|
+
|
|
205
|
+
FileUtils.mkdir_p(output)
|
|
206
|
+
output
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def count_failures(targets, &stop)
|
|
210
|
+
failures = 0
|
|
211
|
+
targets.each do |source, output|
|
|
212
|
+
next if convert_one(source, output)
|
|
213
|
+
|
|
214
|
+
failures += 1
|
|
215
|
+
break if stop&.call
|
|
216
|
+
end
|
|
217
|
+
failures
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def convert_one(source, output)
|
|
221
|
+
result = converter.convert(source, output: output, name: options[:name])
|
|
222
|
+
result.warnings.each { |warning| @err.puts("#{source}: #{warning}") }
|
|
223
|
+
note_missing_name(result)
|
|
224
|
+
report(result) unless options[:quiet]
|
|
225
|
+
true
|
|
226
|
+
rescue Error => e
|
|
227
|
+
@err.puts("#{source}: #{e.message}")
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def converter
|
|
232
|
+
@converter ||= Converter.new(titles: titles, render_template: options[:template],
|
|
233
|
+
paper: PdfRenderer.paper(options[:paper]),
|
|
234
|
+
annotations: options[:annotations])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def titles = @titles ||= Titles.load(options[:titles])
|
|
238
|
+
|
|
239
|
+
def report(result)
|
|
240
|
+
pages = pluralize(result.page_count, "page")
|
|
241
|
+
summary = [result.document ? "ink on #{pages}" : pages, pluralize(result.stroke_count, "stroke")]
|
|
242
|
+
summary << problems(result) if result.warnings.any?
|
|
243
|
+
@out.puts("#{result.source} -> #{result.output} (#{summary.join(", ")})")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A document's warnings are about placing notes as well as decoding strokes.
|
|
247
|
+
def problems(result)
|
|
248
|
+
return "#{pluralize(result.warnings.size, "warning")}, see above" if result.document
|
|
249
|
+
|
|
250
|
+
"#{pluralize(result.warnings.size, "stroke")} could not be decoded"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def note_missing_name(result)
|
|
254
|
+
return if result.name
|
|
255
|
+
|
|
256
|
+
titles.note_missing(result.uuid,
|
|
257
|
+
"#{pluralize(result.page_count, "page")}, " \
|
|
258
|
+
"last written #{result.written_at.strftime("%Y-%m-%d")}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def pluralize(count, noun) = "#{count} #{noun}#{"s" unless count == 1}"
|
|
262
|
+
|
|
263
|
+
def fail_with(message)
|
|
264
|
+
@err.puts(message)
|
|
265
|
+
FAILURE
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# OptionParser has no way to stop parsing, so --help and --version unwind.
|
|
269
|
+
def halt(message)
|
|
270
|
+
@out.puts(message)
|
|
271
|
+
throw(:halt, SUCCESS)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Sergey Avseyev
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module KindleNotebook
|
|
6
|
+
# A Kindle Scribe `nbk` file is an ordinary SQLite database that has been
|
|
7
|
+
# sliced up and interleaved with 1024-byte signature blocks: one block sits
|
|
8
|
+
# after the first kibibyte of database bytes, and another after every
|
|
9
|
+
# mebibyte that follows.
|
|
10
|
+
#
|
|
11
|
+
# [ 1 KiB db ][ 1 KiB sig ][ 1 MiB db ][ 1 KiB sig ][ 1 MiB db ] ...
|
|
12
|
+
#
|
|
13
|
+
# Stripping those blocks yields a database that SQLite opens cleanly.
|
|
14
|
+
module Container
|
|
15
|
+
BLOCK_SIZE = 1024
|
|
16
|
+
CHUNK_SIZE = 1024 * 1024
|
|
17
|
+
STRIDE = BLOCK_SIZE + CHUNK_SIZE
|
|
18
|
+
FIRST_BLOCK_OFFSET = BLOCK_SIZE
|
|
19
|
+
|
|
20
|
+
MAGIC = "SQLite format 3\0"
|
|
21
|
+
HEADER_SIZE = 100
|
|
22
|
+
|
|
23
|
+
class FormatError < Error; end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Offsets of every signature block for a container of the given size.
|
|
28
|
+
def block_offsets(size)
|
|
29
|
+
offsets = []
|
|
30
|
+
offset = FIRST_BLOCK_OFFSET
|
|
31
|
+
while offset + BLOCK_SIZE <= size
|
|
32
|
+
offsets << offset
|
|
33
|
+
offset += STRIDE
|
|
34
|
+
end
|
|
35
|
+
offsets
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Database size in bytes as declared by the SQLite header.
|
|
39
|
+
def database_size(data)
|
|
40
|
+
raise FormatError, "not a SQLite database" unless data.byteslice(0, MAGIC.bytesize) == MAGIC
|
|
41
|
+
raise FormatError, "truncated SQLite header" if data.bytesize < HEADER_SIZE
|
|
42
|
+
|
|
43
|
+
page_size = data.byteslice(16, 2).unpack1("n")
|
|
44
|
+
page_size = 65_536 if page_size == 1
|
|
45
|
+
page_count = data.byteslice(28, 4).unpack1("N")
|
|
46
|
+
raise FormatError, "unusable SQLite header" if page_size.zero? || page_count.zero?
|
|
47
|
+
|
|
48
|
+
page_size * page_count
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the plain SQLite bytes carried by +data+.
|
|
52
|
+
def unwrap(data)
|
|
53
|
+
data = data.b
|
|
54
|
+
expected = database_size(data)
|
|
55
|
+
extra = data.bytesize - expected
|
|
56
|
+
return data if extra.zero?
|
|
57
|
+
|
|
58
|
+
raise FormatError, "database is shorter than its header claims" if extra.negative?
|
|
59
|
+
|
|
60
|
+
offsets = block_offsets(data.bytesize)
|
|
61
|
+
unless offsets.size * BLOCK_SIZE == extra
|
|
62
|
+
raise FormatError,
|
|
63
|
+
"unexpected container layout: #{extra} stray bytes, " \
|
|
64
|
+
"#{offsets.size} signature blocks"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
out = +""
|
|
68
|
+
cursor = 0
|
|
69
|
+
offsets.each do |offset|
|
|
70
|
+
out << data.byteslice(cursor, offset - cursor)
|
|
71
|
+
cursor = offset + BLOCK_SIZE
|
|
72
|
+
end
|
|
73
|
+
out << data.byteslice(cursor, data.bytesize - cursor)
|
|
74
|
+
out
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unwrap_file(source, destination)
|
|
78
|
+
File.binwrite(destination, unwrap(File.binread(source)))
|
|
79
|
+
destination
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Sergey Avseyev
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module KindleNotebook
|
|
9
|
+
# Turns notebooks into PDF files on disk, working out a sensible file name for
|
|
10
|
+
# each one, and adds the ink written on a PDF to a copy of it.
|
|
11
|
+
class Converter
|
|
12
|
+
# For a document, +page_count+ counts the pages that carry ink.
|
|
13
|
+
Result = Struct.new(:source, :output, :uuid, :name, :page_count, :stroke_count, :warnings,
|
|
14
|
+
:written_at, :document, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# +titles+ maps a notebook uuid (or book id) to the name you know it by on
|
|
17
|
+
# the device, since standalone notebooks do not store their own title.
|
|
18
|
+
# +annotations+ adds ink to a document as annotations rather than drawing
|
|
19
|
+
# it into the page.
|
|
20
|
+
def initialize(titles: {}, render_template: true, paper: PdfRenderer.paper, annotations: false)
|
|
21
|
+
@titles = titles
|
|
22
|
+
@renderer = PdfRenderer.new(render_template: render_template, paper: paper)
|
|
23
|
+
@documents = DocumentRenderer.new(annotations: annotations)
|
|
24
|
+
@written = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.document?(path) = File.extname(path).casecmp?(".pdf")
|
|
28
|
+
|
|
29
|
+
# Converts one notebook, or annotates one PDF. +output+ may be a file path
|
|
30
|
+
# or a directory.
|
|
31
|
+
def convert(source, output: nil, name: nil)
|
|
32
|
+
return convert_document(source, output, name) if self.class.document?(source)
|
|
33
|
+
|
|
34
|
+
notebook = Notebook.open(source)
|
|
35
|
+
pages = notebook.pages
|
|
36
|
+
raise Error, "#{source}: notebook has no pages" if pages.empty?
|
|
37
|
+
|
|
38
|
+
stem = name ? notebook.sanitize(name) : notebook.basename(titles: @titles)
|
|
39
|
+
destination = resolve_output(output, stem, File.dirname(notebook.path))
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
41
|
+
@renderer.render(pages, destination, title: notebook.known_name(@titles) || File.basename(destination, ".pdf"))
|
|
42
|
+
notebook_result(notebook, destination, pages)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def notebook_result(notebook, destination, pages)
|
|
48
|
+
Result.new(
|
|
49
|
+
source: notebook.path,
|
|
50
|
+
output: destination,
|
|
51
|
+
uuid: notebook.uuid,
|
|
52
|
+
name: notebook.known_name(@titles),
|
|
53
|
+
page_count: pages.size,
|
|
54
|
+
stroke_count: pages.sum { |page| page.strokes.size },
|
|
55
|
+
warnings: notebook.warnings,
|
|
56
|
+
written_at: notebook.modified_at,
|
|
57
|
+
document: false
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The copy goes into the current directory unless told otherwise: next to
|
|
62
|
+
# the original it would have the original's name.
|
|
63
|
+
def convert_document(source, output, name)
|
|
64
|
+
document = Document.open(source)
|
|
65
|
+
stem = (name && Notebook.safe_name(name)) || File.basename(document.path, ".*")
|
|
66
|
+
destination = resolve_output(output, stem, Dir.pwd)
|
|
67
|
+
if File.expand_path(destination) == document.path ||
|
|
68
|
+
(File.exist?(destination) && File.identical?(destination, document.path))
|
|
69
|
+
raise Error, "refusing to write over the original; choose another place with -o"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
claim(destination, document.path)
|
|
73
|
+
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
75
|
+
notes = @documents.render(document, destination)
|
|
76
|
+
document_result(document, destination, stem, notes)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def document_result(document, destination, stem, notes)
|
|
80
|
+
Result.new(
|
|
81
|
+
source: document.path,
|
|
82
|
+
output: destination,
|
|
83
|
+
uuid: document.id,
|
|
84
|
+
name: stem,
|
|
85
|
+
page_count: notes.map(&:page_index).uniq.size,
|
|
86
|
+
stroke_count: notes.sum { |note| note.page.strokes.size },
|
|
87
|
+
warnings: document.warnings,
|
|
88
|
+
written_at: File.mtime(document.notebook_path),
|
|
89
|
+
document: true
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Two PDFs of the same name in different folders would otherwise end up
|
|
94
|
+
# in one file, the second silently replacing the first.
|
|
95
|
+
def claim(destination, source)
|
|
96
|
+
earlier = @written[File.expand_path(destination)]
|
|
97
|
+
raise Error, "#{destination} was already written from #{earlier}; convert this one with -o" if earlier
|
|
98
|
+
|
|
99
|
+
@written[File.expand_path(destination)] = source
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resolve_output(output, stem, default_directory)
|
|
103
|
+
if output.nil?
|
|
104
|
+
File.join(default_directory, "#{stem}.pdf")
|
|
105
|
+
elsif File.directory?(output) || output.end_with?(File::SEPARATOR)
|
|
106
|
+
File.join(output, "#{stem}.pdf")
|
|
107
|
+
else
|
|
108
|
+
output
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Sergey Avseyev
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
module KindleNotebook
|
|
9
|
+
# Locates the notebook folder on a Kindle attached over USB.
|
|
10
|
+
#
|
|
11
|
+
# A Kindle speaks MTP rather than presenting a block device, so it cannot be
|
|
12
|
+
# mounted with mount(8). On Linux, gvfs exposes MTP devices as FUSE paths
|
|
13
|
+
# under the session runtime directory once `gio mount` has attached them,
|
|
14
|
+
# which is what this drives.
|
|
15
|
+
class Device
|
|
16
|
+
NOTEBOOKS = ".notebooks"
|
|
17
|
+
ACTIVATION_ROOT = %r{activation_root=(mtp://\S+)}
|
|
18
|
+
|
|
19
|
+
# Amazon's USB vendor id, so a missing device can be told apart from one
|
|
20
|
+
# gvfs has not offered yet.
|
|
21
|
+
KINDLE_VENDOR = "1949"
|
|
22
|
+
USB_DEVICES = "/sys/bus/usb/devices"
|
|
23
|
+
|
|
24
|
+
# Detaching and reattaching makes a Kindle re-enumerate, during which gvfs
|
|
25
|
+
# briefly stops listing it, so both steps wait rather than fail outright.
|
|
26
|
+
# The waits are against the clock, not a count of sleeps, because probing
|
|
27
|
+
# gvfs costs about half a second each time.
|
|
28
|
+
MOUNT_TIMEOUT = 20
|
|
29
|
+
VOLUME_TIMEOUT = 10
|
|
30
|
+
POLL_INTERVAL = 0.25
|
|
31
|
+
|
|
32
|
+
# A Kindle that has dropped off the bus does not come back on its own.
|
|
33
|
+
RECONNECT = "unplug the USB cable and plug it back in, then try again"
|
|
34
|
+
|
|
35
|
+
class Unavailable < Error; end
|
|
36
|
+
|
|
37
|
+
# The external commands this needs, isolated so tests can stand in for them.
|
|
38
|
+
class Shell
|
|
39
|
+
def capture(*argv)
|
|
40
|
+
output, status = Open3.capture2e(*argv)
|
|
41
|
+
[output, status.success?]
|
|
42
|
+
rescue Errno::ENOENT
|
|
43
|
+
["", false]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# capture returns false for a command that is not installed at all.
|
|
47
|
+
def available?(command) = capture(command, "--version").last
|
|
48
|
+
|
|
49
|
+
def sleep(seconds) = Kernel.sleep(seconds)
|
|
50
|
+
|
|
51
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(shell: Shell.new, runtime_dir: nil, host_os: RbConfig::CONFIG["host_os"],
|
|
55
|
+
usb_devices: USB_DEVICES)
|
|
56
|
+
@shell = shell
|
|
57
|
+
@runtime_dir = runtime_dir || ENV["XDG_RUNTIME_DIR"] || "/run/user/#{Process.uid}"
|
|
58
|
+
@host_os = host_os
|
|
59
|
+
@usb_devices = usb_devices
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The device's `.notebooks` folder, mounting the Kindle first when it is
|
|
63
|
+
# not mounted already.
|
|
64
|
+
def notebooks_path
|
|
65
|
+
require_linux
|
|
66
|
+
existing = mounted_notebooks
|
|
67
|
+
return existing if existing
|
|
68
|
+
|
|
69
|
+
mount
|
|
70
|
+
mounted_notebooks || raise(Unavailable, "mounted the Kindle but found no #{NOTEBOOKS} folder on it")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every notebook on the device. Only the top level counts: the clipboard's
|
|
74
|
+
# scratch buffer sits a level deeper and is not a notebook of its own.
|
|
75
|
+
def notebooks(root = notebooks_path)
|
|
76
|
+
Dir.glob(File.join(root, "*", "nbk")).select { |path| File.file?(path) }.sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# True when a Kindle is already mounted and holds notebooks.
|
|
80
|
+
def mounted? = !mounted_notebooks.nil?
|
|
81
|
+
|
|
82
|
+
# True when a device with Amazon's USB vendor id is plugged in.
|
|
83
|
+
def connected?
|
|
84
|
+
Dir.glob(File.join(@usb_devices, "*", "idVendor")).any? do |path|
|
|
85
|
+
File.read(path).strip == KINDLE_VENDOR
|
|
86
|
+
rescue SystemCallError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def require_linux
|
|
94
|
+
return if @host_os.match?(/linux/i)
|
|
95
|
+
|
|
96
|
+
raise Unavailable, "connecting to a Kindle over MTP is only implemented for Linux, not #{@host_os}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# gvfs names each MTP mount after the device, and the storage sits one
|
|
100
|
+
# level in ("Internal Storage" on a Scribe).
|
|
101
|
+
def mounted_notebooks
|
|
102
|
+
Dir.glob(File.join(@runtime_dir, "gvfs", "mtp:host=*", "*", NOTEBOOKS))
|
|
103
|
+
.find { |path| File.directory?(path) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def mount
|
|
107
|
+
raise Unavailable, "gio is not installed; it comes with gvfs" unless @shell.available?("gio")
|
|
108
|
+
|
|
109
|
+
uri = activation_root
|
|
110
|
+
output, ok = @shell.capture("gio", "mount", uri)
|
|
111
|
+
raise Unavailable, "could not mount #{uri}: #{output.strip}" unless ok
|
|
112
|
+
|
|
113
|
+
wait_for_mount
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A Kindle that is not on the bus at all will not turn up by waiting, so
|
|
117
|
+
# that case is reported straight away; a Kindle that is plugged in may still
|
|
118
|
+
# be settling, and gets the benefit of the wait.
|
|
119
|
+
def activation_root
|
|
120
|
+
raise Unavailable, "no Kindle found on USB; connect it with a cable and try again" unless connected?
|
|
121
|
+
|
|
122
|
+
wait_for(VOLUME_TIMEOUT) { listed_activation_root } ||
|
|
123
|
+
raise(Unavailable, "a Kindle is connected but gvfs is not offering it over MTP. " \
|
|
124
|
+
"Unlock the screen, and if it stays hidden, #{RECONNECT}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def listed_activation_root
|
|
128
|
+
listing, ok = @shell.capture("gio", "mount", "-li")
|
|
129
|
+
ok ? listing[ACTIVATION_ROOT, 1] : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def wait_for_mount = wait_for(MOUNT_TIMEOUT) { mounted_notebooks }
|
|
133
|
+
|
|
134
|
+
def wait_for(timeout)
|
|
135
|
+
deadline = @shell.now + timeout
|
|
136
|
+
loop do
|
|
137
|
+
found = yield
|
|
138
|
+
return found if found
|
|
139
|
+
return nil if @shell.now >= deadline
|
|
140
|
+
|
|
141
|
+
@shell.sleep(POLL_INTERVAL)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|