bookflow 0.1.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/LICENSE +21 -0
- data/README.md +158 -0
- data/bin/bookflow +5 -0
- data/lib/bookflow/apple_books/database.rb +107 -0
- data/lib/bookflow/apple_books/extract.rb +96 -0
- data/lib/bookflow/apple_books/models.rb +18 -0
- data/lib/bookflow/apple_books/paths.rb +24 -0
- data/lib/bookflow/apple_books/render_markdown.rb +50 -0
- data/lib/bookflow/cli.rb +111 -0
- data/lib/bookflow/utils.rb +51 -0
- data/lib/bookflow/version.rb +3 -0
- data/lib/bookflow.rb +11 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cd8cba2fef6ce99676690119e530e7354737e6210d352a5d8c9f7d0da6b6eb57
|
|
4
|
+
data.tar.gz: c32011ded974d9ffa537b3a4b7799e772a63b2fe7a9680e8d346c0abf79c2e65
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 76468692b7293a984e41839334069a8eba65b461489c24eeb2c4e1fd8073c3793cabad00a7b2b2b9e26f4841b6c1d9424a63a134a1c8abf1fdd2474dbd375bce
|
|
7
|
+
data.tar.gz: 0a65ba7ab9acd20a2872bcbdec671f8a759d7abe26d2751d425ba1bedbd7904fe732ca72d198106ee6113358d60b0f9a0f66a69c80b08c92a83b792ac5fddf49
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Meireles
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Bookflow
|
|
2
|
+
|
|
3
|
+
`bookflow` is a local Ruby CLI that exports Apple Books highlights and notes from macOS SQLite databases into deterministic Markdown files (one file per book).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Reads Apple Books annotation and library metadata from local SQLite databases
|
|
8
|
+
- Exports one Markdown file per book
|
|
9
|
+
- Deterministic filenames and stable annotation ordering
|
|
10
|
+
- Case-insensitive filtering by book title/author
|
|
11
|
+
- Dry-run mode for previewing exports
|
|
12
|
+
- Safe overwrite behavior (`--overwrite` required when files already exist)
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- macOS (Apple Books database paths are macOS container paths)
|
|
17
|
+
- Ruby `>= 3.1`
|
|
18
|
+
- Bundler
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
From RubyGems (after publish):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install bookflow
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For local development:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Run the CLI via Bundler:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle exec ruby bin/bookflow export apple-books
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or directly after making it executable:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
./bin/bookflow export apple-books
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Command
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bookflow export apple-books [options]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Options
|
|
55
|
+
|
|
56
|
+
- `--output-dir <path>`
|
|
57
|
+
- Output directory for Markdown files
|
|
58
|
+
- Default: `./exports`
|
|
59
|
+
- `--book <substring>`
|
|
60
|
+
- Case-insensitive filter applied to book title and author
|
|
61
|
+
- `--overwrite`
|
|
62
|
+
- Overwrite existing output files
|
|
63
|
+
- `--dry-run`
|
|
64
|
+
- Print files that would be written, without writing any files
|
|
65
|
+
|
|
66
|
+
## Apple Books Database Paths
|
|
67
|
+
|
|
68
|
+
By default, `bookflow` reads:
|
|
69
|
+
|
|
70
|
+
- Annotation DB:
|
|
71
|
+
- `~/Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite`
|
|
72
|
+
- Library DB:
|
|
73
|
+
- `~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite`
|
|
74
|
+
|
|
75
|
+
## Environment Variable Overrides
|
|
76
|
+
|
|
77
|
+
You can override database paths:
|
|
78
|
+
|
|
79
|
+
- `BOOKFLOW_APPLE_BOOKS_ANNOTATION_DB`
|
|
80
|
+
- `BOOKFLOW_APPLE_BOOKS_LIBRARY_DB`
|
|
81
|
+
|
|
82
|
+
Legacy-compatible aliases are also supported:
|
|
83
|
+
|
|
84
|
+
- `APPLE_BOOKS_EXPORTER_ANNOTATION_DB`
|
|
85
|
+
- `APPLE_BOOKS_EXPORTER_LIBRARY_DB`
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
BOOKFLOW_APPLE_BOOKS_ANNOTATION_DB=/tmp/annotations.sqlite \
|
|
91
|
+
BOOKFLOW_APPLE_BOOKS_LIBRARY_DB=/tmp/library.sqlite \
|
|
92
|
+
bundle exec ruby bin/bookflow export apple-books --output-dir ./exports --dry-run
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
Export all annotations to `./exports`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bundle exec ruby bin/bookflow export apple-books
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Only export books matching `"metz"` in title or author:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
bundle exec ruby bin/bookflow export apple-books --book metz
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Preview output without writing files:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
bundle exec ruby bin/bookflow export apple-books --dry-run
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Overwrite existing files:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bundle exec ruby bin/bookflow export apple-books --overwrite
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Output
|
|
122
|
+
|
|
123
|
+
Each exported Markdown file contains:
|
|
124
|
+
|
|
125
|
+
- Book metadata (title, author, asset id, source path)
|
|
126
|
+
- Export timestamp
|
|
127
|
+
- Ordered annotations (highlight text, note text, location metadata, created timestamp)
|
|
128
|
+
|
|
129
|
+
Annotation ordering is deterministic and prioritizes reading position:
|
|
130
|
+
|
|
131
|
+
1. Absolute physical location
|
|
132
|
+
2. Range start
|
|
133
|
+
3. Creation timestamp
|
|
134
|
+
4. Annotation primary key (`Z_PK`)
|
|
135
|
+
|
|
136
|
+
## Run Tests
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bundle exec rake test
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Release
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bundle exec rake test
|
|
146
|
+
gem build bookflow.gemspec
|
|
147
|
+
gem signin
|
|
148
|
+
gem push bookflow-$(ruby -Ilib -e 'require "bookflow/version"; print Bookflow::VERSION').gem
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Project Structure
|
|
152
|
+
|
|
153
|
+
- `bin/bookflow`: CLI entrypoint
|
|
154
|
+
- `lib/bookflow/cli.rb`: command parsing and orchestration
|
|
155
|
+
- `lib/bookflow/apple_books/database.rb`: read-only SQLite access + schema validation
|
|
156
|
+
- `lib/bookflow/apple_books/extract.rb`: grouping/filtering/sorting
|
|
157
|
+
- `lib/bookflow/apple_books/render_markdown.rb`: markdown rendering
|
|
158
|
+
- `test/`: unit and integration-style tests with fixture databases
|
data/bin/bookflow
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require "sqlite3"
|
|
3
|
+
|
|
4
|
+
module Bookflow
|
|
5
|
+
module AppleBooks
|
|
6
|
+
class Database
|
|
7
|
+
REQUIRED_ANNOTATION_COLUMNS = %w[
|
|
8
|
+
Z_PK
|
|
9
|
+
ZANNOTATIONASSETID
|
|
10
|
+
ZANNOTATIONDELETED
|
|
11
|
+
ZANNOTATIONTYPE
|
|
12
|
+
ZANNOTATIONSTYLE
|
|
13
|
+
ZANNOTATIONCREATIONDATE
|
|
14
|
+
ZANNOTATIONMODIFICATIONDATE
|
|
15
|
+
ZANNOTATIONNOTE
|
|
16
|
+
ZANNOTATIONREPRESENTATIVETEXT
|
|
17
|
+
ZANNOTATIONSELECTEDTEXT
|
|
18
|
+
ZPLABSOLUTEPHYSICALLOCATION
|
|
19
|
+
ZPLLOCATIONRANGESTART
|
|
20
|
+
ZPLLOCATIONRANGEEND
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
REQUIRED_LIBRARY_COLUMNS = %w[
|
|
24
|
+
ZASSETID
|
|
25
|
+
ZTITLE
|
|
26
|
+
ZAUTHOR
|
|
27
|
+
ZPATH
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(annotation_db_path:, library_db_path:)
|
|
31
|
+
@annotation_db_path = annotation_db_path.to_s
|
|
32
|
+
@library_db_path = library_db_path.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_schema?
|
|
36
|
+
validate_schema!
|
|
37
|
+
true
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_schema!
|
|
43
|
+
validate_database!(
|
|
44
|
+
path: @annotation_db_path,
|
|
45
|
+
table: "ZAEANNOTATION",
|
|
46
|
+
columns: REQUIRED_ANNOTATION_COLUMNS
|
|
47
|
+
)
|
|
48
|
+
validate_database!(
|
|
49
|
+
path: @library_db_path,
|
|
50
|
+
table: "ZBKLIBRARYASSET",
|
|
51
|
+
columns: REQUIRED_LIBRARY_COLUMNS
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def annotation_rows
|
|
56
|
+
open_readonly(@annotation_db_path) do |db|
|
|
57
|
+
db.execute(<<~SQL)
|
|
58
|
+
SELECT Z_PK, ZANNOTATIONASSETID, ZANNOTATIONDELETED, ZANNOTATIONTYPE, ZANNOTATIONSTYLE,
|
|
59
|
+
ZANNOTATIONCREATIONDATE, ZANNOTATIONMODIFICATIONDATE, ZANNOTATIONNOTE,
|
|
60
|
+
ZANNOTATIONREPRESENTATIVETEXT, ZANNOTATIONSELECTEDTEXT, ZPLABSOLUTEPHYSICALLOCATION,
|
|
61
|
+
ZPLLOCATIONRANGESTART, ZPLLOCATIONRANGEEND
|
|
62
|
+
FROM ZAEANNOTATION
|
|
63
|
+
WHERE COALESCE(ZANNOTATIONDELETED, 0) = 0
|
|
64
|
+
SQL
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def book_rows
|
|
69
|
+
open_readonly(@library_db_path) do |db|
|
|
70
|
+
db.execute(<<~SQL)
|
|
71
|
+
SELECT ZASSETID, ZTITLE, ZAUTHOR, ZPATH
|
|
72
|
+
FROM ZBKLIBRARYASSET
|
|
73
|
+
WHERE ZASSETID IS NOT NULL
|
|
74
|
+
SQL
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def open_readonly(path)
|
|
81
|
+
raise ArgumentError, "Database missing: #{path}" unless File.exist?(path)
|
|
82
|
+
|
|
83
|
+
db = SQLite3::Database.new(path, readonly: true)
|
|
84
|
+
db.results_as_hash = true
|
|
85
|
+
yield db
|
|
86
|
+
ensure
|
|
87
|
+
db&.close
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_database!(path:, table:, columns:)
|
|
91
|
+
open_readonly(path) do |db|
|
|
92
|
+
tables = db.execute(
|
|
93
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
94
|
+
[table]
|
|
95
|
+
)
|
|
96
|
+
raise ArgumentError, "Required table missing: #{table}" if tables.empty?
|
|
97
|
+
|
|
98
|
+
found_columns = db.execute("PRAGMA table_info(#{table})").map { |row| row["name"] }.to_set
|
|
99
|
+
missing_columns = columns.reject { |column| found_columns.include?(column) }
|
|
100
|
+
return if missing_columns.empty?
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "Required columns missing in #{table}: #{missing_columns.join(', ')}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Bookflow
|
|
4
|
+
module AppleBooks
|
|
5
|
+
module Extract
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def group_exports(annotation_rows:, book_rows:, book_filter:)
|
|
9
|
+
books_by_asset_id = build_books_lookup(book_rows)
|
|
10
|
+
grouped_annotations = Hash.new { |hash, key| hash[key] = [] }
|
|
11
|
+
|
|
12
|
+
annotation_rows.each do |row|
|
|
13
|
+
next if blank_text_row?(row)
|
|
14
|
+
|
|
15
|
+
asset_id = row["ZANNOTATIONASSETID"].to_s
|
|
16
|
+
book = books_by_asset_id[asset_id] || unknown_book(asset_id)
|
|
17
|
+
next if filtered_out?(book, book_filter)
|
|
18
|
+
|
|
19
|
+
grouped_annotations[book] << build_annotation(row)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
grouped_annotations.map do |book, annotations|
|
|
23
|
+
BookExport.new(book: book, annotations: sort_annotations(annotations))
|
|
24
|
+
end.sort_by { |book_export| [book_export.book.title.to_s.downcase, book_export.book.asset_id.to_s] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sort_annotations(annotations)
|
|
28
|
+
annotations.sort_by do |annotation|
|
|
29
|
+
[
|
|
30
|
+
annotation.absolute_location || Float::INFINITY,
|
|
31
|
+
annotation.range_start || Float::INFINITY,
|
|
32
|
+
annotation.created_at || Time.at(0).utc,
|
|
33
|
+
annotation.annotation_id || 0
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def filtered_out?(book, book_filter)
|
|
39
|
+
return false if book_filter.nil? || book_filter.strip.empty?
|
|
40
|
+
|
|
41
|
+
haystack = "#{book.title} #{book.author}".downcase
|
|
42
|
+
!haystack.include?(book_filter.downcase)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def blank_text_row?(row)
|
|
46
|
+
selected = row["ZANNOTATIONSELECTEDTEXT"].to_s
|
|
47
|
+
representative = row["ZANNOTATIONREPRESENTATIVETEXT"].to_s
|
|
48
|
+
note = row["ZANNOTATIONNOTE"].to_s
|
|
49
|
+
selected.empty? && representative.empty? && note.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_books_lookup(rows)
|
|
53
|
+
rows.each_with_object({}) do |row, memo|
|
|
54
|
+
asset_id = row["ZASSETID"].to_s
|
|
55
|
+
memo[asset_id] = Book.new(
|
|
56
|
+
asset_id: asset_id,
|
|
57
|
+
title: row["ZTITLE"],
|
|
58
|
+
author: row["ZAUTHOR"],
|
|
59
|
+
source_path: row["ZPATH"]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_annotation(row)
|
|
65
|
+
selected_text = row["ZANNOTATIONSELECTEDTEXT"].to_s
|
|
66
|
+
representative_text = row["ZANNOTATIONREPRESENTATIVETEXT"]
|
|
67
|
+
highlight_text = selected_text.empty? ? representative_text : selected_text
|
|
68
|
+
|
|
69
|
+
Annotation.new(
|
|
70
|
+
annotation_id: row["Z_PK"],
|
|
71
|
+
asset_id: row["ZANNOTATIONASSETID"],
|
|
72
|
+
highlight_text: highlight_text,
|
|
73
|
+
note_text: row["ZANNOTATIONNOTE"],
|
|
74
|
+
created_at: parse_time(row["ZANNOTATIONCREATIONDATE"]),
|
|
75
|
+
modified_at: parse_time(row["ZANNOTATIONMODIFICATIONDATE"]),
|
|
76
|
+
absolute_location: row["ZPLABSOLUTEPHYSICALLOCATION"],
|
|
77
|
+
range_start: row["ZPLLOCATIONRANGESTART"],
|
|
78
|
+
range_end: row["ZPLLOCATIONRANGEEND"]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_time(value)
|
|
83
|
+
return value if value.is_a?(Time)
|
|
84
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
85
|
+
|
|
86
|
+
Time.parse(value.to_s)
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unknown_book(asset_id)
|
|
92
|
+
Book.new(asset_id: asset_id, title: "Unknown Book", author: nil, source_path: nil)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Bookflow
|
|
2
|
+
module AppleBooks
|
|
3
|
+
Book = Struct.new(:asset_id, :title, :author, :source_path, keyword_init: true)
|
|
4
|
+
Annotation = Struct.new(
|
|
5
|
+
:annotation_id,
|
|
6
|
+
:asset_id,
|
|
7
|
+
:highlight_text,
|
|
8
|
+
:note_text,
|
|
9
|
+
:created_at,
|
|
10
|
+
:modified_at,
|
|
11
|
+
:absolute_location,
|
|
12
|
+
:range_start,
|
|
13
|
+
:range_end,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
BookExport = Struct.new(:book, :annotations, keyword_init: true)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Bookflow
|
|
4
|
+
module AppleBooks
|
|
5
|
+
module Paths
|
|
6
|
+
ANNOTATION_DB_RELATIVE_PATH = "Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite".freeze
|
|
7
|
+
LIBRARY_DB_RELATIVE_PATH = "Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite".freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def annotation_db_path(home: Dir.home)
|
|
12
|
+
Pathname.new(File.join(home, ANNOTATION_DB_RELATIVE_PATH))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def library_db_path(home: Dir.home)
|
|
16
|
+
Pathname.new(File.join(home, LIBRARY_DB_RELATIVE_PATH))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def output_dir(value, pwd: Dir.pwd)
|
|
20
|
+
Pathname.new(File.expand_path(value, pwd))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Bookflow
|
|
2
|
+
module AppleBooks
|
|
3
|
+
module RenderMarkdown
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def render(book_export, exported_at:)
|
|
7
|
+
lines = []
|
|
8
|
+
lines << "# #{book_export.book.title}"
|
|
9
|
+
lines << ""
|
|
10
|
+
lines << "- Author: #{book_export.book.author}"
|
|
11
|
+
lines << "- Asset ID: #{book_export.book.asset_id}"
|
|
12
|
+
lines << "- Source Path: #{book_export.book.source_path}"
|
|
13
|
+
lines << "- Exported At: #{Utils.format_timestamp(exported_at)}"
|
|
14
|
+
lines << "- Annotation Count: #{book_export.annotations.length}"
|
|
15
|
+
lines << ""
|
|
16
|
+
lines << "## Highlights"
|
|
17
|
+
|
|
18
|
+
book_export.annotations.each_with_index do |annotation, index|
|
|
19
|
+
lines << ""
|
|
20
|
+
lines << "### #{index + 1}"
|
|
21
|
+
lines << ""
|
|
22
|
+
lines << "> #{annotation.highlight_text.to_s.rstrip}"
|
|
23
|
+
lines << ""
|
|
24
|
+
lines << "- Created At: #{Utils.format_timestamp(annotation.created_at)}" if annotation.created_at
|
|
25
|
+
location = format_location(annotation)
|
|
26
|
+
lines << "- Location: #{location}" unless location.nil?
|
|
27
|
+
|
|
28
|
+
note = annotation.note_text.to_s.rstrip
|
|
29
|
+
next if note.empty?
|
|
30
|
+
|
|
31
|
+
lines << ""
|
|
32
|
+
lines << "Note:"
|
|
33
|
+
lines << note
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
"#{lines.join("\n")}\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_location(annotation)
|
|
40
|
+
parts = []
|
|
41
|
+
parts << "absolute=#{annotation.absolute_location.inspect}" unless annotation.absolute_location.nil?
|
|
42
|
+
parts << "start=#{annotation.range_start.inspect}" unless annotation.range_start.nil?
|
|
43
|
+
parts << "end=#{annotation.range_end.inspect}" unless annotation.range_end.nil?
|
|
44
|
+
return nil if parts.empty?
|
|
45
|
+
|
|
46
|
+
parts.join(", ")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/bookflow/cli.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Bookflow
|
|
6
|
+
class CLI
|
|
7
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV, pwd: Dir.pwd)
|
|
8
|
+
@stdout = stdout
|
|
9
|
+
@stderr = stderr
|
|
10
|
+
@env = env
|
|
11
|
+
@pwd = pwd
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(argv)
|
|
15
|
+
command = argv.shift
|
|
16
|
+
unless command == "export"
|
|
17
|
+
return write_error("Usage: bookflow export apple-books [options]")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
source = argv.shift
|
|
21
|
+
unless source == "apple-books"
|
|
22
|
+
return write_error("Usage: bookflow export apple-books [options]")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
options = parse_export_apple_books_options(argv)
|
|
26
|
+
raise ArgumentError, "Unexpected arguments: #{argv.join(' ')}" unless argv.empty?
|
|
27
|
+
|
|
28
|
+
export_apple_books(options)
|
|
29
|
+
0
|
|
30
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
31
|
+
write_error(e.message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_export_apple_books_options(argv)
|
|
37
|
+
options = { output_dir: "./exports", book: nil, overwrite: false, dry_run: false }
|
|
38
|
+
parser = OptionParser.new do |opts|
|
|
39
|
+
opts.banner = "Usage: bookflow export apple-books [options]"
|
|
40
|
+
opts.on("--output-dir PATH") { |value| options[:output_dir] = value }
|
|
41
|
+
opts.on("--book SUBSTRING") { |value| options[:book] = value }
|
|
42
|
+
opts.on("--overwrite") { options[:overwrite] = true }
|
|
43
|
+
opts.on("--dry-run") { options[:dry_run] = true }
|
|
44
|
+
end
|
|
45
|
+
parser.parse!(argv)
|
|
46
|
+
options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def export_apple_books(options)
|
|
50
|
+
annotation_db_path =
|
|
51
|
+
@env["BOOKFLOW_APPLE_BOOKS_ANNOTATION_DB"] ||
|
|
52
|
+
@env["APPLE_BOOKS_EXPORTER_ANNOTATION_DB"] ||
|
|
53
|
+
AppleBooks::Paths.annotation_db_path.to_s
|
|
54
|
+
library_db_path =
|
|
55
|
+
@env["BOOKFLOW_APPLE_BOOKS_LIBRARY_DB"] ||
|
|
56
|
+
@env["APPLE_BOOKS_EXPORTER_LIBRARY_DB"] ||
|
|
57
|
+
AppleBooks::Paths.library_db_path.to_s
|
|
58
|
+
output_dir = AppleBooks::Paths.output_dir(options[:output_dir], pwd: @pwd)
|
|
59
|
+
|
|
60
|
+
database = AppleBooks::Database.new(
|
|
61
|
+
annotation_db_path: annotation_db_path,
|
|
62
|
+
library_db_path: library_db_path
|
|
63
|
+
)
|
|
64
|
+
database.validate_schema!
|
|
65
|
+
|
|
66
|
+
exports = AppleBooks::Extract.group_exports(
|
|
67
|
+
annotation_rows: database.annotation_rows,
|
|
68
|
+
book_rows: database.book_rows,
|
|
69
|
+
book_filter: options[:book]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
write_exports(
|
|
73
|
+
exports,
|
|
74
|
+
output_dir: output_dir,
|
|
75
|
+
overwrite: options[:overwrite],
|
|
76
|
+
dry_run: options[:dry_run],
|
|
77
|
+
exported_at: Time.now
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write_exports(exports, output_dir:, overwrite:, dry_run:, exported_at:)
|
|
82
|
+
used_filenames = {}
|
|
83
|
+
FileUtils.mkdir_p(output_dir) if !dry_run && !exports.empty?
|
|
84
|
+
|
|
85
|
+
exports.each do |book_export|
|
|
86
|
+
filename = Utils.output_filename(
|
|
87
|
+
title: book_export.book.title,
|
|
88
|
+
asset_id: book_export.book.asset_id,
|
|
89
|
+
used_filenames: used_filenames
|
|
90
|
+
)
|
|
91
|
+
used_filenames[filename] = true
|
|
92
|
+
path = output_dir.join(filename)
|
|
93
|
+
|
|
94
|
+
if dry_run
|
|
95
|
+
@stdout.puts("would write #{path}")
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "Output file exists: #{path}" if File.exist?(path) && !overwrite
|
|
100
|
+
|
|
101
|
+
File.write(path, AppleBooks::RenderMarkdown.render(book_export, exported_at: exported_at))
|
|
102
|
+
@stdout.puts("wrote #{path}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_error(message)
|
|
107
|
+
@stderr.puts(message)
|
|
108
|
+
1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Bookflow
|
|
4
|
+
module Utils
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def slugify(value)
|
|
8
|
+
ascii = value.to_s.unicode_normalize(:nfkd).encode(
|
|
9
|
+
"ASCII",
|
|
10
|
+
invalid: :replace,
|
|
11
|
+
undef: :replace,
|
|
12
|
+
replace: ""
|
|
13
|
+
)
|
|
14
|
+
ascii.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def short_asset_id(asset_id)
|
|
18
|
+
text = asset_id.to_s
|
|
19
|
+
text[-8, 8] || text
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_timestamp(time)
|
|
23
|
+
value =
|
|
24
|
+
case time
|
|
25
|
+
when Time
|
|
26
|
+
time
|
|
27
|
+
else
|
|
28
|
+
Time.parse(time.to_s)
|
|
29
|
+
end
|
|
30
|
+
value.getlocal.strftime("%Y-%m-%d %H:%M:%S %z")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def output_filename(title:, asset_id:, used_filenames: {})
|
|
34
|
+
base = slugify(title)
|
|
35
|
+
base = slugify(asset_id) if base.empty?
|
|
36
|
+
base = "book" if base.empty?
|
|
37
|
+
|
|
38
|
+
first_choice = "#{base}.md"
|
|
39
|
+
return first_choice unless used_filenames.key?(first_choice)
|
|
40
|
+
|
|
41
|
+
suffix = short_asset_id(asset_id)
|
|
42
|
+
candidate = "#{base}-#{suffix}.md"
|
|
43
|
+
counter = 2
|
|
44
|
+
while used_filenames.key?(candidate)
|
|
45
|
+
candidate = "#{base}-#{suffix}-#{counter}.md"
|
|
46
|
+
counter += 1
|
|
47
|
+
end
|
|
48
|
+
candidate
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/bookflow.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require_relative "bookflow/version"
|
|
2
|
+
require_relative "bookflow/utils"
|
|
3
|
+
require_relative "bookflow/cli"
|
|
4
|
+
require_relative "bookflow/apple_books/paths"
|
|
5
|
+
require_relative "bookflow/apple_books/models"
|
|
6
|
+
require_relative "bookflow/apple_books/database"
|
|
7
|
+
require_relative "bookflow/apple_books/extract"
|
|
8
|
+
require_relative "bookflow/apple_books/render_markdown"
|
|
9
|
+
|
|
10
|
+
module Bookflow
|
|
11
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bookflow
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Pedro Meireles
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sqlite3
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.7'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.7'
|
|
26
|
+
description: A local macOS CLI to export Apple Books highlights and notes from SQLite
|
|
27
|
+
to deterministic Markdown files.
|
|
28
|
+
executables:
|
|
29
|
+
- bookflow
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- LICENSE
|
|
34
|
+
- README.md
|
|
35
|
+
- bin/bookflow
|
|
36
|
+
- lib/bookflow.rb
|
|
37
|
+
- lib/bookflow/apple_books/database.rb
|
|
38
|
+
- lib/bookflow/apple_books/extract.rb
|
|
39
|
+
- lib/bookflow/apple_books/models.rb
|
|
40
|
+
- lib/bookflow/apple_books/paths.rb
|
|
41
|
+
- lib/bookflow/apple_books/render_markdown.rb
|
|
42
|
+
- lib/bookflow/cli.rb
|
|
43
|
+
- lib/bookflow/utils.rb
|
|
44
|
+
- lib/bookflow/version.rb
|
|
45
|
+
homepage: https://github.com/pedromeireles/apple-books-exporter
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
source_code_uri: https://github.com/pedromeireles/apple-books-exporter
|
|
50
|
+
bug_tracker_uri: https://github.com/pedromeireles/apple-books-exporter/issues
|
|
51
|
+
changelog_uri: https://github.com/pedromeireles/apple-books-exporter/releases
|
|
52
|
+
rubygems_mfa_required: 'true'
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.1'
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 3.6.9
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: Export Apple Books highlights and notes to Markdown
|
|
70
|
+
test_files: []
|