diff --git a/README.md b/README.md index 300e2b0..67c5680 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ puts document.child? # is this a highlight/note of another doc # Check location puts document.in_new? -puts document.in_later? +puts document.in_later? puts document.in_archive? # Check category @@ -142,6 +142,32 @@ document = client.create_document(document: document_create) documents = client.create_documents(documents: [document_create1, document_create2]) ``` +## Command Line Interface + +This gem includes a `readwise` command-line tool for quickly sending HTML content to Readwise Reader. + +First, set your API token: +```bash +export READWISE_API_KEY=your_token_here +``` + +Then use the CLI to send HTML files: +```bash +# Basic usage +readwise document create --html-file content.html +readwise document create --url https://datatracker.ietf.org/doc/html/rfc2324 + +# Short form flag +readwise document create -f content.html + +# With options +readwise document create --html-file content.html --title="My Article" --location=later + +# See all available options +readwise --help +readwise document create --help +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/exe/readwise b/exe/readwise new file mode 100755 index 0000000..58f7d30 --- /dev/null +++ b/exe/readwise @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'readwise' +require 'readwise/cli' + +Readwise::CLI.start diff --git a/lib/readwise/cli.rb b/lib/readwise/cli.rb new file mode 100644 index 0000000..7ac186a --- /dev/null +++ b/lib/readwise/cli.rb @@ -0,0 +1,64 @@ +require_relative 'cli/base_command' +require_relative 'cli/command_registry' + +module Readwise + class CLI + def self.start(args = ARGV) + new.start(args) + end + + def start(args) + if args.empty? + show_help + exit 1 + end + + if args.first == '--help' || args.first == '-h' + show_help + return + end + + resource = args.shift&.downcase + action = args.shift&.downcase + + unless resource && action + puts "Error: Resource and action are required" + puts "Usage: readwise [options] [arguments]" + puts "Run 'readwise --help' for more information" + exit 1 + end + + command_class = CommandRegistry.find(resource, action) + unless command_class + puts "Error: Unknown command '#{resource} #{action}'" + puts "Run 'readwise --help' to see available commands" + exit 1 + end + + command = command_class.new + command.execute(args) + rescue => e + puts "Unexpected error: #{e.message}" + exit 1 + end + + private + + def show_help + puts <<~HELP + Usage: readwise [options] [arguments] + + Available commands: + document create --html-file Send HTML content to Readwise Reader + + Global options: + -h, --help Show this help message + + Examples: + readwise document create --html-file content.html --title="My Article" + readwise document create -f content.html --title="My Article" + readwise document create --help + HELP + end + end +end diff --git a/lib/readwise/cli/base_command.rb b/lib/readwise/cli/base_command.rb new file mode 100644 index 0000000..73614ac --- /dev/null +++ b/lib/readwise/cli/base_command.rb @@ -0,0 +1,105 @@ +require 'optparse' + +module Readwise + class CLI + class BaseCommand + def initialize + @options = {} + end + + def execute(args) + parse_options(args) + validate_arguments(args) + run(args) + rescue OptionParser::InvalidOption => e + puts "Error: #{e.message}" + show_help + exit 1 + rescue ArgumentError => e + puts "Error: #{e.message}" + exit 1 + end + + private + + attr_reader :options + + def parse_options(args) + parser = create_option_parser + parser.parse!(args) + end + + def create_option_parser + OptionParser.new do |opts| + opts.banner = banner + opts.separator "" + opts.separator description if description + opts.separator "" + opts.separator "Options:" + + add_options(opts) + + opts.on("-h", "--help", "Show this help message") do + show_help + exit + end + end + end + + def show_help + puts create_option_parser.help + end + + def get_api_client + token = ENV['READWISE_API_KEY'] + unless token + puts "Error: READWISE_API_KEY environment variable is not set" + exit 1 + end + + Readwise::Client.new(token: token) + end + + def read_file(file_path) + unless File.exist?(file_path) + puts "Error: File '#{file_path}' not found" + exit 1 + end + + File.read(file_path) + end + + def handle_api_error(&block) + yield + rescue Readwise::Client::Error => e + puts "API Error: #{e.message}" + exit 1 + rescue => e + puts "Unexpected error: #{e.message}" + exit 1 + end + + # Override these methods in subclasses + + def banner + "Usage: readwise" + end + + def description + nil + end + + def add_options(opts) + # Override in subclasses to add specific options + end + + def validate_arguments(args) + # Override in subclasses to validate arguments + end + + def run(args) + raise NotImplementedError, "Subclasses must implement #run" + end + end + end +end diff --git a/lib/readwise/cli/command_registry.rb b/lib/readwise/cli/command_registry.rb new file mode 100644 index 0000000..1137059 --- /dev/null +++ b/lib/readwise/cli/command_registry.rb @@ -0,0 +1,24 @@ +module Readwise + class CLI + class CommandRegistry + @commands = {} + + def self.register(resource, action, command_class) + key = "#{resource}:#{action}" + @commands[key] = command_class + end + + def self.find(resource, action) + key = "#{resource}:#{action}" + @commands[key] + end + + def self.all_commands + @commands.keys.map { |key| key.split(':') } + end + end + end +end + +# Register available commands +require_relative 'document/create_command' diff --git a/lib/readwise/cli/document/create_command.rb b/lib/readwise/cli/document/create_command.rb new file mode 100644 index 0000000..a68a38f --- /dev/null +++ b/lib/readwise/cli/document/create_command.rb @@ -0,0 +1,128 @@ +require_relative '../base_command' + +module Readwise + class CLI + module Document + class CreateCommand < BaseCommand + CATEGORIES = %w[article email rss highlight note pdf epub tweet video].freeze + def banner + "Usage: readwise document create [options]" + end + + def description + "Sends HTML content to Readwise Reader API" + end + + def add_options(opts) + opts.on("-f", "--html-file=FILE", "HTML file path") do |file| + options[:file] = file + end + + opts.on("--title=TITLE", "Document title") do |title| + options[:title] = title + end + + opts.on("--author=AUTHOR", "Document author") do |author| + options[:author] = author + end + + opts.on("-u", "--url=URL", "Source URL (defaults to https://example.com/)") do |url| + options[:url] = url + end + + opts.on("--summary=SUMMARY", "Document summary") do |summary| + options[:summary] = summary + end + + opts.on("--notes=NOTES", "Personal notes") do |notes| + options[:notes] = notes + end + + opts.on("--location=LOCATION", "Document location: new, later, archive, feed (default: new)") do |location| + unless %w[new later archive feed].include?(location) + puts "Error: Invalid location. Must be one of: new, later, archive, feed" + exit 1 + end + options[:location] = location + end + + opts.on("--category=CATEGORY", "Document category: #{CATEGORIES.join(', ')}") do |category| + unless CATEGORIES.include?(category) + puts "Error: Invalid category. Must be one of: #{CATEGORIES.join(', ')}" + exit 1 + end + options[:category] = category + end + + opts.on("--tags=TAGS", "Comma-separated list of tags") do |tags| + options[:tags] = tags.split(',').map(&:strip) + end + + opts.on("--image-url=URL", "Image URL") do |image_url| + options[:image_url] = image_url + end + + opts.on("--published-date=DATE", "Published date (ISO 8601 format)") do |date| + options[:published_date] = date + end + + opts.on("--[no-]clean-html", "Clean HTML (default: true)") do |clean| + options[:should_clean_html] = clean + end + + opts.on("--saved-using=SOURCE", "Saved using source (default: cli)") do |source| + options[:saved_using] = source + end + end + + def validate_arguments(args) + unless options[:file] || options[:url] + puts "Error: File path or URL is required" + show_help + exit 1 + end + end + + def run(args) + html_file = options[:file] + html_content = read_file(html_file) if html_file + + document_params = build_document_params(html_content, html_file) + + handle_api_error do + client = get_api_client + document_create = Readwise::DocumentCreate.new(**document_params) + document = client.create_document(document: document_create) + + puts "Document created successfully!" + puts "ID: #{document.id}" + puts "Title: #{document.title}" + puts "Location: #{document.location}" + puts "URL: #{document.url}" if document.url + end + end + + private + + def build_document_params(html_content, html_file) + document_params = { + html: html_content, + location: options[:location] || 'new', + should_clean_html: options.key?(:should_clean_html) ? options[:should_clean_html] : true, + saved_using: options[:saved_using] || 'cli', + url: options[:url] || "https://example.com/#{File.basename(html_file)}" + } + + [:title, :author, :summary, :notes, :category, :tags, :image_url, :published_date].each do |key| + document_params[key] = options[key] if options[key] + end + + document_params + end + end + end + end +end + +# Register this command +Readwise::CLI::CommandRegistry.register('document', 'create', Readwise::CLI::Document::CreateCommand) diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb new file mode 100644 index 0000000..ef2eb8a --- /dev/null +++ b/spec/cli_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' +require 'tempfile' +require 'open3' + +RSpec.describe 'readwise CLI' do + let(:cli_path) { File.expand_path('../../exe/readwise', __FILE__) } + let(:test_html) { '

