Module: Scriptorium::Helpers

Includes:
Exceptions
Included in:
API, BannerSVG, Post, Reddit, Repo, Repo, StandardFiles, Theme, Theme, View, Widget
Defined in:
lib/scriptorium/helpers.rb,
lib/skeleton.rb

Overview

Helpers

Instance Method Summary collapse

Methods included from Exceptions

#make_exception

Instance Method Details

#add_post_to_state_file(file_path, post_id) ⇒ Object



371
372
373
374
375
# File 'lib/scriptorium/helpers.rb', line 371

def add_post_to_state_file(file_path, post_id)
  post_ids = read_post_state_file(file_path)
  post_ids << post_id unless post_ids.include?(post_id)
  write_post_state_file(file_path, post_ids)
end

#cf_time(t1, t2) ⇒ Object



343
344
345
346
347
348
349
# File 'lib/scriptorium/helpers.rb', line 343

def cf_time(t1, t2)
  t1 = t1.split(/- :/, 6)
  t2 = t2.split(/- :/, 6)
  t1 = Time.new(*t1)
  t2 = Time.new(*t2)
  t1 <=> t2
end

#change_config(file_path, target_key, new_value) ⇒ Object



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/scriptorium/helpers.rb', line 209

def change_config(file_path, target_key, new_value)
  pattern = /
    ^(?<leading>\s*#{Regexp.escape(target_key)}\s+)  # key and spacing
    (?<old_value>[^\#]*?)                            # value (non-greedy up to comment)
    (?<trailing>\s*)                                 # trailing space
    (?<comment>\#.*)?$                               # optional comment
  /x

  lines = read_file(file_path, lines: true)
  updated_lines = lines.map do |line|
    if match = pattern.match(line)
      leading  = match[:leading]
      trailing = match[:trailing]
      comment  = match[:comment] || ''
      "#{leading}#{new_value}#{trailing}#{comment}\n"
    else
      line
    end
  end

  write_file(file_path, updated_lines.join)
end

#clean_slugify(title) ⇒ Object



242
243
244
245
246
247
248
249
250
# File 'lib/scriptorium/helpers.rb', line 242

def clean_slugify(title)
  return "title-is-missing" if title.nil?
  
  slug = title.downcase.strip
             .gsub(/[^a-z0-9\s_-]/, '')  # remove punctuation
             .gsub(/[\s_-]+/, '-')       # replace spaces and underscores with hyphen
             .gsub(/^-+|-+$/, '')        # trim leading/trailing hyphens
  slug
end

#copy_gem_asset_to_user(asset_name, target_dir = "assets") ⇒ Object



493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/scriptorium/helpers.rb', line 493

def copy_gem_asset_to_user(asset_name, target_dir = "assets")
  gem_spec = Gem.loaded_specs['scriptorium']
  if gem_spec
    gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{asset_name}"
    if File.exist?(gem_asset_path)
      # Create target directory if it doesn't exist
      FileUtils.mkdir_p(target_dir) unless Dir.exist?(target_dir)
      
      # Copy the asset
      target_path = "#{target_dir}/#{File.basename(asset_name)}"
      FileUtils.cp(gem_asset_path, target_path)
      return target_path
    end
  end
  nil
rescue => e
  # If gem lookup fails, return nil
  nil
end

#copy_to_clipboard(text) ⇒ Object

Clipboard helper methods



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/scriptorium/helpers.rb', line 514

def copy_to_clipboard(text)
  begin
    require 'clipboard'
    Clipboard.copy(text)
    true
  rescue LoadError => e
    # Fallback to system commands if clipboard gem not available
    case RbConfig::CONFIG['host_os']
    when /darwin/     # macOS
      system("echo '#{text}' | pbcopy")
    when /linux/      # Linux
      system("echo '#{text}' | xclip -selection clipboard")
    when /mswin|mingw|cygwin/  # Windows
      system("echo '#{text}' | clip")
    else
      puts "Clipboard not supported on this OS"
      false
    end
  rescue => e
    puts "Failed to copy to clipboard: #{e.message}"
    false
  end
end

#d4(num) ⇒ Object



46
47
48
# File 'lib/scriptorium/helpers.rb', line 46

def d4(num)
  "%04d" % num
end

#escape_html(str) ⇒ Object



312
313
314
315
316
317
318
# File 'lib/scriptorium/helpers.rb', line 312

def escape_html(str)
  str.gsub(/&/, '&amp;')
     .gsub(/</, '&lt;')
     .gsub(/>/, '&gt;')
     .gsub(/"/, '&quot;')
     .gsub(/'/, '&#39;')
end

#find_asset(base_dir, asset_name) ⇒ Object

Helper method to find asset recursively in a directory



389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/scriptorium/helpers.rb', line 389

def find_asset(base_dir, asset_name)
  # First try exact path
  exact_path = "#{base_dir}/#{asset_name}"
  return exact_path if File.exist?(exact_path)
  
  # Then search recursively
  Dir.glob("#{base_dir}/**/*").each do |file|
    next unless File.file?(file)
    next unless File.basename(file) == File.basename(asset_name)
    return file
  end
  
  nil
end

#format_date(format, day = Time.now.to_date) ⇒ Object



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/scriptorium/helpers.rb', line 565

def format_date(format, day = Time.now.to_date)
  # Handle nil dates
  return format if day.nil?
  
  # Handle string dates by parsing them
  if day.is_a?(String)
    begin
      day = Date.parse(day)
    rescue Date::Error
      return format # Return original format if date is invalid
    end
  end
  
  # Parse the format string and replace tokens
  result = format.dup
  
  # Month name vs number
  result.gsub!(/\bmonth\b/, day.strftime("%B"))
  result.gsub!(/\bmm\b/, day.strftime("%m"))
  
  # Day ordinal vs number - use smart padding
  result.gsub!(/\bday\b/, ordinalize(day.day))
  result.gsub!(/\bdd\b/) { smart_pad_day(day.day, result, $~.offset(0)[0]) }
  
  # Year formats
  result.gsub!(/\byyyy\b/, day.strftime("%Y"))
  result.gsub!(/\byy\b/, day.strftime("%y"))
  
  # Line breaks - handle spaces around break
  result.gsub!(/\s+break\s+/, "<br>")
  result.gsub!(/\s+break\b/, "<br>")
  result.gsub!(/\bbreak\s+/, "<br>")
  result.gsub!(/\bbreak\b/, "<br>")
  
  result
end

#generate_missing_asset_svg(filename, width: 200, height: 150) ⇒ Object



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/scriptorium/helpers.rb', line 437

def generate_missing_asset_svg(filename, width: 200, height: 150)
  # Truncate filename if too long for display
  display_name = filename.length > 20 ? filename[0..16] + "..." : filename
  
  # Generate SVG with broken image icon and filename
  svg = <<~SVG
    <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
      <!-- Background -->
      <rect fill="#f8f9fa" stroke="#ddd" stroke-width="1" width="#{width}" height="#{height}" rx="4"/>
      
      <!-- Broken image icon -->
      <g transform="translate(#{width/2}, #{height/2 - 20})">
        <!-- Image frame -->
        <rect x="-15" y="-10" width="30" height="20" fill="none" stroke="#999" stroke-width="1"/>
        <!-- Broken corner -->
        <path d="M 15 -10 L 25 -20 M 15 -10 L 25 0" stroke="#999" stroke-width="1" fill="none"/>
        <!-- Image icon -->
        <rect x="-12" y="-7" width="24" height="14" fill="#e9ecef"/>
        <circle cx="-5" cy="-2" r="2" fill="#999"/>
        <polygon points="-8,8 -2,2 2,6 8,0" fill="#999"/>
      </g>
      
      <!-- Filename -->
      <text x="#{width/2}" y="#{height/2 + 15}" text-anchor="middle" fill="#666" font-family="Arial, sans-serif" font-size="11">
        #{escape_html(display_name)}
      </text>
      
      <!-- "Asset not found" message -->
      <text x="#{width/2}" y="#{height/2 + 30}" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="9">
        Asset not found
      </text>
    </svg>
  SVG
  
  svg.strip
end

#get_asset_path(name) ⇒ Object



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/scriptorium/helpers.rb', line 404

def get_asset_path(name)
  if Scriptorium::Repo.testing
    # Development/testing: Check dev_assets first, then local assets
    dev_asset = find_asset("dev_assets", name)
    return dev_asset if dev_asset
    
    local_asset = find_asset("assets", name)
    return local_asset if local_asset
    
    raise AssetNotFound(name)
  else  # Production
    # Production: Check user assets first, then gem assets
    
    # Check user assets first (highest priority) - recursively
    user_asset = find_asset("assets", name)
    return user_asset if user_asset
    
    # Then check gem assets (fallback) - recursively
    begin
      gem_spec = Gem.loaded_specs['scriptorium']
      if gem_spec
        gem_asset = find_asset("#{gem_spec.full_gem_path}/assets", name)
        return gem_asset if gem_asset
      end
    rescue => e
      # If gem lookup fails, continue without gem assets
    end
    
    # Asset not found
    raise AssetNotFound(name)
  end
end

#get_from_clipboardObject



538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
# File 'lib/scriptorium/helpers.rb', line 538

def get_from_clipboard
  begin
    require 'clipboard'
    Clipboard.paste
  rescue LoadError => e
    # Fallback to system commands if clipboard gem not available
    case RbConfig::CONFIG['host_os']
    when /darwin/     # macOS
      `pbpaste`
    when /linux/      # Linux
      `xclip -selection clipboard -o`
    when /mswin|mingw|cygwin/  # Windows
      `powershell -command "Get-Clipboard"`
    else
      puts "Clipboard not supported on this OS"
      nil
    end
  rescue => e
    puts "Failed to read from clipboard: #{e.message}"
    nil
  end
end

#getvars(file) ⇒ Object



32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/scriptorium/helpers.rb', line 32

def getvars(file)
  lines = read_file(file, lines: true)
  lines.map! {|line| line.sub(/# .*$/, "").strip }
  lines.reject! {|line| line.empty? }
  vhash = Hash.new("")
  lines.each do |line|
    var, val = line.split(" ", 2)
    # Fix: treat nil values as empty strings
    val = "" if val.nil?
    vhash[var.to_sym] = val
  end
  vhash
end

#list_gem_assetsObject



474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/scriptorium/helpers.rb', line 474

def list_gem_assets
  assets = []
  gem_spec = Gem.loaded_specs['scriptorium']
  if gem_spec
    gem_assets_dir = "#{gem_spec.full_gem_path}/assets"
    if Dir.exist?(gem_assets_dir)
      Dir.glob("#{gem_assets_dir}/**/*").each do |file|
        next unless File.file?(file)
        relative_path = file.sub("#{gem_assets_dir}/", "")
        assets << relative_path
      end
    end
  end
  assets.sort
rescue => e
  # If gem lookup fails, return empty array
  []
end

#make_dir(dir, create_parents = false) ⇒ Object

Raises:

  • (DirectoryPathNil)


98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/scriptorium/helpers.rb', line 98

def make_dir(dir, create_parents = false)
  # Input validation
      raise DirectoryPathNil if dir.nil?

  raise DirectoryPathEmpty if dir.to_s.strip.empty?
  
  # Create parent directories if requested
  if create_parents
    FileUtils.mkdir_p(dir)
  else
    # Create single directory with error handling
    begin
      Dir.mkdir(dir)
    rescue Errno::ENOSPC => e
      raise DirectoryDiskFull(dir, e.message)
    rescue Errno::EACCES => e
      raise DirectoryPermissionDenied(dir, e.message)
    rescue Errno::ENOENT => e
      raise DirectoryParentNotFound(dir, e.message)
    rescue Errno::EEXIST => e
      # Directory already exists - this is usually not an error
      # But we could make this configurable if needed
    rescue => e
      raise CreateDirectoryError(dir, e.message)
    end
  end
end

#make_tree(base, text) ⇒ Object



270
271
272
273
274
275
276
277
278
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
# File 'lib/scriptorium/helpers.rb', line 270

def make_tree(base, text)
  lines = text.split("\n").map(&:chomp)
  lines.each {|line| line.gsub!(/ *#.*$/, "") }
  entries = []

  # Always throw away the first line (for backward compatibility)
  lines.shift
  
  # Create the base directory and start stack there
  make_dir(base) unless File.exist?(base)
  stack = [base]

  # Parse all lines as structure
  lines.each do |line|
    if (i = line.index(/ [a-zA-Z0-9_.]/))
      name = line[(i + 1)..-1]
      level = i / 4
    else
      name = line.strip
      level = 0
    end
    entries << [level, name]
  end

  entries.each do |level, name|
    stack = stack[0..level]
    full_path = File.join(stack.last, name)

    if name.end_with?("/")
      make_dir(full_path) unless File.exist?(full_path)
      stack << full_path
    else
      write_file(full_path, "Empty file generated at #{Time.now}")
    end
  end
end

#need(type, path, exception_class = RuntimeError) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/scriptorium/helpers.rb', line 143

def need(type, path, exception_class = RuntimeError)
  # Input validation
      raise RequirePathNil(type) if path.nil?

  raise RequirePathEmpty(type) if path.to_s.strip.empty?
  
  # Check if file/directory exists
  exists = case type
           when :file
             File.exist?(path)
           when :dir
             Dir.exist?(path)
           else
             raise InvalidType(type)
           end
  
  unless exists
    raise RequiredFileNotFound(type, path) if exception_class == RuntimeError
    
    # Exception class - try to call it as a method first, then as constructor
    raise exception_class.call(path) if exception_class.respond_to?(:call)
    raise exception_class.new(path)
  end
  
  path
end

#parse_commented_file(file_path) ⇒ Object



332
333
334
335
336
337
338
339
340
341
# File 'lib/scriptorium/helpers.rb', line 332

def parse_commented_file(file_path)
  config = {}
  read_commented_file(file_path).each do |line|
    if line.include?(' ')
      key, value = line.split(/\s+/, 2)
      config[key] = config[key.to_sym] = value
    end
  end
  config
end

#post_compare(a, b) ⇒ Object

Post comparison for sorting (uses post.date which handles fallback)



23
24
25
26
27
28
29
30
# File 'lib/scriptorium/helpers.rb', line 23

def post_compare(a, b)
  # Primary sort: date (newest first)
  date_compare = b.date <=> a.date
  return date_compare unless date_compare == 0
  
  # Secondary sort: post number (ascending) for stable ordering when dates are identical
  a.num <=> b.num
end

#post_in_state_file?(file_path, post_id) ⇒ Boolean

Returns:

  • (Boolean)


383
384
385
386
# File 'lib/scriptorium/helpers.rb', line 383

def post_in_state_file?(file_path, post_id)
  post_ids = read_post_state_file(file_path)
  post_ids.include?(post_id)
end

#read_commented_file(file_path) ⇒ Object



320
321
322
323
324
325
326
327
328
329
330
# File 'lib/scriptorium/helpers.rb', line 320

def read_commented_file(file_path)
  return [] unless File.exist?(file_path)
  lines = read_file(file_path, lines: true)  # Read file and remove newline characters
  lines.reject! do |line|    # Remove empty lines and comments
    line.strip.empty? || line.strip.start_with?("#")
  end
  lines.map! do |line|       # Strip trailing comments + preceding spaces
    line.sub(/# .*$/, "").strip  
  end
  lines  # Return cleaned lines
end

#read_file(file, options = {}) ⇒ Object

Raises:

  • (ReadFilePathNil)


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
# File 'lib/scriptorium/helpers.rb', line 170

def read_file(file, options = {})
  # Input validation
      raise ReadFilePathNil if file.nil?

  raise ReadFilePathEmpty if file.to_s.strip.empty?
  
  # Handle missing file with fallback
  if options[:missing_fallback]
    return options[:missing_fallback] unless File.exist?(file)
  end
  
  # Read the file with error handling
  begin
    if options[:lines]
      # Read as lines
      if options[:chomp]
        File.readlines(file, chomp: true)
      else
        File.readlines(file)
      end
    else
      # Read as content
      File.read(file)
    end
  rescue Errno::ENOENT => e
    if options[:missing_fallback]
      return options[:missing_fallback]
    else
      raise ReadFileNotFound(file, e.message)
    end
  rescue Errno::EACCES => e
            raise ReadFilePermissionDenied(file, e.message)
  rescue => e
            raise ReadFileError(file, e.message)
  end
end

#read_post_state_file(file_path) ⇒ Object

Post state management helpers



353
354
355
356
357
358
# File 'lib/scriptorium/helpers.rb', line 353

def read_post_state_file(file_path)
  return [] unless File.exist?(file_path)
  content = read_file(file_path)
  return [] if content.strip.empty?
  content.lines.map { |line| line.strip.to_i }.reject { |id| id == 0 }
end

#remove_post_from_state_file(file_path, post_id) ⇒ Object



377
378
379
380
381
# File 'lib/scriptorium/helpers.rb', line 377

def remove_post_from_state_file(file_path, post_id)
  post_ids = read_post_state_file(file_path)
  post_ids.delete(post_id)
  write_post_state_file(file_path, post_ids)
end

#see(label, var) ⇒ Object



266
267
268
# File 'lib/scriptorium/helpers.rb', line 266

def see(label, var)
  puts "#{label} = \n<<<\n#{var}\n>>>"
end

#see_file(file) ⇒ Object

Really from TestHelpers



260
261
262
263
264
# File 'lib/scriptorium/helpers.rb', line 260

def see_file(file)   # Really from TestHelpers
  puts "----- File: #{file}"
  system!("cat #{file}", "displaying file contents")
  puts "-----"
end

#slugify(id, title) ⇒ Object



232
233
234
235
236
237
238
239
240
# File 'lib/scriptorium/helpers.rb', line 232

def slugify(id, title)
  return "#{d4(id)}-untitled" if title.nil? || title.strip.empty?
  
  slug = title.downcase.strip
             .gsub(/[^a-z0-9\s_-]/, '')  # remove punctuation
             .gsub(/[\s_-]+/, '-')       # replace spaces and underscores with hyphen
             .gsub(/^-+|-+$/, '')        # trim leading/trailing hyphens
  "#{d4(id)}-#{slug}"
end

#substitute(obj, text) ⇒ Object



307
308
309
310
# File 'lib/scriptorium/helpers.rb', line 307

def substitute(obj, text)
  vars = obj.is_a?(Hash) ? obj : obj.vars
  text % vars
end

#system!(command, description = nil) ⇒ Object

Raises:

  • (CommandNil)


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/scriptorium/helpers.rb', line 126

def system!(command, description = nil)
  # Input validation
      raise CommandNil if command.nil?

  raise CommandEmpty if command.to_s.strip.empty?
  
  # Execute command with error handling
  success = system(command)
  
  unless success
    desc = description ? " (#{description})" : ""
    raise CommandFailedWithDesc(desc, command)
  end
  
  success
end

#tty(str) ⇒ Object



561
562
563
# File 'lib/scriptorium/helpers.rb', line 561

def tty(str)
  File.open('/dev/tty', 'w') { |f| f.puts str }
end

#view_dir(name) ⇒ Object



50
51
52
# File 'lib/scriptorium/helpers.rb', line 50

def view_dir(name)
  @root/:views/name
end

#write_file(file, content, empty: false) ⇒ Object

Raises:

  • (FilePathNil)


54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/scriptorium/helpers.rb', line 54

def write_file(file, content, empty: false)
  # Input validation
              raise FilePathNil if file.nil?

      raise FilePathEmpty if file.to_s.strip.empty?
  
  # Handle empty content if empty: true is specified
  if empty && (content.nil? || content.to_s.strip.empty?)
    # If file exists, do nothing; if it doesn't exist, touch it
    unless File.exist?(file)
      FileUtils.mkdir_p(File.dirname(file))
      FileUtils.touch(file)
    end
    return
  end
  
  # Ensure parent directory exists
  FileUtils.mkdir_p(File.dirname(file))
  
  # Write the file with error handling
  begin
    File.open(file, "w") do |f|
      f.puts content
    end
  rescue Errno::ENOSPC => e
            raise FileDiskFull(file, e.message)
  rescue Errno::EACCES => e
            raise FilePermissionDenied(file, e.message)
  rescue Errno::ENOENT => e
            raise FileDirectoryNotFound(file, e.message)
  rescue => e
    raise WriteFileError(file, e.message)
  end
end

#write_file!(file, *lines, empty: false) ⇒ Object



89
90
91
92
93
94
95
96
# File 'lib/scriptorium/helpers.rb', line 89

def write_file!(file, *lines, empty: false)
  # Convert nil values to empty strings for proper joining
  processed_lines = lines.map { |line| line.nil? ? "" : line.to_s }
  content = processed_lines.join("\n")
  # Always add a newline at the end to ensure there's an empty line
  content += "\n"
  write_file(file, content, empty: empty)
end

#write_post_state_file(file_path, post_ids) ⇒ Object



360
361
362
363
364
365
366
367
368
369
# File 'lib/scriptorium/helpers.rb', line 360

def write_post_state_file(file_path, post_ids)
  if post_ids.empty?
    # Write empty file (empty = all posts are in that state)
    # For state files, empty means "all posts are in this state", so we need to clear the file
    write_file(file_path, "")
  else
    content = post_ids.sort.uniq.join("\n") + "\n"
    write_file(file_path, content)
  end
end

#ymdhmsObject



252
253
254
# File 'lib/scriptorium/helpers.rb', line 252

def ymdhms
  Time.now.strftime("%Y-%m-%d %H:%M:%S")
end

#ymdhms_filenameObject



256
257
258
# File 'lib/scriptorium/helpers.rb', line 256

def ymdhms_filename
  Time.now.strftime("%Y%m%d-%H%M%S")
end