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 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,5 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
+ require "bookflow"
4
+
5
+ exit Bookflow::CLI.new.run(ARGV)
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Bookflow
2
+ VERSION = "0.1.0"
3
+ 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: []