appydave-tools 0.67.0 → 0.69.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.
@@ -91,8 +91,14 @@ module Appydave
91
91
  end
92
92
 
93
93
  unless Dir.exist?(brand_path)
94
+ # Get available brands for error message
94
95
  available = Config.available_brands_display
95
- raise BrandNotFoundError.new(brand, available)
96
+
97
+ # Use fuzzy matching to suggest similar brands
98
+ available_list = Config.available_brands
99
+ suggestions = FuzzyMatcher.find_matches(brand, available_list, threshold: 3)
100
+
101
+ raise BrandNotFoundError.new(brand, available, suggestions)
96
102
  end
97
103
 
98
104
  key
@@ -37,7 +37,12 @@ module Appydave
37
37
 
38
38
  unless Dir.exist?(path)
39
39
  brands_list = available_brands_display
40
- raise "Brand directory not found: #{path}\nAvailable brands:\n#{brands_list}"
40
+ # Use fuzzy matching to suggest similar brands (check both shortcuts and keys)
41
+ Appydave::Tools::Configuration::Config.configure
42
+ brands_config = Appydave::Tools::Configuration::Config.brands
43
+ all_brand_identifiers = brands_config.brands.flat_map { |b| [b.shortcut, b.key] }.uniq
44
+ suggestions = FuzzyMatcher.find_matches(brand_key, all_brand_identifiers, threshold: 3)
45
+ raise BrandNotFoundError.new(path, brands_list, suggestions)
41
46
  end
42
47
 
43
48
  path
@@ -8,8 +8,16 @@ module Appydave
8
8
 
9
9
  # Raised when brand directory not found
10
10
  class BrandNotFoundError < DamError
11
- def initialize(brand, available_brands = nil)
11
+ def initialize(brand, available_brands = nil, suggestions = nil)
12
12
  message = "Brand directory not found: #{brand}"
13
+
14
+ # Add fuzzy match suggestions if provided
15
+ if suggestions && !suggestions.empty?
16
+ message += "\n\nDid you mean?"
17
+ suggestions.each { |s| message += "\n - #{s}" }
18
+ end
19
+
20
+ # Add full list of available brands
13
21
  message += "\n\nAvailable brands:\n#{available_brands}" if available_brands && !available_brands.empty?
14
22
  super(message)
15
23
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Dam
6
+ # Fuzzy matching for brand names using Levenshtein distance
7
+ class FuzzyMatcher
8
+ class << self
9
+ # Find closest matches to input string
10
+ # @param input [String] Input string to match
11
+ # @param candidates [Array<String>] List of valid options
12
+ # @param threshold [Integer] Maximum distance to consider a match (default: 3)
13
+ # @return [Array<String>] Sorted list of closest matches
14
+ def find_matches(input, candidates, threshold: 3)
15
+ return [] if input.nil? || input.empty? || candidates.empty?
16
+
17
+ # Calculate distances and filter by threshold
18
+ matches = candidates.map do |candidate|
19
+ distance = levenshtein_distance(input.downcase, candidate.downcase)
20
+ { candidate: candidate, distance: distance }
21
+ end
22
+
23
+ # Filter by threshold
24
+ matches = matches.select { |m| m[:distance] <= threshold }
25
+
26
+ # Sort by distance (closest first)
27
+ matches.sort_by { |m| m[:distance] }.map { |m| m[:candidate] }
28
+ end
29
+
30
+ # Calculate Levenshtein distance between two strings
31
+ # @param str1 [String] First string
32
+ # @param str2 [String] Second string
33
+ # @return [Integer] Edit distance
34
+ def levenshtein_distance(str1, str2)
35
+ return str2.length if str1.empty?
36
+ return str1.length if str2.empty?
37
+
38
+ # Create distance matrix
39
+ matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1) }
40
+
41
+ # Initialize first row and column
42
+ (0..str1.length).each { |i| matrix[i][0] = i }
43
+ (0..str2.length).each { |j| matrix[0][j] = j }
44
+
45
+ # Calculate distances
46
+ (1..str1.length).each do |i|
47
+ (1..str2.length).each do |j|
48
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
49
+ matrix[i][j] = [
50
+ matrix[i - 1][j] + 1, # deletion
51
+ matrix[i][j - 1] + 1, # insertion
52
+ matrix[i - 1][j - 1] + cost # substitution
53
+ ].min
54
+ end
55
+ end
56
+
57
+ matrix[str1.length][str2.length]
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -49,15 +49,26 @@ module Appydave
49
49
  )
