Class: WillowCampCLI::CLI

Inherits:
Object
  • Object
show all
Defined in:
lib/willow_camp_cli/cli.rb

Constant Summary collapse

API_URL =
"https://willow.camp/"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options) ⇒ CLI

Returns a new instance of CLI.



15
16
17
18
19
20
21
# File 'lib/willow_camp_cli/cli.rb', line 15

def initialize(options)
  @token = options[:token]
  @directory = options[:directory]
  @dry_run = options[:dry_run]
  @verbose = options[:verbose]
  @slug = options[:slug]
end

Instance Attribute Details

#tokenObject (readonly)

Returns the value of attribute token.



13
14
15
# File 'lib/willow_camp_cli/cli.rb', line 13

def token
  @token
end

#verboseObject (readonly)

Returns the value of attribute verbose.



13
14
15
# File 'lib/willow_camp_cli/cli.rb', line 13

def verbose
  @verbose
end

Class Method Details

.run(args, testing = false) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/willow_camp_cli/cli.rb', line 279

def self.run(args, testing = false)
  command = args.shift
  commands = %w[list show create update delete upload download ghost-import help]

  unless commands.include?(command)
    puts "Unknown command: #{command}".red
    puts "Available commands: #{commands.join(", ")}"
    return false if testing
    exit(1)
  end

  # Parse command-line options
  options = {
    token: ENV["WILLOW_CAMP_API_TOKEN"],
    directory: ".",
    file: nil,
    slug: nil,
    output: nil,
    ghost_export: nil,
    output_dir: "markdown",
    dry_run: false,
    verbose: false
  }

  opt_parser = OptionParser.new do |opts|
    opts.banner = "Usage: willow-camp COMMAND [options]"
    opts.separator ""
    opts.separator "Commands:"
    opts.separator "  list                List all posts"
    opts.separator "  show                Show a single post by slug"
    opts.separator "  create              Create a new post from a Markdown file"
    opts.separator "  update              Update an existing post by slug"
    opts.separator "  delete              Delete a post by slug"
    opts.separator "  upload              Bulk upload posts from a directory"
    opts.separator "  download            Download a post to a Markdown file"
    opts.separator "  ghost-import        Import posts from a Ghost export file"
    opts.separator "  help                Show this help message"
    opts.separator ""
    opts.separator "Options:"



    opts.on("-t", "--token TOKEN", "API Bearer Token") do |token|
      options[:token] = token
    end

    opts.on("-d", "--directory DIRECTORY", "Directory containing Markdown files (for upload)") do |dir|
      options[:directory] = dir
    end

    opts.on("-f", "--file FILE", "Single Markdown file (for create/update)") do |file|
      options[:file] = file
    end

    opts.on("-s", "--slug SLUG", "Post slug (for show/update/delete/download)") do |slug|
      options[:slug] = slug
    end

    opts.on("-o", "--output FILE", "Output file (for download)") do |file|
      options[:output] = file
    end

    opts.on("-g", "--ghost-export FILE", "Ghost export JSON file") do |file|
      options[:ghost_export] = file
    end

    opts.on("--output-dir DIRECTORY", "Output directory for Ghost import (default: 'markdown')") do |dir|
      options[:output_dir] = dir
    end

    opts.on("--dry-run", "Show what would be done without making actual changes") do
      options[:dry_run] = true
    end

    opts.on("-v", "--verbose", "Show detailed output") do
      options[:verbose] = true
    end

    opts.on("-h", "--help", "Show this help message") do
      puts opts
      exit
    end
  end

  # Special case for help command
  if command == "help"
    puts opt_parser
    exit
  end

  # Parse the command-line arguments
  opt_parser.parse!(args)

  # Validate required options for each command
  case command
  when "list"
    # No specific validation needed
  when "show", "delete", "download"
    if !options[:slug]
      puts "Error: Slug is required for #{command} command (use --slug)".red
      exit 1
    end
  when "create"
    if !options[:file]
      puts "Error: File path is required for create command (use --file)".red
      exit 1
    end
  when "update"
    if !options[:slug] || !options[:file]
      puts "Error: Both slug and file are required for update command (use --slug and --file)".red
      exit 1
    end
  when "upload"
    # No specific validation needed beyond the common ones
  when "ghost-import"
    if !options[:ghost_export]
      puts "Error: Ghost export file is required for ghost-import command (use --ghost-export)".red
      exit 1
    end
  end

  # Common validation for token (except for dry runs and ghost-import when not uploading)
  unless options[:token] || options[:dry_run] || (command == "ghost-import" && !options[:token])
    puts "Error: API token is required (unless using --dry-run)".red
    puts "Try 'willow-camp help' for more information"
    exit 1
  end

  # Create client and execute command
  begin
    client = new(options)

    case command
    when "list"
      client.list_posts
    when "show"
      client.show_post
    when "create"
      content = File.read(options[:file])
      client.upload_file(options[:file])
    when "update"
      content = File.read(options[:file])
      client.update_post(content)
    when "delete"
      client.delete_post
    when "upload"
      client.upload_all
    when "download"
      client.download_post(options[:output])
    when "ghost-import"
      client.ghost_import(options[:ghost_export], options[:output_dir])
    end
  rescue => e
    puts "Error: #{e.message}".red
    exit 1
  end
