appydave-tools 0.39.0 → 0.41.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: d5c03f22b90ef02e4609a1d50bf1c1d06cf03b782a24cd9196463a746d91f33d
4
- data.tar.gz: 61bc3604ae50ad6d9cc037c1df8be8c9d677a11aab23387c5fe74974e0bb3da4
3
+ metadata.gz: d87056ef6a0201fc1d722295aec17a46fa2c53a7581e9236db94369c8425ea9d
4
+ data.tar.gz: 24eadac450c656d2c4f0e38b36a0683a2fa096c02df913d97c580d6c7e109c34
5
5
  SHA512:
6
- metadata.gz: 8c6ebbbce5cea68d4168d606521a57162e2dcee80d91dfd11b3785ca2e8fa722d1199553a64b51c9734c89869e725626e0cbcef6089e73e694c9b16b12079abb
7
- data.tar.gz: b95e4edcd87705d08b2ebdf0c5b93884cf59386da8d169e21aea881119b5f8eb14d555c8f5c6b8c83ba236904b8f99d102fc3a192f9db4d06fc814d7405731c0
6
+ metadata.gz: 4390b2ec24eda2540a79fcb9dc0edea9c40d9d50e2cbf9720c56fa624071677a1e97f15be23f1cc63a7dbcb56c2a453bd28722154f0fbe0376689f6992110a79
7
+ data.tar.gz: fc43acf77aeb8c132cc1ed02172366b4eadfee12b50bcbe138694cfc4ff1e00fd6fbfb847a12046ee0b81576fdb9a18214a9a88d341e7fb05d65b95407d77517
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.40.0](https://github.com/appydave/appydave-tools/compare/v0.39.0...v0.40.0) (2025-11-21)
2
+
3
+
4
+ ### Features
5
+
6
+ * add S3 overwrite warnings with timestamp comparison to prevent data loss ([e922891](https://github.com/appydave/appydave-tools/commit/e922891d9ae87328f5a7cffe5e827f08232c90cc))
7
+
8
+ # [0.39.0](https://github.com/appydave/appydave-tools/compare/v0.38.0...v0.39.0) (2025-11-21)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
1
15
  # [0.38.0](https://github.com/appydave/appydave-tools/compare/v0.37.0...v0.38.0) (2025-11-21)
2
16
 
3
17
 
@@ -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
@@ -150,7 +150,7 @@ module Appydave
150
150
 
151
151
  # Find the most recent modification time across all projects
152
152
  def self.find_last_modified(brand, projects)
153
- return Time.at(0) if projects.empty?
153
+ return nil if projects.empty?
154
154
 
155
155
  projects.map do |project|
156
156
  File.mtime(Config.project_path(brand, project))
@@ -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}"
@@ -235,10 +271,21 @@ module Appydave
235
271
 
236
272
  if s3_files.empty? && local_files.empty?
237
273
  puts "❌ No files found in S3 or locally for #{brand}/#{project_id}"
274
+ puts ' This project has no heavy files in s3-staging/ or S3.'
275
+ puts " Tip: Add files to #{File.basename(staging_dir)}/ folder, then run: dam s3-up"
238
276
  return
239
277
  end
240
278
 
241
279
  puts "📊 S3 Sync Status for #{brand}/#{project_id}"
280
+
281
+ # Show last sync time
282
+ if s3_files.any?
283
+ most_recent = s3_files.map { |f| f['LastModified'] }.compact.max
284
+ if most_recent
285
+ time_ago = format_time_ago(Time.now - most_recent)
286
+ puts " Last synced: #{time_ago} ago (#{most_recent.strftime('%Y-%m-%d %H:%M')})"
287
+ end
288
+ end
242
289
  puts ''
243
290
 
244
291
  # Combine all file paths (S3 + local)
@@ -527,6 +574,28 @@ module Appydave
527
574
  end
528
575
  end
529
576
 
577
+ def format_time_ago(seconds)
578
+ return 'just now' if seconds < 60
579
+
580
+ minutes = seconds / 60
581
+ return "#{minutes.round} minute#{'s' if minutes > 1}" if minutes < 60
582
+
583
+ hours = minutes / 60
584
+ return "#{hours.round} hour#{'s' if hours > 1}" if hours < 24
585
+
586
+ days = hours / 24
587
+ return "#{days.round} day#{'s' if days > 1}" if days < 7
588
+
589
+ weeks = days / 7
590
+ return "#{weeks.round} week#{'s' if weeks > 1}" if weeks < 4
591
+
592
+ months = days / 30
593
+ return "#{months.round} month#{'s' if months > 1}" if months < 12
594
+
595
+ years = days / 365
596
+ "#{years.round} year#{'s' if years > 1}"
597
+ end
598
+
530
599
  def detect_content_type(filename)
531
600
  ext = File.extname(filename).downcase
532
601
  case ext
@@ -634,13 +703,31 @@ module Appydave
634
703
  {
635
704
  'Key' => obj.key,
636
705
  'Size' => obj.size,
637
- 'ETag' => obj.etag
706
+ 'ETag' => obj.etag,
707
+ 'LastModified' => obj.last_modified
638
708
  }
639
709
  end
640
710
  rescue Aws::S3::Errors::ServiceError
641
711
  []
642
712
  end
643
713
 
714
+ # Get full S3 file info including timestamp
715
+ def get_s3_file_info(s3_key)
716
+ response = s3_client.head_object(
717
+ bucket: brand_info.aws.s3_bucket,
718
+ key: s3_key
719
+ )
720
+
721
+ {
722
+ 'Key' => s3_key,
723
+ 'Size' => response.content_length,
724
+ 'ETag' => response.etag,
725
+ 'LastModified' => response.last_modified
726
+ }
727
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
728
+ nil
729
+ end
730
+
644
731
  # List local files in staging directory
645
732
  def list_local_files(staging_dir)
646
733
  return {} unless Dir.exist?(staging_dir)
@@ -60,15 +60,6 @@ module Appydave
60
60
  puts "📊 Brand Status: v-#{brand}"
61
61
  puts ''
62
62
 
63
- # Show git remote (with self-healing)
64
- remote = Config.git_remote(brand)
65
- if remote
66
- puts "📡 Git Remote: #{remote}"
67
- else
68
- puts '📡 Git Remote: Not configured (not a git repository)'
69
- end
70
- puts ''
71
-
72
63
  # Show git status
73
64
  if git_repo?
74
65
  show_brand_git_status
@@ -108,7 +99,7 @@ module Appydave
108
99
  local = project_entry[:storage][:local]
109
100
 
110
101
  if local[:exists]
111
- puts " 📁 Local: ✓ exists (#{local[:structure]} structure)"
102
+ puts ' 📁 Local: ✓ exists'
112
103
  puts " Heavy files: #{local[:has_heavy_files] ? 'yes' : 'no'}"
113
104
  puts " Light files: #{local[:has_light_files] ? 'yes' : 'no'}"
114
105
  else
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.39.0'
5
+ VERSION = '0.41.0'
6
6
  end
7
7
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.39.0",
3
+ "version": "0.41.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.39.0
4
+ version: 0.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cruwys