50
50
  end
51
51
  else
52
- # Default view
53
- puts 'BRAND KEY PROJECTS SIZE LAST MODIFIED GIT S3 SYNC'
54
- puts '-' * 130
52
+ # Default view - use same format for header and data
53
+ # rubocop:disable Style/RedundantFormat
54
+ puts format(
55
+ '%-30s %-15s %10s %12s %20s %-15s %-10s',
56
+ 'BRAND',
57
+ 'KEY',
58
+ 'PROJECTS',
59
+ 'SIZE',
60
+ 'LAST MODIFIED',
61
+ 'GIT',
62
+ 'S3 SYNC'
63
+ )
64
+ # rubocop:enable Style/RedundantFormat
65
+ puts '-' * 133
55
66
 
56
67
  brand_data.each do |data|
57
68
  brand_display = "#{data[:shortcut]} - #{data[:name]}"
58
69
 
59
70
  puts format(
60
- '%-30s %-12s %10d %12s %20s %-15s %-10s',
71
+ '%-30s %-15s %10d %12s %20s %-15s %-10s',
61
72
  brand_display,
62
73
  data[:key],
63
74
  data[:count],
@@ -119,16 +130,32 @@ module Appydave
119
130
  puts ''
120
131
 
121
132
  if detailed
122
- # Detailed view with additional columns
123
- header = 'PROJECT SIZE AGE GIT S3 ' \
124
- 'PATH HEAVY FILES LIGHT FILES SSD BACKUP'
125
- puts header
126
- puts '-' * 200
133
+ # Detailed view with additional columns - use same format for header and data
134
+ # rubocop:disable Style/RedundantFormat
135
+ puts format(
136
+ '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
137
+ 'PROJECT',
138
+ 'SIZE',
139
+ 'AGE',
140
+ 'GIT',
141
+ 'S3',
142
+ 'PATH',
143
+ 'HEAVY FILES',
144
+ 'LIGHT FILES',
145
+ 'SSD BACKUP',
146
+ 'S3 ↑ UPLOAD',
147
+ 'S3 ↓ DOWNLOAD'
148
+ )
149
+ # rubocop:enable Style/RedundantFormat
150
+ puts '-' * 280
127
151
 
128
152
  project_data.each do |data|
129
153
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
154
+ s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
155
+ s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
156
+
130
157
  puts format(
131
- '%-45s %12s %15s %-15s %-10s %-35s %-18s %-18s %-30s',
158
+ '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
132
159
  data[:name],
133
160
  format_size(data[:size]),
134
161
  age_display,
@@ -137,18 +164,29 @@ module Appydave
137
164
  shorten_path(data[:path]),
138
165
  data[:heavy_files] || 'N/A',
139
166
  data[:light_files] || 'N/A',
140
- data[:ssd_backup] || 'N/A'
167
+ data[:ssd_backup] || 'N/A',
168
+ s3_upload,
169
+ s3_download
141
170
  )
142
171
  end
143
172
  else
144
- # Default view
145
- puts 'PROJECT SIZE AGE GIT S3'
173
+ # Default view - use same format for header and data
174
+ # rubocop:disable Style/RedundantFormat
175
+ puts format(
176
+ '%-45s %12s %15s %-15s %-12s',
177
+ 'PROJECT',
178
+ 'SIZE',
179
+ 'AGE',
180
+ 'GIT',
181
+ 'S3'
182
+ )
183
+ # rubocop:enable Style/RedundantFormat
146
184
  puts '-' * 130
147
185
 
148
186
  project_data.each do |data|
149
187
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
150
188
  puts format(
151
- '%-45s %12s %15s %-15s %-10s',
189
+ '%-45s %12s %15s %-15s %-12s',
152
190
  data[:name],
153
191
  format_size(data[:size]),
154
192
  age_display,
@@ -377,6 +415,38 @@ module Appydave
377
415
  end
378
416
  end
379
417
 
418
+ # Calculate 3-state S3 sync status for a project
419
+ def self.calculate_project_s3_sync_status(brand_arg, brand_info, project)
420
+ # Check if S3 is configured
421
+ s3_bucket = brand_info.aws.s3_bucket
422
+ return 'N/A' if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
423
+
424
+ # Use S3Operations to calculate sync status
425
+ begin
426
+ s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
427
+ s3_ops.calculate_sync_status
428
+ rescue StandardError
429
+ # S3 not accessible or other error
430
+ 'N/A'
431
+ end
432
+ end
433
+
434
+ # Calculate S3 sync timestamps for a project
435
+ def self.calculate_s3_timestamps(brand_arg, brand_info, project)
436
+ # Check if S3 is configured
437
+ s3_bucket = brand_info.aws.s3_bucket
438
+ return { last_upload: nil, last_download: nil } if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
439
+
440
+ # Use S3Operations to get timestamps
441
+ begin
442
+ s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
443
+ s3_ops.sync_timestamps
444
+ rescue StandardError
445
+ # S3 not accessible or other error
446
+ { last_upload: nil, last_download: nil }
447
+ end
448
+ end
449
+
380
450
  # Collect project data for display
381
451
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
382
452
  def self.collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: false)
@@ -391,12 +461,8 @@ module Appydave
391
461
  'N/A'
392
462
  end
393
463
 
394
- # Check if project has s3-staging folder
395
- s3_sync = if Dir.exist?(File.join(project_path, 's3-staging'))
396
- '✓ staged'
397
- else
398
- 'none'
399
- end
464
+ # Calculate 3-state S3 sync status
465
+ s3_sync = calculate_project_s3_sync_status(brand_arg, brand_info, project)
400
466
 
401
467
  result = {
402
468
  name: project,
@@ -434,10 +500,15 @@ module Appydave
434
500
  File.exist?(ssd_project_path) ? shorten_path(ssd_project_path) : nil
435
501
  end
436
502
 
503
+ # S3 timestamps (last upload/download)
504
+ s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
505
+
437
506
  result.merge!(
438
507
  heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
439
508
  light_files: "#{light_count} (#{format_size(light_size)})",
440
- ssd_backup: ssd_path
509
+ ssd_backup: ssd_path,
510
+ s3_last_upload: s3_timestamps[:last_upload],
511
+ s3_last_download: s3_timestamps[:last_download]
441
512
  )
442
513
  end
443
514
 
@@ -488,6 +488,101 @@ module Appydave
488
488
  puts dry_run ? '✅ Archive dry-run complete!' : '✅ Archive complete!'
489
489
  end
490
490
 
491
+ # Calculate 3-state S3 sync status
492
+ # @return [String] One of: '↑ upload', '↓ download', '✓ synced', 'none'
493
+ def calculate_sync_status
494
+ project_dir = project_directory_path
495
+ staging_dir = File.join(project_dir, 's3-staging')
496
+
497
+ # No s3-staging directory means no S3 intent
498
+ return 'none' unless Dir.exist?(staging_dir)
499
+
500
+ # Get S3 files (if S3 configured)
501
+ begin
502
+ s3_files = list_s3_files
503
+ rescue StandardError
504
+ # S3 not configured or not accessible
505
+ return 'none'
506
+ end
507
+
508
+ local_files = list_local_files(staging_dir)
509
+
510
+ # No files anywhere
511
+ return 'none' if s3_files.empty? && local_files.empty?
512
+
513
+ # Build S3 files map
514
+ s3_files_map = s3_files.each_with_object({}) do |file, hash|
515
+ relative_path = extract_relative_path(file['Key'])
516
+ hash[relative_path] = file
517
+ end
518
+
519
+ # Check for differences
520
+ needs_upload = false
521
+ needs_download = false
522
+
523
+ # Check all local files
524
+ local_files.each_key do |relative_path|
525
+ local_file = File.join(staging_dir, relative_path)
526
+ s3_file = s3_files_map[relative_path]
527
+
528
+ if s3_file
529
+ # Compare MD5
530
+ local_md5 = file_md5(local_file)
531
+ s3_md5 = s3_file['ETag'].gsub('"', '')
532
+ needs_upload = true if local_md5 != s3_md5
533
+ else
534
+ # Local file not in S3
535
+ needs_upload = true
536
+ end
537
+ end
538
+
539
+ # Check for S3-only files
540
+ s3_files_map.each_key do |relative_path|
541
+ local_file = File.join(staging_dir, relative_path)
542
+ needs_download = true unless File.exist?(local_file)
543
+ end
544
+
545
+ # Return status based on what's needed
546
+ if needs_upload && needs_download
547
+ '⚠️ both'
548
+ elsif needs_upload
549
+ '↑ upload'
550
+ elsif needs_download
551
+ '↓ download'
552
+ else
553
+ '✓ synced'
554
+ end
555
+ end
556
+
557
+ # Calculate S3 sync timestamps (last upload/download times)
558
+ # @return [Hash] { last_upload: Time|nil, last_download: Time|nil }
559
+ def sync_timestamps
560
+ project_dir = project_directory_path
561
+ staging_dir = File.join(project_dir, 's3-staging')
562
+
563
+ # No s3-staging directory means no S3 intent
564
+ return { last_upload: nil, last_download: nil } unless Dir.exist?(staging_dir)
565
+
566
+ # Get S3 files (if S3 configured)
567
+ begin
568
+ s3_files = list_s3_files
569
+ rescue StandardError
570
+ # S3 not configured or not accessible
571
+ return { last_upload: nil, last_download: nil }
572
+ end
573
+
574
+ # Last upload time = most recent S3 file LastModified
575
+ last_upload = s3_files.map { |f| f['LastModified'] }.compact.max if s3_files.any?
576
+
577
+ # Last download time = most recent local file mtime (in s3-staging)
578
+ last_download = if Dir.exist?(staging_dir)
579
+ local_files = Dir.glob(File.join(staging_dir, '**/*')).select { |f| File.file?(f) }
580
+ local_files.map { |f| File.mtime(f) }.max if local_files.any?
581
+ end
582
+
583
+ { last_upload: last_upload, last_download: last_download }
584
+ end
585
+
491
586
  # Build S3 key for a file
492
587
  def build_s3_key(relative_path)
493
588
  "#{brand_info.aws.s3_prefix}#{project_id}/#{relative_path}"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.67.0'
5
+ VERSION = '0.69.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/fuzzy_matcher'
58
59
  require 'appydave/tools/dam/brand_resolver'
59
60
  require 'appydave/tools/dam/config'
60
61
  require 'appydave/tools/dam/project_resolver'
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.67.0",
3
+ "version": "0.69.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.67.0
4
+ version: 0.69.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cruwys
@@ -228,6 +228,7 @@ files:
228
228
  - bin/youtube_automation.rb
229
229
  - bin/youtube_manager.rb
230
230
  - docs/README.md
231
+ - docs/ai-instructions/behavioral-regression-audit.md
231
232
  - docs/ai-instructions/code-quality-retrospective.md
232
233
  - docs/ai-instructions/defensive-logging-audit.md
233
234
  - docs/architecture/cli/cli-pattern-comparison.md
@@ -251,8 +252,11 @@ files:
251
252
  - docs/archive/tool-discovery.md
252
253
  - docs/archive/tool-documentation-analysis.md
253
254
  - docs/code-quality/README.md
255
+ - docs/code-quality/behavioral-audit-2025-01-22.md
254
256
  - docs/code-quality/implementation-plan.md
255
257
  - docs/code-quality/report-2025-01-21.md
258
+ - docs/code-quality/uat-plan-2025-01-22.md
259
+ - docs/code-quality/uat-report-2025-01-22.md
256
260
  - docs/guides/configuration-setup.md
257
261
  - docs/guides/platforms/windows/README.md
258
262
  - docs/guides/platforms/windows/dam-testing-plan-windows-powershell.md
@@ -302,6 +306,7 @@ files:
302
306
  - lib/appydave/tools/dam/config_loader.rb
303
307
  - lib/appydave/tools/dam/errors.rb
304
308
  - lib/appydave/tools/dam/file_helper.rb
309
+ - lib/appydave/tools/dam/fuzzy_matcher.rb
305
310
  - lib/appydave/tools/dam/git_helper.rb
306
311
  - lib/appydave/tools/dam/manifest_generator.rb
307
312
  - lib/appydave/tools/dam/project_listing.rb