end

Instance Method Details

#delete_postObject

Delete a post by slug



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/willow_camp_cli/cli.rb', line 87

def delete_post
  return puts "Error: Slug is required".red unless @slug

  puts "šŸ—‘ļø Deleting post with slug: #{@slug}...".blue

  if @dry_run
    puts "  DRY RUN: Would delete post #{@slug}".yellow
    return
  end

  response = api_request(:delete, "/api/posts/#{@slug}")
  if response && response.code.to_i == 204
    puts "āœ… Successfully deleted post: #{@slug}".green
  end
end

#download_post(output_path) ⇒ Object

Download a post to a file



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/willow_camp_cli/cli.rb', line 143

def download_post(output_path)
  return puts "Error: Slug is required".red unless @slug

  puts "šŸ“„ Downloading post with slug: #{@slug}...".blue

  response = api_request(:get, "/api/posts/#{@slug}")
  if response
    post = JSON.parse(response.body)["post"]

    # Use provided output path or generate one based on slug
    output_path ||= "#{@slug}.md"

    File.write(output_path, post["markdown"])
    puts "āœ… Successfully downloaded post to #{output_path}".green
  end
end

#ghost_import(ghost_export_file, output_dir = "markdown") ⇒ Object

Import posts from a Ghost export file



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/willow_camp_cli/cli.rb', line 161

def ghost_import(ghost_export_file, output_dir = "markdown")
  return puts "Error: Ghost export file is required".red unless ghost_export_file
  return puts "Error: Ghost export file not found: #{ghost_export_file}".red unless File.exist?(ghost_export_file)

  puts "šŸ” Processing Ghost export file: #{ghost_export_file}...".blue
  
  begin
    # Create output directory if it doesn't exist
    FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
    
    # Parse JSON export file
    ghost_data = JSON.parse(File.read(ghost_export_file))
    
    posts = ghost_data["db"][0]["data"]["posts"].select { |post| post["status"] == "published" }
    
    if posts.empty?
      puts "āŒ No published posts found in the Ghost export".red
      return
    end
    
    puts "Found #{posts.size} published posts".green
    
    # Process each post
    processed_count = 0
    posts.each do |post|
      title = post["title"]
      slug = post["slug"]
      published = post["status"] == "published" ? !post["published_at"].nil? : nil
      published_at = post["published_at"]&.split("T")&.first
      
      puts "\n[#{processed_count + 1}/#{posts.size}] Processing '#{title}' (#{slug})".cyan
      
      # Get content from the most appropriate source
      # First try html, then markdown (for test compatibility), then lexical, then plaintext
      content = nil
      
      if post["html"] && !post["html"].empty?
        # Convert HTML to Markdown
        html_content = post["html"]
        content = ReverseMarkdown.convert(html_content)
        source = "html converted to markdown"
        puts "  Note: Converting HTML content to markdown".yellow if @verbose
      elsif post["plaintext"] && !post["plaintext"].empty?
        content = post["plaintext"]
        source = "plaintext"
        puts "  Note: Using plaintext content (HTML/lexical not available)".yellow if @verbose
      else
        puts "  Warning: No content found for post '#{title}'".yellow
        next
      end
      
      # Replace Ghost URL placeholders if present
      content = content.gsub(/__GHOST_URL__/, "")
      
      # Get tags for this post
      tags = []
      if ghost_data["db"][0]["data"]["posts_tags"]
         = ghost_data["db"][0]["data"]["posts_tags"].select { |pt| pt["post_id"] == post["id"] }
        
        .each do |pt|
          tag = ghost_data["db"][0]["data"]["tags"].find { |t| t["id"] == pt["tag_id"] }
          tags << tag["name"] if tag
        end
      end
      
      # Get feature image
      feature_image = post["feature_image"]
      feature_image&.gsub!(/__GHOST_URL__/, "")
      
      # Create markdown file with proper frontmatter
      filename = File.join(output_dir, "#{slug}.md")
      
      File.open(filename, "w") do |file|
        file.puts "---"
        file.puts "title: \"#{title}\""
        file.puts "published_at: #{published_at}" if published_at
        file.puts "slug: #{slug}"
        file.puts "published: #{published}" if published
        
        # Add meta description if available
        if post["custom_excerpt"] && !post["custom_excerpt"].empty?
          file.puts "meta_description: \"#{post['custom_excerpt']}\""
        end
        
        # Add tags if available
        unless tags.empty?
          file.puts "tags:"
          tags.each do |tag|
            file.puts "  - #{tag}"
          end
        end
        
        file.puts "---"
        file.puts
        file.puts content
      end
      
      puts "  āœ… Created: #{filename} (from #{source})".green
      processed_count += 1
      
      # Upload the post if requested
      if @token && !@dry_run
        upload_file(filename)
      elsif @dry_run
        puts "  DRY RUN: Would upload #{filename}".yellow
      end
    end
    
    puts "\nāœ… Conversion complete! #{processed_count} markdown files created in #{output_dir}/".green
    
  rescue JSON::ParserError => e
    puts "āŒ Error parsing Ghost export JSON: #{e.message}".red
  rescue => e
    puts "āŒ Error processing Ghost export: #{e.message}".red
    puts e.backtrace.join("\n") if @verbose
  end
