willow_camp_cli 0.1.12

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: c468d463e934298a4d1b7978fba95864b0706d1ec8c9f9c663f4471c0f656750
4
+ data.tar.gz: '03921da40af47edb550b0649f9208f10f6537220b5eb5599f9ce1332e7887c79'
5
+ SHA512:
6
+ metadata.gz: ff22ca55177e194ca8e0a1c31de62cce902873baa21b0927905d27ca3bc53da6909434e2666c6ef69d87f614200fc48b4bd29dbc69ff01a953d743d631c1bb6f
7
+ data.tar.gz: 1c8b21285051eae025cdb2a96788a90bc709cf39f62da2bd0bf1ae1000fd5e444ef69dd8a02af517f8effab3533193cc4010b270696cd5157b2563c82c44d226
@@ -0,0 +1,45 @@
1
+ name: Ruby Gem
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ build:
11
+ name: Build + Publish
12
+ runs-on: ubuntu-latest
13
+
14
+ permissions:
15
+ contents: write
16
+ packages: write
17
+ id-token: write
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - name: Set up Ruby
22
+ uses: ruby/setup-ruby@v1
23
+ - name: Install dependencies
24
+ run: bundle install --jobs 4 --retry 3
25
+ - name: Run tests
26
+ run: rake test
27
+ - name: Build gem
28
+ run: gem build *.gemspec
29
+ - name: Publish to GPR
30
+ run: |
31
+ mkdir -p $HOME/.gem
32
+ touch $HOME/.gem/credentials
33
+ chmod 0600 $HOME/.gem/credentials
34
+ printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
35
+ gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem
36
+ env:
37
+ GEM_HOST_API_KEY: "Bearer ${{secrets.GH_GEM_HOST_TOKEN}}"
38
+ OWNER: ${{ github.repository_owner }}
39
+ - name: Debug Git State
40
+ run: |
41
+ git status
42
+ git diff
43
+ git ls-files --others --exclude-standard
44
+ - name: Publish to RubyGems
45
+ uses: rubygems/release-gem@v1
data/.gitignore ADDED
@@ -0,0 +1,61 @@
1
+ # Created by https://www.toptal.com/developers/gitignore/api/ruby
2
+ # Edit at https://www.toptal.com/developers/gitignore?templates=ruby
3
+
4
+ ### Ruby ###
5
+ *.gem
6
+ *.rbc
7
+ /.config
8
+ /coverage/
9
+ /InstalledFiles
10
+ /pkg/
11
+ /spec/reports/
12
+ /spec/examples.txt
13
+ /test/tmp/
14
+ /test/version_tmp/
15
+ /tmp/
16
+
17
+ # Used by dotenv library to load environment variables.
18
+ # .env
19
+
20
+ # Ignore Byebug command history file.
21
+ .byebug_history
22
+
23
+ ## Specific to RubyMotion:
24
+ .dat*
25
+ .repl_history
26
+ build/
27
+ *.bridgesupport
28
+ build-iPhoneOS/
29
+ build-iPhoneSimulator/
30
+
31
+ ## Specific to RubyMotion (use of CocoaPods):
32
+ #
33
+ # We recommend against adding the Pods directory to your .gitignore. However
34
+ # you should judge for yourself, the pros and cons are mentioned at:
35
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
36
+ # vendor/Pods/
37
+
38
+ ## Documentation cache and generated files:
39
+ /.yardoc/
40
+ /_yardoc/
41
+ /doc/
42
+ /rdoc/
43
+
44
+ ## Environment normalization:
45
+ /.bundle/
46
+ /vendor/bundle
47
+ /lib/bundler/man/
48
+
49
+ # for a library or gem, you might want to ignore these files since the code is
50
+ # intended to run in multiple environments; otherwise, check them in:
51
+ # Gemfile.lock
52
+ # .ruby-version
53
+ # .ruby-gemset
54
+
55
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
56
+ .rvmrc
57
+
58
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
59
+ # .rubocop-https?--*
60
+
61
+ # End of https://www.toptal.com/developers/gitignore/api/ruby
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ ## [0.1.12] - 2025-05-26
4
+
5
+ - debug gem publishing
6
+
7
+ ## [0.1.11] - 2025-05-26
8
+
9
+ - debug gem publishing
10
+
11
+ ## [0.1.10] - 2025-05-26
12
+
13
+ - debug gem publishing
14
+
15
+ ## [0.1.9] - 2025-05-26
16
+
17
+ - debug gem publishing
18
+
19
+ ## [0.1.8] - 2025-05-26
20
+
21
+ - update gem publishing
22
+
23
+ ## [0.1.7] - 2025-05-26
24
+
25
+ - update gem publishing
26
+
27
+ ## [0.1.6] - 2025-05-26
28
+
29
+ - update gem publishing
30
+
31
+ ## [0.1.5] - 2025-05-26
32
+
33
+ - Bumping version for CI
34
+
35
+ ## [0.1.4] - 2025-05-26
36
+
37
+ - Convert ghost html to markdown when importing
38
+
39
+ ## [0.1.3] - 2025-05-25
40
+
41
+ - Bumping version for CI
42
+
43
+
44
+ ## [0.1.2] - 2025-05-25
45
+
46
+ - Bumping version for CI
47
+
48
+ ## [0.1.1] - 2025-05-25
49
+
50
+ - Added ghost-import command to convert Ghost blog exports to willow.camp markdown format
51
+ - Support for converting Ghost exports with proper frontmatter
52
+ - Automatically detect and use the best content format available (markdown, HTML or plaintext)
53
+
54
+ ## [0.1.0] - 2025-05-25
55
+
56
+ - Initial release
57
+ - Support for listing, showing, creating, updating, and deleting posts
58
+ - Support for bulk uploading posts from a directory
59
+ - Support for downloading posts to a file
60
+ - Support for dry-run mode
61
+ - Support for verbose output
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in willow_camp_cli.gemspec
4
+ gemspec
5
+
6
+ gem "rake", "~> 13.0"
7
+ gem "minitest", "~> 5.0"
8
+ gem "webmock", "~> 3.18"
data/Gemfile.lock ADDED
@@ -0,0 +1,53 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ willow_camp_cli (0.1.12)
5
+ colorize (~> 0.8.1)
6
+ fileutils (~> 1.0)
7
+ json (~> 2.0)
8
+ reverse_markdown (~> 2.1)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ addressable (2.8.7)
14
+ public_suffix (>= 2.0.2, < 7.0)
15
+ bigdecimal (3.1.9)
16
+ colorize (0.8.1)
17
+ crack (1.0.0)
18
+ bigdecimal
19
+ rexml
20
+ fileutils (1.7.3)
21
+ hashdiff (1.2.0)
22
+ json (2.12.2)
23
+ mini_portile2 (2.8.9)
24
+ minitest (5.25.5)
25
+ nokogiri (1.18.8)
26
+ mini_portile2 (~> 2.8.2)
27
+ racc (~> 1.4)
28
+ nokogiri (1.18.8-arm64-darwin)
29
+ racc (~> 1.4)
30
+ public_suffix (6.0.2)
31
+ racc (1.8.1)
32
+ rake (13.2.1)
33
+ reverse_markdown (2.1.1)
34
+ nokogiri
35
+ rexml (3.4.1)
36
+ webmock (3.25.1)
37
+ addressable (>= 2.8.0)
38
+ crack (>= 0.3.2)
39
+ hashdiff (>= 0.4.0, < 2.0.0)
40
+
41
+ PLATFORMS
42
+ arm64-darwin-24
43
+ ruby
44
+
45
+ DEPENDENCIES
46
+ bundler (~> 2.0)
47
+ minitest (~> 5.0)
48
+ rake (~> 13.0)
49
+ webmock (~> 3.18)
50
+ willow_camp_cli!
51
+
52
+ BUNDLED WITH
53
+ 2.6.7
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Willow Camp CLI
2
+
3
+ A command-line interface for managing blog posts on a willow.camp.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'willow_camp_cli'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install willow_camp_cli
23
+ ```
24
+ ## Usage
25
+
26
+ ```
27
+ Usage: willow-camp COMMAND [options]
28
+
29
+ Commands:
30
+ list List all posts
31
+ show Show a single post by slug
32
+ create Create a new post from a Markdown file
33
+ update Update an existing post by slug
34
+ delete Delete a post by slug
35
+ upload Bulk upload posts from a directory
36
+ download Download a post to a Markdown file
37
+ ghost-import Import posts from a Ghost export file
38
+ help Show this help message
39
+
40
+ Options:
41
+ -u, --url URL API URL (e.g., https://yourblog.example.com)
42
+ -t, --token TOKEN API Bearer Token
43
+ -d, --directory DIRECTORY Directory containing Markdown files (for upload)
44
+ -f, --file FILE Single Markdown file (for create/update)
45
+ -s, --slug SLUG Post slug (for show/update/delete/download)
46
+ -o, --output FILE Output file (for download)
47
+ -g, --ghost-export FILE Ghost export JSON file
48
+ --output-dir DIRECTORY Output directory for Ghost import (default: 'markdown')
49
+ --dry-run Show what would be done without making actual changes
50
+ -v, --verbose Show detailed output
51
+ -h, --help Show this help message
52
+ ```
53
+
54
+ ## Examples
55
+
56
+ ```sh
57
+ export WILLOW_CAMP_API_TOKEN=<your token>
58
+ ```
59
+
60
+ ### List all posts
61
+
62
+ ```bash
63
+ willow-camp list
64
+ ```
65
+
66
+ ### Show a single post
67
+
68
+ ```bash
69
+ willow-camp show -s my-post-slug
70
+ ```
71
+
72
+ ### Create a new post from a Markdown file
73
+
74
+ ```bash
75
+ willow-camp create -f path/to/post.md
76
+ ```
77
+
78
+ ### Update an existing post
79
+
80
+ ```bash
81
+ willow-camp update -s my-post-slug -f path/to/updated-post.md
82
+ ```
83
+
84
+ ### Delete a post
85
+
86
+ ```bash
87
+ willow-camp delete -s my-post-slug
88
+ ```
89
+
90
+ ### Bulk upload posts from a directory
91
+
92
+ ```bash
93
+ willow-camp upload -d path/to/markdown/files
94
+ ```
95
+
96
+ ### Download a post to a file
97
+
98
+ ```bash
99
+ willow-camp download -s my-post-slug -o path/to/save.md
100
+ ```
101
+
102
+ ### Import posts from a Ghost export file
103
+
104
+ ```bash
105
+ # Just convert to Markdown files
106
+ willow-camp ghost-import --ghost-export path/to/ghost-export.json --output-dir path/to/output
107
+
108
+ # Convert and upload in one step
109
+ willow-camp ghost-import -g path/to/ghost-export.json --output-dir path/to/output
110
+ ```
111
+
112
+ ## Environment Variables
113
+
114
+ You can set default API URL and token using environment variables:
115
+
116
+ ```bash
117
+ export WILLOW_API_URL="https://yourblog.example.com"
118
+ export WILLOW_API_TOKEN="your-api-token"
119
+ ```
120
+
121
+ If these environment variables are set, you don't need to provide the `-u` and `-t` options.
122
+
123
+ ## Development
124
+
125
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
126
+
127
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
128
+
129
+ ## Contributing
130
+
131
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/willow_camp.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require "willow_camp_cli"
4
+
5
+ # You can add fixtures and/or initialization code here to make experimenting
6
+ # with your gem easier. You can also use a different console, if you like.
7
+
8
+ # (If you use this, don't forget to add pry to your Gemfile!)
9
+ # require "pry"
10
+ # Pry.start
11
+
12
+ require "irb"
13
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ require "fileutils"
3
+
4
+ # path to your application root.
5
+ APP_ROOT = File.expand_path("..", __dir__)
6
+
7
+ def system!(*args)
8
+ system(*args) || abort("\n== Command #{args} failed ==")
9
+ end
10
+
11
+ FileUtils.chdir APP_ROOT do
12
+ # This script is a way to set up or update your development environment automatically.
13
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
14
+ # Add necessary setup steps to this file.
15
+
16
+ puts "== Installing dependencies =="
17
+ system! "gem install bundler --conservative"
18
+ system("bundle check") || system!("bundle install")
19
+
20
+ puts "\n== Removing old logs and tempfiles =="
21
+ system! "rm -f log/*"
22
+ system! "rm -rf tmp/cache"
23
+
24
+ puts "\n== Restarting application server =="
25
+ system! "bin/rails restart"
26
+ end
data/exe/willow-camp ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require "willow_camp_cli"
5
+ rescue LoadError
6
+ # Try to load from the local path if the gem is not found
7
+ lib_path = File.expand_path("../../lib", __FILE__)
8
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
9
+ require "willow_camp_cli"
10
+ end
11
+
12
+ WillowCampCLI::CLI.run(ARGV)
@@ -0,0 +1,489 @@
1
+ require "json"
2
+ require "uri"
3
+ require "net/http"
4
+ require "optparse"
5
+ require "pathname"
6
+ require "colorize"
7
+ require "fileutils"
8
+ require "reverse_markdown"
9
+
10
+ module WillowCampCLI
11
+ class CLI
12
+ API_URL = "https://willow.camp/"
13
+ attr_reader :token, :verbose
14
+
15
+ def initialize(options)
16
+ @token = options[:token]
17
+ @directory = options[:directory]
18
+ @dry_run = options[:dry_run]
19
+ @verbose = options[:verbose]
20
+ @slug = options[:slug]
21
+ end
22
+
23
+ # List all posts
24
+ def list_posts
25
+ puts "📋 Listing all posts from #{API_URL}...".blue
26
+
27
+ response = api_request(:get, "/api/posts")
28
+ if response
29
+ posts = JSON.parse(response.body)["posts"]
30
+ if posts.empty?
31
+ puts "No posts found".yellow
32
+ else
33
+ puts "\nFound #{posts.size} post(s):".green
34
+ posts.each do |post|
35
+ puts "- [#{post["id"]}] #{post["slug"]}".cyan
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Show a single post by slug
42
+ def show_post
43
+ return puts "Error: Slug is required".red unless @slug
44
+
45
+ puts "🔍 Fetching post with slug: #{@slug}...".blue
46
+
47
+ response = api_request(:get, "/api/posts/#{@slug}")
48
+ if response
49
+ post = JSON.parse(response.body)["post"]
50
+ puts "\nPost details:".green
51
+ puts "ID: #{post["id"]}".cyan
52
+ puts "Slug: #{post["slug"]}".cyan
53
+ puts "Title: #{post.dig("title")}".cyan
54
+ puts "Published: #{post["published"] || false}".cyan
55
+ puts "Published at: #{post["published_at"] || "Not published"}".cyan
56
+ puts "Tags: #{(post["tag_list"] || []).join(", ")}".cyan
57
+
58
+ if @verbose
59
+ puts "\nContent:".cyan
60
+ puts "-" * 50
61
+ puts post["markdown"]
62
+ puts "-" * 50
63
+ end
64
+ end
65
+ end
66
+
67
+ # Update a post by slug
68
+ def update_post(content)
69
+ return puts "Error: Slug and content are required".red unless @slug && content
70
+
71
+ puts "🔄 Updating post with slug: #{@slug}...".blue
72
+
73
+ if @dry_run
74
+ puts " DRY RUN: Would update post #{@slug}".yellow
75
+ puts " Content preview: #{content[0..100]}...".yellow if @verbose
76
+ return
77
+ end
78
+
79
+ response = api_request(:patch, "/api/posts/#{@slug}", {post: {markdown: content}})
80
+ if response
81
+ post = JSON.parse(response.body)["post"]
82
+ puts "✅ Successfully updated post: #{post["title"]} (#{post["slug"]})".green
83
+ end
84
+ end
85
+
86
+ # Delete a post by slug
87
+ def delete_post
88
+ return puts "Error: Slug is required".red unless @slug
89
+
90
+ puts "🗑️ Deleting post with slug: #{@slug}...".blue
91
+
92
+ if @dry_run
93
+ puts " DRY RUN: Would delete post #{@slug}".yellow
94
+ return
95
+ end
96
+
97
+ response = api_request(:delete, "/api/posts/#{@slug}")
98
+ if response && response.code.to_i == 204
99
+ puts "✅ Successfully deleted post: #{@slug}".green
100
+ end
101
+ end
102
+
103
+ # Upload a single Markdown file
104
+ def upload_file(file_path)
105
+ puts "📤 Uploading #{file_path}...".blue
106
+ content = File.read(file_path)
107
+
108
+ if @dry_run
109
+ puts " DRY RUN: Would upload #{file_path}".yellow
110
+ puts " Content preview: #{content[0..100]}...".yellow if @verbose
111
+ return
112
+ end
113
+
114
+ response = api_request(:post, "/api/posts", {post: {markdown: content}})
115
+ if response
116
+ post = JSON.parse(response.body)["post"]
117
+ puts "✅ Successfully uploaded: #{file_path}".green
118
+ puts "📌 Created post '#{post["title"]}' with slug: #{post["slug"]}".green
119
+ end
120
+ end
121
+
122
+ # Upload all Markdown files from a directory
123
+ def upload_all
124
+ puts "🔍 Looking for Markdown files in #{@directory}...".blue
125
+
126
+ files = find_markdown_files
127
+ if files.empty?
128
+ puts "❌ No Markdown files found in #{@directory}".red
129
+ return
130
+ end
131
+
132
+ puts "📝 Found #{files.size} Markdown file(s)".blue
133
+
134
+ files.each_with_index do |file, index|
135
+ puts "\n[#{index + 1}/#{files.size}] Processing #{file}".cyan
136
+ upload_file(file)
137
+ end
138
+
139
+ puts "\n✅ Operation complete!".green
140
+ end
141
+
142
+ # Download a post to a file
143
+ def download_post(output_path)
144
+ return puts "Error: Slug is required".red unless @slug
145
+
146
+ puts "📥 Downloading post with slug: #{@slug}...".blue
147
+
148
+ response = api_request(:get, "/api/posts/#{@slug}")
149
+ if response
150
+ post = JSON.parse(response.body)["post"]
151
+
152
+ # Use provided output path or generate one based on slug
153
+ output_path ||= "#{@slug}.md"
154
+
155
+ File.write(output_path, post["markdown"])
156
+ puts "✅ Successfully downloaded post to #{output_path}".green
157
+ end
158
+ end
159
+
160
+ # Import posts from a Ghost export file
161
+ def ghost_import(ghost_export_file, output_dir = "markdown")
162
+ return puts "Error: Ghost export file is required".red unless ghost_export_file
163
+ return puts "Error: Ghost export file not found: #{ghost_export_file}".red unless File.exist?(ghost_export_file)
164
+
165
+ puts "🔍 Processing Ghost export file: #{ghost_export_file}...".blue
166
+
167
+ begin
168
+ # Create output directory if it doesn't exist
169
+ FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
170
+
171
+ # Parse JSON export file
172
+ ghost_data = JSON.parse(File.read(ghost_export_file))
173
+
174
+ posts = ghost_data["db"][0]["data"]["posts"].select { |post| post["status"] == "published" }
175
+
176
+ if posts.empty?
177
+ puts "❌ No published posts found in the Ghost export".red
178
+ return
179
+ end
180
+
181
+ puts "Found #{posts.size} published posts".green
182
+
183
+ # Process each post
184
+ processed_count = 0
185
+ posts.each do |post|
186
+ title = post["title"]
187
+ slug = post["slug"]
188
+ published = post["status"] == "published" ? !post["published_at"].nil? : nil
189
+ published_at = post["published_at"]&.split("T")&.first
190
+
191
+ puts "\n[#{processed_count + 1}/#{posts.size}] Processing '#{title}' (#{slug})".cyan
192
+
193
+ # Get content from the most appropriate source
194
+ # First try html, then markdown (for test compatibility), then lexical, then plaintext
195
+ content = nil
196
+
197
+ if post["html"] && !post["html"].empty?
198
+ # Convert HTML to Markdown
199
+ html_content = post["html"]
200
+ content = ReverseMarkdown.convert(html_content)
201
+ source = "html converted to markdown"
202
+ puts " Note: Converting HTML content to markdown".yellow if @verbose
203
+ elsif post["plaintext"] && !post["plaintext"].empty?
204
+ content = post["plaintext"]
205
+ source = "plaintext"
206
+ puts " Note: Using plaintext content (HTML/lexical not available)".yellow if @verbose
207
+ else
208
+ puts " Warning: No content found for post '#{title}'".yellow
209
+ next
210
+ end
211
+
212
+ # Replace Ghost URL placeholders if present
213
+ content = content.gsub(/__GHOST_URL__/, "")
214
+
215
+ # Get tags for this post
216
+ tags = []
217
+ if ghost_data["db"][0]["data"]["posts_tags"]
218
+ post_tags = ghost_data["db"][0]["data"]["posts_tags"].select { |pt| pt["post_id"] == post["id"] }
219
+
220
+ post_tags.each do |pt|
221
+ tag = ghost_data["db"][0]["data"]["tags"].find { |t| t["id"] == pt["tag_id"] }
222
+ tags << tag["name"] if tag
223
+ end
224
+ end
225
+
226
+ # Get feature image
227
+ feature_image = post["feature_image"]
228
+ feature_image&.gsub!(/__GHOST_URL__/, "")
229
+
230
+ # Create markdown file with proper frontmatter
231
+ filename = File.join(output_dir, "#{slug}.md")
232
+
233
+ File.open(filename, "w") do |file|
234
+ file.puts "---"
235
+ file.puts "title: \"#{title}\""
236
+ file.puts "published_at: #{published_at}" if published_at
237
+ file.puts "slug: #{slug}"
238
+ file.puts "published: #{published}" if published
239
+
240
+ # Add meta description if available
241
+ if post["custom_excerpt"] && !post["custom_excerpt"].empty?
242
+ file.puts "meta_description: \"#{post['custom_excerpt']}\""
243
+ end
244
+
245
+ # Add tags if available
246
+ unless tags.empty?
247
+ file.puts "tags:"
248
+ tags.each do |tag|
249
+ file.puts " - #{tag}"
250
+ end
251
+ end
252
+
253
+ file.puts "---"
254
+ file.puts
255
+ file.puts content
256
+ end
257
+
258
+ puts " ✅ Created: #{filename} (from #{source})".green
259
+ processed_count += 1
260
+
261
+ # Upload the post if requested
262
+ if @token && !@dry_run
263
+ upload_file(filename)
264
+ elsif @dry_run
265
+ puts " DRY RUN: Would upload #{filename}".yellow
266
+ end
267
+ end
268
+
269
+ puts "\n✅ Conversion complete! #{processed_count} markdown files created in #{output_dir}/".green
270
+
271
+ rescue JSON::ParserError => e
272
+ puts "❌ Error parsing Ghost export JSON: #{e.message}".red
273
+ rescue => e
274
+ puts "❌ Error processing Ghost export: #{e.message}".red
275
+ puts e.backtrace.join("\n") if @verbose
276
+ end
277
+ end
278
+
279
+ def self.run(args, testing = false)
280
+ command = args.shift
281
+ commands = %w[list show create update delete upload download ghost-import help]
282
+
283
+ unless commands.include?(command)
284
+ puts "Unknown command: #{command}".red
285
+ puts "Available commands: #{commands.join(", ")}"
286
+ return false if testing
287
+ exit(1)
288
+ end
289
+
290
+ # Parse command-line options
291
+ options = {
292
+ token: ENV["WILLOW_CAMP_API_TOKEN"],
293
+ directory: ".",
294
+ file: nil,
295
+ slug: nil,
296
+ output: nil,
297
+ ghost_export: nil,
298
+ output_dir: "markdown",
299
+ dry_run: false,
300
+ verbose: false
301
+ }
302
+
303
+ opt_parser = OptionParser.new do |opts|
304
+ opts.banner = "Usage: willow-camp COMMAND [options]"
305
+ opts.separator ""
306
+ opts.separator "Commands:"
307
+ opts.separator " list List all posts"
308
+ opts.separator " show Show a single post by slug"
309
+ opts.separator " create Create a new post from a Markdown file"
310
+ opts.separator " update Update an existing post by slug"
311
+ opts.separator " delete Delete a post by slug"
312
+ opts.separator " upload Bulk upload posts from a directory"
313
+ opts.separator " download Download a post to a Markdown file"
314
+ opts.separator " ghost-import Import posts from a Ghost export file"
315
+ opts.separator " help Show this help message"
316
+ opts.separator ""
317
+ opts.separator "Options:"
318
+
319
+
320
+
321
+ opts.on("-t", "--token TOKEN", "API Bearer Token") do |token|
322
+ options[:token] = token
323
+ end
324
+
325
+ opts.on("-d", "--directory DIRECTORY", "Directory containing Markdown files (for upload)") do |dir|
326
+ options[:directory] = dir
327
+ end
328
+
329
+ opts.on("-f", "--file FILE", "Single Markdown file (for create/update)") do |file|
330
+ options[:file] = file
331
+ end
332
+
333
+ opts.on("-s", "--slug SLUG", "Post slug (for show/update/delete/download)") do |slug|
334
+ options[:slug] = slug
335
+ end
336
+
337
+ opts.on("-o", "--output FILE", "Output file (for download)") do |file|
338
+ options[:output] = file
339
+ end
340
+
341
+ opts.on("-g", "--ghost-export FILE", "Ghost export JSON file") do |file|
342
+ options[:ghost_export] = file
343
+ end
344
+
345
+ opts.on("--output-dir DIRECTORY", "Output directory for Ghost import (default: 'markdown')") do |dir|
346
+ options[:output_dir] = dir
347
+ end
348
+
349
+ opts.on("--dry-run", "Show what would be done without making actual changes") do
350
+ options[:dry_run] = true
351
+ end
352
+
353
+ opts.on("-v", "--verbose", "Show detailed output") do
354
+ options[:verbose] = true
355
+ end
356
+
357
+ opts.on("-h", "--help", "Show this help message") do
358
+ puts opts
359
+ exit
360
+ end
361
+ end
362
+
363
+ # Special case for help command
364
+ if command == "help"
365
+ puts opt_parser
366
+ exit
367
+ end
368
+
369
+ # Parse the command-line arguments
370
+ opt_parser.parse!(args)
371
+
372
+ # Validate required options for each command
373
+ case command
374
+ when "list"
375
+ # No specific validation needed
376
+ when "show", "delete", "download"
377
+ if !options[:slug]
378
+ puts "Error: Slug is required for #{command} command (use --slug)".red
379
+ exit 1
380
+ end
381
+ when "create"
382
+ if !options[:file]
383
+ puts "Error: File path is required for create command (use --file)".red
384
+ exit 1
385
+ end
386
+ when "update"
387
+ if !options[:slug] || !options[:file]
388
+ puts "Error: Both slug and file are required for update command (use --slug and --file)".red
389
+ exit 1
390
+ end
391
+ when "upload"
392
+ # No specific validation needed beyond the common ones
393
+ when "ghost-import"
394
+ if !options[:ghost_export]
395
+ puts "Error: Ghost export file is required for ghost-import command (use --ghost-export)".red
396
+ exit 1
397
+ end
398
+ end
399
+
400
+ # Common validation for token (except for dry runs and ghost-import when not uploading)
401
+ unless options[:token] || options[:dry_run] || (command == "ghost-import" && !options[:token])
402
+ puts "Error: API token is required (unless using --dry-run)".red
403
+ puts "Try 'willow-camp help' for more information"
404
+ exit 1
405
+ end
406
+
407
+ # Create client and execute command
408
+ begin
409
+ client = new(options)
410
+
411
+ case command
412
+ when "list"
413
+ client.list_posts
414
+ when "show"
415
+ client.show_post
416
+ when "create"
417
+ content = File.read(options[:file])
418
+ client.upload_file(options[:file])
419
+ when "update"
420
+ content = File.read(options[:file])
421
+ client.update_post(content)
422
+ when "delete"
423
+ client.delete_post
424
+ when "upload"
425
+ client.upload_all
426
+ when "download"
427
+ client.download_post(options[:output])
428
+ when "ghost-import"
429
+ client.ghost_import(options[:ghost_export], options[:output_dir])
430
+ end
431
+ rescue => e
432
+ puts "Error: #{e.message}".red
433
+ exit 1
434
+ end
435
+ end
436
+
437
+ private
438
+
439
+ def find_markdown_files
440
+ Dir.glob(File.join(@directory, "**", "*.md"))
441
+ end
442
+
443
+ def api_request(method, endpoint, data = nil)
444
+ uri = URI("#{API_URL}#{endpoint}")
445
+
446
+ http = Net::HTTP.new(uri.host, uri.port)
447
+ http.use_ssl = uri.scheme == "https"
448
+
449
+ case method
450
+ when :get
451
+ request = Net::HTTP::Get.new(uri)
452
+ when :post
453
+ request = Net::HTTP::Post.new(uri)
454
+ when :patch
455
+ request = Net::HTTP::Patch.new(uri)
456
+ when :delete
457
+ request = Net::HTTP::Delete.new(uri)
458
+ else
459
+ puts "❌ Unsupported HTTP method: #{method}".red
460
+ return nil
461
+ end
462
+
463
+ request["Content-Type"] = "application/json"
464
+ request["Authorization"] = "Bearer #{@token}" if @token
465
+ request.body = data.to_json if data
466
+
467
+ if @verbose
468
+ puts "🔗 API Endpoint: #{uri} (#{method.to_s.upcase})".blue
469
+ puts "📄 Request body: #{request.body}" if request.body && @verbose
470
+ end
471
+
472
+ begin
473
+ response = http.request(request)
474
+
475
+ case response.code.to_i
476
+ when 200..299
477
+ response
478
+ else
479
+ puts "❌ API request failed: HTTP #{response.code}".red
480
+ puts "Error: #{response.body}".red
481
+ nil
482
+ end
483
+ rescue => e
484
+ puts "❌ Error making API request: #{e.message}".red
485
+ nil
486
+ end
487
+ end
488
+ end
489
+ end
@@ -0,0 +1,3 @@
1
+ module WillowCampCLI
2
+ VERSION = "0.1.12"
3
+ end
@@ -0,0 +1,6 @@
1
+ require_relative "willow_camp_cli/version"
2
+ require_relative "willow_camp_cli/cli"
3
+
4
+ module WillowCampCLI
5
+ class Error < StandardError; end
6
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.4"
@@ -0,0 +1,35 @@
1
+ require_relative "lib/willow_camp_cli/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "willow_camp_cli"
5
+ spec.version = WillowCampCLI::VERSION
6
+ spec.authors = ["Cassia Scheffer"]
7
+ spec.email = ["cassia@willow.camp"]
8
+
9
+ spec.summary = "Command-line interface for managing blog posts on a Willow Camp "
10
+ spec.description = "A command-line interface for managing blog posts on a Willow Camp, supporting operations like listing, creating, updating, and deleting posts."
11
+ spec.homepage = "https://github.com/cassiascheffer/willow_camp_cli"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
14
+
15
+ spec.metadata["source_code_uri"] = "https://github.com/cassiascheffer/willow_camp_cli"
16
+ spec.metadata["changelog_uri"] = "https://github.com/cassiascheffer/willow_camp_cli/blob/main/CHANGELOG.md"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = "exe"
24
+ spec.executables = ["willow-camp"]
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "colorize", "~> 0.8.1"
28
+ spec.add_dependency "json", "~> 2.0"
29
+ spec.add_dependency "fileutils", "~> 1.0"
30
+ spec.add_dependency "reverse_markdown", "~> 2.1"
31
+
32
+ spec.add_development_dependency "bundler", "~> 2.0"
33
+ spec.add_development_dependency "rake", "~> 13.0"
34
+ spec.add_development_dependency "minitest", "~> 5.0"
35
+ end
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: willow_camp_cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.12
5
+ platform: ruby
6
+ authors:
7
+ - Cassia Scheffer
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: colorize
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.8.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.8.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: fileutils
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: reverse_markdown
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bundler
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: minitest
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '5.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '5.0'
110
+ description: A command-line interface for managing blog posts on a Willow Camp, supporting
111
+ operations like listing, creating, updating, and deleting posts.
112
+ email:
113
+ - cassia@willow.camp
114
+ executables:
115
+ - willow-camp
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - ".github/workflows/gem-push.yml"
120
+ - ".gitignore"
121
+ - ".ruby-version"
122
+ - CHANGELOG.md
123
+ - Gemfile
124
+ - Gemfile.lock
125
+ - README.md
126
+ - Rakefile
127
+ - bin/console
128
+ - bin/setup
129
+ - exe/willow-camp
130
+ - lib/willow_camp_cli.rb
131
+ - lib/willow_camp_cli/cli.rb
132
+ - lib/willow_camp_cli/version.rb
133
+ - mise.toml
134
+ - willow_camp_cli.gemspec
135
+ homepage: https://github.com/cassiascheffer/willow_camp_cli
136
+ licenses:
137
+ - MIT
138
+ metadata:
139
+ source_code_uri: https://github.com/cassiascheffer/willow_camp_cli
140
+ changelog_uri: https://github.com/cassiascheffer/willow_camp_cli/blob/main/CHANGELOG.md
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ required_ruby_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: 2.7.0
149
+ required_rubygems_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ requirements: []
155
+ rubygems_version: 3.6.7
156
+ specification_version: 4
157
+ summary: Command-line interface for managing blog posts on a Willow Camp
158
+ test_files: []