appydave-tools 0.38.0 → 0.40.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca0b626390693cf429dec2da61dca877811720aed92dd110849a826bbe53f3fe
4
- data.tar.gz: fba4176a5c363b7578409d63a6119f495138d637c9e03f309830317fb351ba2a
3
+ metadata.gz: 9610c23fa022740f9082e2bd0086bf7727cfe96437309b099a1bc5fbe55f58ea
4
+ data.tar.gz: d556ee4ced131cfc9f7b8244edf9c2c464d5d3692a669cb0f7102831fccf1689
5
5
  SHA512:
6
- metadata.gz: 7fac67c172b7153c59aee5d9a0d845c9bac1cc77040068472e125a850295435f625626addd49becded1b52e81db53d6d206269610c807e5a318a71e3647a0b08
7
- data.tar.gz: 936903137cf045758a3d131a3254e6ff44eca468455b546e5c98b55eb516242a7d861139e20cc75331e41cee256a3ff34a233c9ba2d6d67833ac8f1a245d570a
6
+ metadata.gz: f337f930defe037888a8ebc8dd19f6562519b39170b33a8944e68cc050059a65902d41c788d6cafa2c6132b2d2ee6f408ad54a420ce68c1bb74fb392ab9dc716
7
+ data.tar.gz: f7f0933afa44c8e3a79aa3ceb6b53db950d0acb9e3b5ba17daf3acc46eafc97afd19071c0e335485d3345c08c9218c2285f5dcc0f74332bd87a3bf136a7dc210
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ # [0.39.0](https://github.com/appydave/appydave-tools/compare/v0.38.0...v0.39.0) (2025-11-21)
2
+
3
+
4
+ ### Features
5
+
6
+ * create BrandResolver class to centralize brand name resolution and eliminate ~30 lines of duplication from Config.expand_brand, fixing case-sensitivity bugs ([d6cce4b](https://github.com/appydave/appydave-tools/commit/d6cce4b0c1df6d7efcd40db52e80534c4014fdb5))
7
+
8
+ # [0.38.0](https://github.com/appydave/appydave-tools/compare/v0.37.0...v0.38.0) (2025-11-21)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * rename FileUtils to FileHelper to avoid Ruby stdlib conflict ([e441b43](https://github.com/appydave/appydave-tools/commit/e441b4356ec40a66f5562e9cf0ec74f562b449b2))
14
+
15
+
16
+ ### Features
17
+
18
+ * add DAM exception hierarchy for consistent error handling ([c885590](https://github.com/appydave/appydave-tools/commit/c885590be721d2b2803fd6aaa7001afc8cdbd285))
19
+ * extract FileUtils module to eliminate 40 lines of duplication ([643748e](https://github.com/appydave/appydave-tools/commit/643748ea583eba3605e1a6ce17e117db18f66c4c))
20
+ * extract GitHelper module to eliminate ~90 lines of git operations duplication across status.rb, repo_status.rb, and repo_push.rb ([f32dc1d](https://github.com/appydave/appydave-tools/commit/f32dc1d54877a9a981878a6e7c203922e50004fe))
21
+
1
22
  # [0.37.0](https://github.com/appydave/appydave-tools/compare/v0.36.0...v0.37.0) (2025-11-21)
2
23
 
3
24
 
@@ -3,7 +3,33 @@
3
3
  module Appydave
4
4
  module Tools
5
5
  module Configuration
6
- # Configuration class for handling multiple configurations
6
+ # Central configuration management for appydave-tools
7
+ #
8
+ # Thread-safe singleton pattern with memoization for registered configurations.
9
+ # Calling `Config.configure` multiple times is safe and idempotent.
10
+ #
11
+ # @example Basic usage
12
+ # Config.configure # Load default configuration (idempotent)
13
+ # Config.settings.video_projects_root # Access settings
14
+ # Config.brands.get_brand('appydave') # Access brands
15
+ #
16
+ # @example DAM module usage pattern
17
+ # # Config.configure called once at module load time
18
+ # # All subsequent calls within DAM classes are no-ops (memoized)
19
+ # def some_method
20
+ # Config.configure # Safe to call - returns immediately if already configured
21
+ # brand = Config.brands.get_brand('appydave')
22
+ # end
23
+ #
24
+ # @example Registered configurations
25
+ # Config.settings # => SettingsConfig instance
26
+ # Config.brands # => BrandsConfig instance
27
+ # Config.channels # => ChannelsConfig instance
28
+ # Config.youtube_automation # => YoutubeAutomationConfig instance
29
+ #
30
+ # @note Configuration instances are created once on first registration and reused
31
+ # for all subsequent accesses. This prevents unnecessary file I/O and ensures
32
+ # consistent state across the application.
7
33
  class Config
8
34
  class << self
9
35
  include KLog::Logging
@@ -12,6 +38,25 @@ module Appydave
12
38
  attr_reader :configurations
13
39
  attr_reader :default_block
14
40
 
41
+ # Load configuration using either provided block or default configuration
42
+ #
43
+ # This method is idempotent and thread-safe. Calling it multiple times
44
+ # has no negative side effects - configurations are memoized on first call.
45
+ #
46
+ # @yield [Config] configuration object for manual setup
47
+ # @return [void]
48
+ # @raise [Error] if no block provided and no default_block set
49
+ #
50
+ # @example With block (manual configuration)
51
+ # Config.configure do |config|
52
+ # config.config_path = '/custom/path'
53
+ # config.register(:settings, SettingsConfig)
54
+ # end
55
+ #
56
+ # @example Without block (uses default_block)
57
+ # Config.set_default { |config| config.register(:settings, SettingsConfig) }
58
+ # Config.configure # Uses default_block
59
+ # Config.configure # Safe to call again - no-op due to memoization
15
60
  def configure
16
61
  if block_given?
17
62
  yield self
@@ -23,6 +68,23 @@ module Appydave
23
68
  ensure_config_directory
24
69
  end
25
70
 
71
+ # Register a configuration class with memoization
72
+ #
73
+ # Creates a single instance of the configuration class on first call.
74
+ # Subsequent calls return the same instance (memoized). This prevents
75
+ # unnecessary file I/O and ensures consistent configuration state.
76
+ #
77
+ # @param key [Symbol] configuration identifier (e.g., :settings, :brands)
78
+ # @param klass [Class] configuration class to instantiate
79
+ # @return [Object] configuration instance
80
+ #
81
+ # @example
82
+ # Config.register(:settings, SettingsConfig)
83
+ # Config.settings # => SettingsConfig instance (created on first access)
84
+ # Config.settings # => Same instance (memoized)
85
+ #
86
+ # @note This method implements lazy initialization - the configuration
87
+ # instance is only created when first accessed, not at registration time.
26
88
  def register(key, klass)
27
89
  @configurations ||= {}
28
90
  # Only create new instance if not already registered (prevents multiple reloads)
@@ -30,10 +92,26 @@ module Appydave
30
92
  end
31
93
 
32
94
  # Reset all configurations (primarily for testing)
95
+ #
96
+ # Clears all memoized configuration instances. Use this in test teardown
97
+ # to ensure each test starts with a clean configuration state.
98
+ #
99
+ # @return [void]
100
+ #
101
+ # @example RSpec usage
102
+ # after { Config.reset }
33
103
  def reset
34
104
  @configurations = nil
35
105
  end
36
106
 
107
+ # Dynamic accessor for registered configurations
108
+ #
109
+ # Provides method-style access to registered configuration instances.
110
+ # Called when accessing Config.settings, Config.brands, etc.
111
+ #
112
+ # @param method_name [Symbol] configuration key
113
+ # @return [Object] configuration instance
114
+ # @raise [Error] if configurations not registered or key not found
37
115
  def method_missing(method_name, *_args)
38
116
  raise Appydave::Tools::Error, 'Configuration has never been registered' if @configurations.nil?
39
117
  raise Appydave::Tools::Error, "Configuration not available: #{method_name}" unless @configurations.key?(method_name)
@@ -45,18 +123,27 @@ module Appydave
45
123
  @configurations.key?(method_name) || super
46
124
  end
47
125
 
126
+ # Save all registered configurations to their respective files
127
+ # @return [void]
48
128
  def save
49
129
  configurations.each_value(&:save)
50
130
  end
51
131
 
132
+ # Set default configuration block used when configure called without block
133
+ # @yield [Config] configuration block to execute by default
134
+ # @return [Proc] the stored block
52
135
  def set_default(&block)
53
136
  @default_block = block
54
137
  end
55
138
 
139
+ # Load all registered configurations from their respective files
140
+ # @return [void]
56
141
  def load
57
142
  configurations.each_value(&:load)
58
143
  end
59
144
 
145
+ # Open configuration directory in VS Code
146
+ # @return [void]
60
147
  def edit
61
148
  ensure_config_directory
62
149
  puts "Edit configuration: #{config_path}"
@@ -64,6 +151,8 @@ module Appydave
64
151
  Open3.capture3(open_vscode)
65
152
  end
66
153
 
154
+ # Debug output for all configurations
155
+ # @return [void]
67
156
  def debug
68
157
  log.kv 'Configuration Path', config_path
69
158
  configurations.each_value(&:debug)
@@ -74,6 +163,9 @@ module Appydave
74
163
  # configurations.each_value(&:print)
75
164
  # end
76
165
 
166
+ # Print specific configurations or all if no keys provided
167
+ # @param keys [Array<String, Symbol>] configuration keys to print
168
+ # @return [void]
77
169
  def print(*keys)
78
170
  if keys.empty?
79
171
  keys = configurations.keys
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Dam
6
+ # Centralized brand name resolution and transformation
7
+ #
8
+ # Handles conversion between:
9
+ # - Shortcuts: 'appydave', 'ad', 'joy', 'ss'
10
+ # - Config keys: 'appydave', 'beauty-and-joy', 'supportsignal'
11
+ # - Display names: 'v-appydave', 'v-beauty-and-joy', 'v-supportsignal'
12
+ #
13
+ # @example
14
+ # BrandResolver.expand('ad') # => 'v-appydave'
15
+ # BrandResolver.normalize('v-voz') # => 'voz'
16
+ # BrandResolver.to_config_key('ad') # => 'appydave'
17
+ # BrandResolver.to_display('voz') # => 'v-voz'
18
+ class BrandResolver
19
+ class << self
20
+ # Expand shortcut or key to full display name
21
+ # @param shortcut [String] Brand shortcut or key
22
+ # @return [String] Full brand name with v- prefix
23
+ def expand(shortcut)
24
+ return shortcut.to_s if shortcut.to_s.start_with?('v-')
25
+
26
+ key = to_config_key(shortcut)
27
+ "v-#{key}"
28
+ end
29
+
30
+ # Normalize brand name to config key (strip v- prefix)
31
+ # @param brand [String] Brand name (with or without v-)
32
+ # @return [String] Config key without v- prefix
33
+ def normalize(brand)
34
+ brand.to_s.sub(/^v-/, '')
35
+ end
36
+
37
+ # Convert to config key (handles shortcuts)
38
+ # @param input [String] Shortcut, key, or display name
39
+ # @return [String] Config key
40
+ def to_config_key(input)
41
+ # Strip v- prefix first
42
+ normalized = normalize(input)
43
+
44
+ # Look up from brands.json
45
+ Appydave::Tools::Configuration::Config.configure
46
+ brands_config = Appydave::Tools::Configuration::Config.brands
47
+
48
+ # Check if matches brand key (case-insensitive)
49
+ brand = brands_config.brands.find { |b| b.key.downcase == normalized.downcase }
50
+ return brand.key if brand
51
+
52
+ # Check if matches shortcut (case-insensitive)
53
+ brand = brands_config.brands.find { |b| b.shortcut.downcase == normalized.downcase }
54
+ return brand.key if brand
55
+
56
+ # Fall back to hardcoded shortcuts (backward compatibility)
57
+ case normalized.downcase
58
+ when 'ad' then 'appydave'
59
+ when 'joy' then 'beauty-and-joy'
60
+ when 'ss' then 'supportsignal'
61
+ else
62
+ normalized.downcase
63
+ end
64
+ end
65
+
66
+ # Convert to display name (always v- prefix)
67
+ # @param input [String] Shortcut, key, or display name
68
+ # @return [String] Display name with v- prefix
69
+ def to_display(input)
70
+ expand(input)
71
+ end
72
+
73
+ # Validate brand exists in filesystem
74
+ # @param brand [String] Brand to validate
75
+ # @raise [BrandNotFoundError] if brand invalid
76
+ # @return [String] Config key if valid
77
+ def validate(brand)
78
+ key = to_config_key(brand)
79
+
80
+ # Build brand path (avoiding circular dependency with Config.brand_path)
81
+ Appydave::Tools::Configuration::Config.configure
82
+ brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(key)
83
+
84
+ # If brand has configured video_projects path, use it
85
+ if brand_info.locations.video_projects && !brand_info.locations.video_projects.empty?
86
+ brand_path = brand_info.locations.video_projects
87
+ else
88
+ # Fall back to projects_root + expanded brand name
89
+ root = Config.projects_root
90
+ brand_path = File.join(root, expand(key))
91
+ end
92
+
93
+ unless Dir.exist?(brand_path)
94
+ available = Config.available_brands_display
95
+ raise BrandNotFoundError.new(brand, available)
96
+ end
97
+
98
+ key
99
+ rescue StandardError => e
100
+ raise BrandNotFoundError, e.message unless e.is_a?(BrandNotFoundError)
101
+
102
+ raise
103
+ end
104
+
105
+ # Check if brand exists (returns boolean instead of raising)
106
+ # @param brand [String] Brand to check
107
+ # @return [Boolean] true if brand exists
108
+ def exists?(brand)
109
+ validate(brand)
110
+ true
111
+ rescue BrandNotFoundError
112
+ false
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -85,34 +85,11 @@ module Appydave
85
85
  end
86
86
 
87
87
  # Expand brand shortcut to full brand name
88
- # Reads from brands.json if available, falls back to hardcoded shortcuts
88
+ # Delegates to BrandResolver for centralized brand resolution
89
89
  # @param shortcut [String] Brand shortcut (e.g., 'appydave', 'ad', 'APPYDAVE')
90
90
  # @return [String] Full brand name (e.g., 'v-appydave')
91
91
  def expand_brand(shortcut)
92
- shortcut_str = shortcut.to_s
93
-
94
- return shortcut_str if shortcut_str.start_with?('v-')
95
-
96
- # Try to read from brands.json
97
- Appydave::Tools::Configuration::Config.configure
98
- brands_config = Appydave::Tools::Configuration::Config.brands
99
-
100
- # Check if input matches a brand key (case-insensitive)
101
- brand = brands_config.brands.find { |b| b.key.downcase == shortcut_str.downcase }
102
- return "v-#{brand.key}" if brand
103
-
104
- # Check if input matches a brand shortcut (case-insensitive)
105
- brand = brands_config.brands.find { |b| b.shortcut.downcase == shortcut_str.downcase }
106
- return "v-#{brand.key}" if brand
107
-
108
- # Fall back to hardcoded shortcuts for backwards compatibility
109
- normalized = shortcut_str.downcase
110
- case normalized
111
- when 'joy' then 'v-beauty-and-joy'
112
- when 'ss' then 'v-supportsignal'
113
- else
114
- "v-#{normalized}"
115
- end
92
+ BrandResolver.expand(shortcut)
116
93
  end
117
94
 
118
95
  # Get list of available brands
@@ -116,9 +116,9 @@ module Appydave
116
116
  # Check if we're inside a v-* directory
117
117
  if current =~ %r{/(v-[^/]+)/([^/]+)/?}
118
118
  brand_with_prefix = ::Regexp.last_match(1)
119
- project = ::Regexp.last_match(2) # Capture BEFORE .sub() which resets Regexp.last_match
119
+ project = ::Regexp.last_match(2) # Capture BEFORE normalize() which resets Regexp.last_match
120
120
  # Strip 'v-' prefix to get brand key (e.g., 'v-supportsignal' → 'supportsignal')
121
- brand_key = brand_with_prefix.sub(/^v-/, '')
121
+ brand_key = BrandResolver.normalize(brand_with_prefix)
122
122
  return [brand_key, project] if project_exists?(brand_key, project)
123
123
  end
124
124
 
@@ -140,6 +140,7 @@ module Appydave
140
140
  skipped = 0
141
141
  failed = 0
142
142
 
143
+ # rubocop:disable Metrics/BlockLength
143
144
  files.each do |file|
144
145
  relative_path = file.sub("#{staging_dir}/", '')
145
146
 
@@ -158,12 +159,31 @@ module Appydave
158
159
  if local_md5 == s3_md5
159
160
  puts " ⏭️ Skipped: #{relative_path} (unchanged)"
160
161
  skipped += 1
161
- elsif upload_file(file, s3_path, dry_run: dry_run)
162
- uploaded += 1
163
162
  else
164
- failed += 1
163
+ # Warn if we're about to overwrite an existing S3 file
164
+ if s3_md5 && s3_md5 != local_md5
165
+ puts " ⚠️ Warning: #{relative_path} exists in S3 with different content"
166
+
167
+ # Try to get S3 timestamp for comparison
168
+ s3_file_info = get_s3_file_info(s3_path)
169
+ if s3_file_info && s3_file_info['LastModified']
170
+ s3_time = s3_file_info['LastModified']
171
+ local_time = File.mtime(file)
172
+ puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
173
+
174
+ puts ' ⚠️ S3 file is NEWER than local - you may be overwriting recent changes!' if s3_time > local_time
175
+ end
176
+ puts ' Uploading will overwrite S3 version...'
177
+ end
178
+
179
+ if upload_file(file, s3_path, dry_run: dry_run)
180
+ uploaded += 1
181
+ else
182
+ failed += 1
183
+ end
165
184
  end
166
185
  end
186
+ # rubocop:enable Metrics/BlockLength
167
187
 
168
188
  puts ''
169
189
  puts '✅ Upload complete!'
@@ -207,13 +227,29 @@ module Appydave
207
227
  if local_md5 == s3_md5
208
228
  puts " ⏭️ Skipped: #{relative_path} (unchanged)"
209
229
  skipped += 1
210
- elsif download_file(key, local_file, dry_run: dry_run)
211
- downloaded += 1
212
230
  else
213
- failed += 1
231
+ # Warn if we're about to overwrite an existing local file
232
+ if local_md5 && local_md5 != s3_md5
233
+ puts " ⚠️ Warning: #{relative_path} exists locally with different content"
234
+
235
+ # Compare timestamps
236
+ if s3_file['LastModified'] && File.exist?(local_file)
237
+ s3_time = s3_file['LastModified']
238
+ local_time = File.mtime(local_file)
239
+ puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
240
+
241
+ puts ' ⚠️ Local file is NEWER than S3 - you may be overwriting recent changes!' if local_time > s3_time
242
+ end
243
+ puts ' Downloading will overwrite local version...'
244
+ end
245
+
246
+ if download_file(key, local_file, dry_run: dry_run)
247
+ downloaded += 1
248
+ else
249
+ failed += 1
250
+ end
214
251
  end
215
252
  end
216
-
217
253
  puts ''
218
254
  puts '✅ Download complete!'
219
255
  puts " Downloaded: #{downloaded}, Skipped: #{skipped}, Failed: #{failed}"
@@ -634,13 +670,31 @@ module Appydave
634
670
  {
635
671
  'Key' => obj.key,
636
672
  'Size' => obj.size,
637
- 'ETag' => obj.etag
673
+ 'ETag' => obj.etag,
674
+ 'LastModified' => obj.last_modified
638
675
  }
639
676
  end
640
677
  rescue Aws::S3::Errors::ServiceError
641
678
  []
642
679
  end
643
680
 
681
+ # Get full S3 file info including timestamp
682
+ def get_s3_file_info(s3_key)
683
+ response = s3_client.head_object(
684
+ bucket: brand_info.aws.s3_bucket,
685
+ key: s3_key
686
+ )
687
+
688
+ {
689
+ 'Key' => s3_key,
690
+ 'Size' => response.content_length,
691
+ 'ETag' => response.etag,
692
+ 'LastModified' => response.last_modified
693
+ }
694
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
695
+ nil
696
+ end
697
+
644
698
  # List local files in staging directory
645
699
  def list_local_files(staging_dir)
646
700
  return {} unless Dir.exist?(staging_dir)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.38.0'
5
+ VERSION = '0.40.0'
6
6
  end
7
7
  end
@@ -55,6 +55,7 @@ require 'appydave/tools/subtitle_processor/join'
55
55
  require 'appydave/tools/dam/errors'
56
56
  require 'appydave/tools/dam/file_helper'
57
57
  require 'appydave/tools/dam/git_helper'
58
+ require 'appydave/tools/dam/brand_resolver'
58
59
  require 'appydave/tools/dam/config'
59
60
  require 'appydave/tools/dam/project_resolver'
60
61
  require 'appydave/tools/dam/config_loader'
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "AppyDave YouTube Automation Tools",
5
5
  "scripts": {
6
6
  "release": "semantic-release"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appydave-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.38.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cruwys
@@ -297,6 +297,7 @@ files:
297
297
  - lib/appydave/tools/configuration/models/settings_config.rb
298
298
  - lib/appydave/tools/configuration/models/youtube_automation_config.rb
299
299
  - lib/appydave/tools/configuration/openai.rb
300
+ - lib/appydave/tools/dam/brand_resolver.rb
300
301
  - lib/appydave/tools/dam/config.rb
301
302
  - lib/appydave/tools/dam/config_loader.rb
302
303
  - lib/appydave/tools/dam/errors.rb