end

#list_postsObject

List all posts



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/willow_camp_cli/cli.rb', line 24

def list_posts
  puts "šŸ“‹ Listing all posts from #{API_URL}...".blue

  response = api_request(:get, "/api/posts")
  if response
    posts = JSON.parse(response.body)["posts"]
    if posts.empty?
      puts "No posts found".yellow
    else
      puts "\nFound #{posts.size} post(s):".green
      posts.each do |post|
        puts "- [#{post["id"]}] #{post["slug"]}".cyan
      end
    end
  end
end

#show_postObject

Show a single post by slug



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/willow_camp_cli/cli.rb', line 42

def show_post
  return puts "Error: Slug is required".red unless @slug

  puts "šŸ” Fetching post with slug: #{@slug}...".blue

  response = api_request(:get, "/api/posts/#{@slug}")
  if response
    post = JSON.parse(response.body)["post"]
    puts "\nPost details:".green
    puts "ID: #{post["id"]}".cyan
    puts "Slug: #{post["slug"]}".cyan
    puts "Title: #{post.dig("title")}".cyan
    puts "Published: #{post["published"] || false}".cyan
    puts "Published at: #{post["published_at"] || "Not published"}".cyan
    puts "Tags: #{(post["tag_list"] || []).join(", ")}".cyan

    if @verbose
      puts "\nContent:".cyan
      puts "-" * 50
      puts post["markdown"]
      puts "-" * 50
    end
  end
end

#update_post(content) ⇒ Object

Update a post by slug



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/willow_camp_cli/cli.rb', line 68

def update_post(content)
  return puts "Error: Slug and content are required".red unless @slug && content

  puts "šŸ”„ Updating post with slug: #{@slug}...".blue

  if @dry_run
    puts "  DRY RUN: Would update post #{@slug}".yellow
    puts "  Content preview: #{content[0..100]}...".yellow if @verbose
    return
  end

  response = api_request(:patch, "/api/posts/#{@slug}", {post: {markdown: content}})
  if response
    post = JSON.parse(response.body)["post"]
    puts "āœ… Successfully updated post: #{post["title"]} (#{post["slug"]})".green
  end
end

#upload_allObject

Upload all Markdown files from a directory



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/willow_camp_cli/cli.rb', line 123

def upload_all
  puts "šŸ” Looking for Markdown files in #{@directory}...".blue

  files = find_markdown_files
  if files.empty?
    puts "āŒ No Markdown files found in #{@directory}".red
    return
  end

  puts "šŸ“ Found #{files.size} Markdown file(s)".blue

  files.each_with_index do |file, index|
    puts "\n[#{index + 1}/#{files.size}] Processing #{file}".cyan
    upload_file(file)
  end

  puts "\nāœ… Operation complete!".green
end

#upload_file(file_path) ⇒ Object

Upload a single Markdown file



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/willow_camp_cli/cli.rb', line 104

def upload_file(file_path)
  puts "šŸ“¤ Uploading #{file_path}...".blue
  content = File.read(file_path)

  if @dry_run
    puts "  DRY RUN: Would upload #{file_path}".yellow
    puts "  Content preview: #{content[0..100]}...".yellow if @verbose
    return
  end

  response = api_request(:post, "/api/posts", {post: {markdown: content}})
  if response
    post = JSON.parse(response.body)["post"]
    puts "āœ… Successfully uploaded: #{file_path}".green
    puts "šŸ“Œ Created post '#{post["title"]}' with slug: #{post["slug"]}".green
  end
end