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.
@@ -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