Test content

' } + let(:temp_file) do + file = Tempfile.new(['test', '.html']) + file.write(test_html) + file.close + file + end + + after do + temp_file.unlink if temp_file + end + + def run_cli(*args) + env = { 'READWISE_API_KEY' => 'test_token' } + Open3.capture3(env, 'ruby', cli_path, *args) + end + + def run_cli_without_token(*args) + env = {} + Open3.capture3(env, 'ruby', cli_path, *args) + end + + describe 'help and error handling' do + it 'shows help when --help is passed' do + stdout, _stderr, status = run_cli('--help') + + expect(status.success?).to be true + expect(stdout).to include('Usage: readwise') + expect(stdout).to include('Available commands:') + end + + it 'shows help when no arguments are provided' do + stdout, _stderr, status = run_cli() + + expect(status.success?).to be false + expect(stdout).to include('Usage: readwise ') + end + + it 'shows error for unknown command' do + stdout, _stderr, status = run_cli('unknown', 'command') + + expect(status.success?).to be false + expect(stdout).to include("Error: Unknown command 'unknown command'") + end + + it 'shows error when resource or action is missing' do + stdout, _stderr, status = run_cli('document') + + expect(status.success?).to be false + expect(stdout).to include('Error: Resource and action are required') + end + end + + describe 'document create command' do + it 'shows help for document create command' do + stdout, _stderr, status = run_cli('document', 'create', '--help') + + expect(status.success?).to be true + expect(stdout).to include('Usage: readwise document create') + expect(stdout).to include('Sends HTML content to Readwise Reader API') + end + + it 'shows error when neither a file path or URL is provided' do + stdout, _stderr, status = run_cli('document', 'create') + + expect(status.success?).to be false + expect(stdout).to include('Error: File path or URL is required') + end + + it 'shows error when file does not exist' do + stdout, _stderr, status = run_cli('document', 'create', '--html-file', 'nonexistent.html') + + expect(status.success?).to be false + expect(stdout).to include("Error: File 'nonexistent.html' not found") + end + + it 'shows error when READWISE_API_KEY is not set' do + stdout, _stderr, status = run_cli_without_token('document', 'create', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: READWISE_API_KEY environment variable is not set') + end + + it 'shows error for invalid location' do + stdout, _stderr, status = run_cli('document', 'create', '--location=invalid', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: Invalid location. Must be one of: new, later, archive, feed') + end + + it 'shows error for invalid category' do + stdout, _stderr, status = run_cli('document', 'create', '--category=invalid', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: Invalid category. Must be one of: article, email, rss, highlight, note, pdf, epub, tweet, video') + end + end +end