hashcards_readwise 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: 28483db60197d3a33cd5b1884f96df11b624ee600a74bd86490b31232c3c9ec1
4
+ data.tar.gz: fc816269bc4f7243ffce9e880d53bc0c44e236fd3c25f85cc5d21f12a47f6bc4
5
+ SHA512:
6
+ metadata.gz: db2f8647c80c5ae77e0c829ec26553d27c5f2595c896ca2b974e39a4916e9bfe42a40d7498845cb62448e35766bacb04393152f505a0696208a17d4f65a8b77e
7
+ data.tar.gz: '075686ab4f1b61fb7b3df46ad00d0c89e3e102ee342bc0a0d0ac92452efeec6e19175990e7967367e2008c51009e6dd8dfed70618c9b1ce99319d541f4d9c611'
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # hashcards-readwise
2
+
3
+ Sync [hashcards](https://github.com/eudoxia0/hashcards) flashcards to [Readwise](https://readwise.io).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install hashcards_readwise
9
+ ```
10
+
11
+ Or add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "hashcards_readwise"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### All-in-one sync
20
+
21
+ ```bash
22
+ # Sync a hashcards collection to Readwise
23
+ hashcards-readwise sync --collection ./cards
24
+
25
+ # Dry run (convert but don't push)
26
+ hashcards-readwise sync --collection ./cards --dry-run
27
+ ```
28
+
29
+ ### Step-by-step
30
+
31
+ ```bash
32
+ # 1. Export hashcards to JSON
33
+ hashcards export ./cards --output export.json
34
+
35
+ # 2. Convert to Readwise format
36
+ hashcards-readwise convert -f export.json -o highlights.json -d ./cards/hashcards.db
37
+
38
+ # 3. Push to Readwise
39
+ hashcards-readwise push -f highlights.json
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ Set your Readwise API token via environment variable:
45
+
46
+ ```bash
47
+ export READWISE_TOKEN="your-token-here"
48
+ ```
49
+
50
+ Or pass it directly:
51
+
52
+ ```bash
53
+ hashcards-readwise sync --token "your-token-here" --collection ./cards
54
+ ```
55
+
56
+ ## Deck Metadata
57
+
58
+ Add TOML frontmatter to your deck files to customize Readwise metadata:
59
+
60
+ ```markdown
61
+ ---
62
+ name = "My Deck"
63
+ author = "Author Name"
64
+ source_url = "https://example.com/source"
65
+ image_url = "https://example.com/image.png"
66
+ tags = ["topic1", "topic2"]
67
+ ---
68
+
69
+ Q: Question here
70
+ A: Answer here
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "hashcards_readwise"
5
+
6
+ HashcardsReadwise::CLI.new.run
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "logger"
5
+
6
+ module HashcardsReadwise
7
+ class CLI
8
+ def initialize(args = ARGV)
9
+ @args = args
10
+ @logger = Logger.new($stderr, level: Logger::WARN)
11
+ @logger.progname = "hashcards-readwise"
12
+ end
13
+
14
+ def run
15
+ OptionParser.new do |o|
16
+ o.on("-v", "--verbose", "Enable verbose logging (can be repeated)") { @logger.level -= 1 }
17
+ end.order!(@args)
18
+
19
+ command = @args.shift
20
+
21
+ case command
22
+ when "convert"
23
+ run_convert
24
+ when "push"
25
+ run_push
26
+ when "sync"
27
+ run_sync
28
+ when "version", "-v", "--version"
29
+ puts "hashcards-readwise #{VERSION}"
30
+ when "help", "-h", "--help", nil
31
+ print_help
32
+ else
33
+ @logger.warn("Unknown command: #{command}")
34
+ print_help
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def run_convert
42
+ options = { file: nil, output: nil, db: nil }
43
+
44
+ OptionParser.new do |o|
45
+ o.banner = "Usage: hashcards-readwise convert [options]"
46
+ o.on("-f", "--file FILE", "Path to hashcards JSON export") { |f| options[:file] = f }
47
+ o.on("-o", "--output FILE", "Output file (default: stdout)") { |f| options[:output] = f }
48
+ o.on("-d", "--db FILE", "Path to hashcards SQLite database") { |f| options[:db] = f }
49
+ end.parse!(@args)
50
+
51
+ unless options[:file]
52
+ @logger.error "Error: --file is required"
53
+ exit 1
54
+ end
55
+
56
+ converter = Converter.new(logger: @logger)
57
+ highlights = converter.convert(hashcards_path: options[:file], db_path: options[:db])
58
+
59
+ output = JSON.pretty_generate(highlights)
60
+ if options[:output]
61
+ File.write(options[:output], output)
62
+ @logger.info("Wrote highlights to #{options[:output]}")
63
+ else
64
+ puts output
65
+ end
66
+ end
67
+
68
+ def run_push
69
+ options = { file: nil, token: ENV["READWISE_TOKEN"], dry_run: false }
70
+
71
+ OptionParser.new do |o|
72
+ o.banner = "Usage: hashcards-readwise push [options]"
73
+ o.on("-f", "--file FILE", "Path to highlights JSON file") { |f| options[:file] = f }
74
+ o.on("-t", "--token TOKEN", "Readwise API token") { |t| options[:token] = t }
75
+ o.on("-n", "--dry-run", "Print request without sending") { options[:dry_run] = true }
76
+ end.parse!(@args)
77
+
78
+ unless options[:file]
79
+ @logger.error "Error: --file is required"
80
+ exit 1
81
+ end
82
+
83
+ unless options[:token]
84
+ @logger.error "Error: Readwise token required (--token or READWISE_TOKEN env)"
85
+ exit 1
86
+ end
87
+
88
+ highlights = JSON.parse(File.read(options[:file]))
89
+ @logger.info("Loaded #{highlights["highlights"].size} highlights from #{options[:file]}")
90
+
91
+ if options[:dry_run]
92
+ @logger.info("Dry run - would POST to #{Pusher::READWISE_API_URL}")
93
+ @logger.info("Body: #{JSON.pretty_generate(highlights)}")
94
+ return
95
+ end
96
+
97
+ pusher = Pusher.new(token: options[:token], logger: @logger)
98
+ result = pusher.push(highlights)
99
+
100
+ unless result[:success]
101
+ @logger.error result[:body]
102
+ exit 1
103
+ end
104
+
105
+ puts JSON.pretty_generate(result[:body])
106
+ end
107
+
108
+ def run_sync
109
+ options = { collection: ".", token: ENV["READWISE_TOKEN"], dry_run: false }
110
+
111
+ OptionParser.new do |o|
112
+ o.banner = "Usage: hashcards-readwise sync [options]"
113
+ o.on("-c", "--collection DIR", "Path to hashcards collection") { |c| options[:collection] = c }
114
+ o.on("-t", "--token TOKEN", "Readwise API token") { |t| options[:token] = t }
115
+ o.on("-n", "--dry-run", "Convert but don't push") { options[:dry_run] = true }
116
+ end.parse!(@args)
117
+
118
+ unless options[:token]
119
+ @logger.error "Error: Readwise token required (--token or READWISE_TOKEN env)"
120
+ exit 1
121
+ end
122
+
123
+ collection = File.expand_path(options[:collection])
124
+ db_path = File.join(collection, "hashcards.db")
125
+ db_path = nil unless File.exist?(db_path)
126
+
127
+ # Export hashcards to temp file
128
+ require "tempfile"
129
+ export_file = Tempfile.new(["hashcards", ".json"])
130
+
131
+ @logger.info("Exporting hashcards from #{collection}...")
132
+ system("hashcards", "export", collection, "--output", export_file.path)
133
+
134
+ unless $?.success?
135
+ @logger.error "Error: hashcards export failed"
136
+ exit 1
137
+ end
138
+
139
+ # Convert
140
+ converter = Converter.new(logger: @logger)
141
+ highlights = converter.convert(hashcards_path: export_file.path, db_path: db_path)
142
+
143
+ if options[:dry_run]
144
+ puts JSON.pretty_generate(highlights)
145
+ return
146
+ end
147
+
148
+ # Push
149
+ pusher = Pusher.new(token: options[:token], logger: @logger)
150
+ result = pusher.push(highlights)
151
+
152
+ unless result[:success]
153
+ @logger.error result[:body]
154
+ exit 1
155
+ end
156
+
157
+ @logger.info "Synced #{highlights["highlights"].size} highlights to Readwise"
158
+ ensure
159
+ export_file&.unlink
160
+ end
161
+
162
+ def print_help
163
+ puts <<~HELP
164
+ hashcards-readwise - Sync hashcards flashcards to Readwise
165
+
166
+ Usage: hashcards-readwise <command> [options]
167
+
168
+ Commands:
169
+ sync Export, convert, and push to Readwise (all-in-one)
170
+ convert Convert hashcards JSON to Readwise highlights format
171
+ push Push highlights JSON to Readwise API
172
+ version Show version
173
+ help Show this help
174
+
175
+ Examples:
176
+ hashcards-readwise sync --collection ./cards
177
+ hashcards-readwise convert -f export.json -o highlights.json
178
+ hashcards-readwise push -f highlights.json
179
+
180
+ Environment:
181
+ READWISE_TOKEN Readwise API token (or use --token)
182
+ HELP
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "sqlite3"
5
+ require "toml-rb"
6
+ require "logger"
7
+
8
+ module HashcardsReadwise
9
+ class Converter
10
+ DEFAULT_AUTHOR = "Derek Stride"
11
+
12
+ attr_reader :logger
13
+
14
+ def initialize(logger: Logger.new($stderr, level: Logger::WARN))
15
+ @logger = logger
16
+ @frontmatter_cache = {}
17
+ end
18
+
19
+ def convert(hashcards_path:, db_path: nil)
20
+ hashcards = JSON.parse(File.read(hashcards_path))
21
+ logger.info("Loaded #{hashcards["cards"].size} cards from #{hashcards_path}")
22
+
23
+ timestamps = load_timestamps(db_path)
24
+ logger.info("Loaded #{timestamps.size} timestamps from database") if timestamps.any?
25
+
26
+ highlights = hashcards["cards"].filter_map { |card| card_to_highlight(card, timestamps) }
27
+ logger.info("Converted #{highlights.size} highlights")
28
+ logger.info("Parsed frontmatter from #{@frontmatter_cache.size} files")
29
+
30
+ { "highlights" => highlights }
31
+ end
32
+
33
+ private
34
+
35
+ def load_timestamps(db_path)
36
+ return {} unless db_path && File.exist?(db_path)
37
+
38
+ db = SQLite3::Database.new(db_path.to_s)
39
+ results = db.execute("SELECT card_hash, added_at FROM cards")
40
+ results.to_h { |row| [row[0], row[1]] }
41
+ rescue SQLite3::Exception => e
42
+ logger.warn("Failed to load timestamps from database: #{e.message}")
43
+ {}
44
+ ensure
45
+ db&.close
46
+ end
47
+
48
+ def parse_frontmatter(file_path)
49
+ return {} unless File.exist?(file_path)
50
+
51
+ content = File.read(file_path)
52
+ return {} unless content.start_with?("---")
53
+
54
+ parts = content.split("---", 3)
55
+ return {} if parts.length < 3
56
+
57
+ toml_content = parts[1].strip
58
+ return {} if toml_content.empty?
59
+
60
+ TomlRB.parse(toml_content)
61
+ rescue TomlRB::ParseError => e
62
+ logger.warn("Failed to parse frontmatter from #{file_path}: #{e.message}")
63
+ {}
64
+ end
65
+
66
+ def get_frontmatter(file_path)
67
+ @frontmatter_cache[file_path] ||= parse_frontmatter(file_path)
68
+ end
69
+
70
+ def card_to_highlight(card, timestamps)
71
+ content = card["content"]
72
+ text = if content["cloze"]
73
+ content["cloze"]["text"]
74
+ elsif content["basic"]
75
+ "Q: #{content["basic"]["question"]}\nA: #{content["basic"]["answer"]}"
76
+ else
77
+ logger.warn("Unknown card content type: #{content.keys}")
78
+ return nil
79
+ end
80
+
81
+ file_path = card.dig("location", "filePath")
82
+ fm = file_path ? get_frontmatter(file_path) : {}
83
+
84
+ note_parts = [".hashcard"]
85
+ fm["tags"]&.each { |tag| note_parts << ".#{tag}" }
86
+ note = note_parts.join("\n")
87
+
88
+ hash = card["hash"]
89
+ highlighted_at = timestamps[hash]
90
+
91
+ {
92
+ "text" => text,
93
+ "title" => card["deckName"],
94
+ "author" => fm["author"] || DEFAULT_AUTHOR,
95
+ "source_type" => "hashcards#sync#derek",
96
+ "category" => "articles",
97
+ "note" => note,
98
+ "source_url" => fm["source_url"],
99
+ "image_url" => fm["image_url"],
100
+ "highlighted_at" => highlighted_at,
101
+ "highlight_url" => "hashcards://#{hash}"
102
+ }.compact
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "logger"
7
+
8
+ module HashcardsReadwise
9
+ class Pusher
10
+ READWISE_API_URL = "https://readwise.io/api/v2/highlights/"
11
+
12
+ attr_reader :logger
13
+
14
+ def initialize(token:, logger: Logger.new($stderr, level: Logger::WARN))
15
+ @token = token
16
+ @logger = logger
17
+ end
18
+
19
+ def push(highlights)
20
+ uri = URI.parse(READWISE_API_URL)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = true
23
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
24
+ http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
25
+
26
+ request = Net::HTTP::Post.new(uri.request_uri)
27
+ request["Authorization"] = "Token #{@token}"
28
+ request["Content-Type"] = "application/json"
29
+ request.body = JSON.generate(highlights)
30
+
31
+ logger.info("POSTing #{highlights["highlights"].size} highlights to Readwise...")
32
+ response = http.request(request)
33
+
34
+ case response
35
+ when Net::HTTPSuccess
36
+ logger.info("Success! Response: #{response.body}")
37
+ { success: true, body: JSON.parse(response.body) }
38
+ else
39
+ logger.error("Failed with status #{response.code}")
40
+ { success: false, code: response.code, body: response.body }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HashcardsReadwise
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hashcards_readwise/version"
4
+ require_relative "hashcards_readwise/converter"
5
+ require_relative "hashcards_readwise/pusher"
6
+ require_relative "hashcards_readwise/cli"
7
+
8
+ module HashcardsReadwise
9
+ class Error < StandardError; end
10
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hashcards_readwise
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Derek Stride
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: toml-rb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Convert hashcards exports to Readwise highlights and push them to the
55
+ Readwise API
56
+ email:
57
+ - derek@stride.host
58
+ executables:
59
+ - hashcards-readwise
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - exe/hashcards-readwise
65
+ - lib/hashcards_readwise.rb
66
+ - lib/hashcards_readwise/cli.rb
67
+ - lib/hashcards_readwise/converter.rb
68
+ - lib/hashcards_readwise/pusher.rb
69
+ - lib/hashcards_readwise/version.rb
70
+ homepage: https://github.com/derekstride/hashcards-readwise
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ allowed_push_host: https://rubygems.org
75
+ homepage_uri: https://github.com/derekstride/hashcards-readwise
76
+ source_code_uri: https://github.com/derekstride/hashcards-readwise
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.0.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.6.9
92
+ specification_version: 4
93
+ summary: Sync hashcards flashcards to Readwise
94
+ test_files: []