appydave-tools 0.66.0 → 0.68.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.
@@ -10,6 +10,7 @@ module Appydave
10
10
  # Project listing functionality for VAT
11
11
  class ProjectListing
12
12
  # List all brands with summary table
13
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
13
14
  def self.list_brands_with_counts(detailed: false)
14
15
  brands = Config.available_brands
15
16
 
@@ -77,9 +78,11 @@ module Appydave
77
78
  "#{total_projects} project#{'s' if total_projects != 1}, " \
78
79
  "#{format_size(total_size)}"
79
80
  end
81
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
80
82
 
81
83
  # List all projects for a specific brand (Mode 3)
82
- def self.list_brand_projects(brand_arg)
84
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
85
+ def self.list_brand_projects(brand_arg, detailed: false)
83
86
  # ProjectResolver expects the original brand key/shortcut, not the expanded v-* version
84
87
  projects = ProjectResolver.list_projects(brand_arg)
85
88
 
@@ -102,58 +105,63 @@ module Appydave
102
105
 
103
106
  # Gather project data
104
107
  brand_path = Config.brand_path(brand_arg)
108
+ brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand_arg)
105
109
  is_git_repo = Dir.exist?(File.join(brand_path, '.git'))
106
110
 
107
111
  project_data = projects.map do |project|
108
- project_path = Config.project_path(brand_arg, project)
109
- size = FileHelper.calculate_directory_size(project_path)
110
- modified = File.mtime(project_path)
111
-
112
- # Check if project has uncommitted changes (if brand is git repo)
113
- git_status = if is_git_repo
114
- calculate_project_git_status(brand_path, project)
115
- else
116
- 'N/A'
117
- end
118
-
119
- # Check if project has s3-staging folder
120
- s3_sync = if Dir.exist?(File.join(project_path, 's3-staging'))
121
- '✓ staged'
122
- else
123
- 'none'
124
- end
125
-
126
- {
127
- name: project,
128
- path: project_path,
129
- size: size,
130
- modified: modified,
131
- age: format_age(modified),
132
- stale: stale?(modified),
133
- git_status: git_status,
134
- s3_sync: s3_sync
135
- }
112
+ collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: detailed)
136
113
  end
137
114
 
138
- # Print table header
115
+ # Print common header
139
116
  puts "Projects in #{brand}:"
140
117
  puts ''
141
118
  puts 'ℹ️ Note: Lists only projects with files, not empty directories'
142
119
  puts ''
143
- puts 'PROJECT SIZE AGE GIT S3'
144
- puts '-' * 130
145
120
 
146
- # Print table rows
147
- project_data.each do |data|
148
- age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
149
- puts format(
150
- '%-45s %12s %15s %-15s %-10s',
151
- data[:name],
152
- format_size(data[:size]),
153
- age_display,
154
- data[:git_status],
155
- data[:s3_sync]
156
- )
121
+ if detailed
122
+ # Detailed view with additional columns
123
+ header = 'PROJECT SIZE AGE GIT S3 ' \
124
+ 'PATH HEAVY FILES LIGHT FILES SSD BACKUP ' \
125
+ 'S3 UPLOAD S3 ↓ DOWNLOAD'
126
+ puts header
127
+ puts '-' * 250
128
+
129
+ project_data.each do |data|
130
+ age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
131
+ s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
132
+ s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
133
+
134
+ puts format(
135
+ '%-45s %12s %15s %-15s %-12s %-35s %-18s %-18s %-30s %-15s %-15s',
136
+ data[:name],
137
+ format_size(data[:size]),
138
+ age_display,
139
+ data[:git_status],
140
+ data[:s3_sync],
141
+ shorten_path(data[:path]),
142
+ data[:heavy_files] || 'N/A',
143
+ data[:light_files] || 'N/A',
144
+ data[:ssd_backup] || 'N/A',
145
+ s3_upload,
146
+ s3_download
147
+ )
148
+ end
149
+ else
150
+ # Default view
151
+ puts 'PROJECT SIZE AGE GIT S3'
152
+ puts '-' * 130
153
+
154
+ project_data.each do |data|
155
+ age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
156
+ puts format(
157
+ '%-45s %12s %15s %-15s %-12s',
158
+ data[:name],
159
+ format_size(data[:size]),
160
+ age_display,
161
+ data[:git_status],
162
+ data[:s3_sync]
163
+ )
164
+ end
157
165
  end
158
166
 
159
167
  # Print footer summary
@@ -163,8 +171,10 @@ module Appydave
163
171
  puts ''
164
172
  puts "Total: #{project_count} project#{'s' if project_count != 1}, #{format_size(total_size)}"
165
173
  end
174
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
166
175
 
167
176
  # List with pattern matching (Mode 3b)
177
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
168
178
  def self.list_with_pattern(brand_arg, pattern)
169
179
  # ProjectResolver expects the original brand key/shortcut, not the expanded v-* version
170
180
  matches = ProjectResolver.resolve_pattern(brand_arg, pattern)
@@ -227,10 +237,12 @@ module Appydave
227
237
  puts "Total: #{match_count} project#{'s' if match_count != 1}, #{format_size(total_size)} " \
228
238
  "(#{percentage}% of #{brand})"
229
239
  end
240
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
230
241
 
231
242
  # Helper methods
232
243
 
233
244
  # Show brand context header with git, S3, and SSD info
245
+ # rubocop:disable Metrics/AbcSize
234
246
  def self.show_brand_header(brand_arg, brand)
235
247
  Appydave::Tools::Configuration::Config.configure
236
248
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand_arg)
@@ -265,8 +277,10 @@ module Appydave
265
277
 
266
278
  puts ''
267
279
  end
280
+ # rubocop:enable Metrics/AbcSize
268
281
 
269
282
  # Collect brand data for display
283
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
270
284
  def self.collect_brand_data(brand, detailed: false)
271
285
  Appydave::Tools::Configuration::Config.configure
272
286
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand)
@@ -325,6 +339,7 @@ module Appydave
325
339
 
326
340
  result
327
341
  end
342
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
328
343
 
329
344
  # Calculate git status for a brand
330
345
  def self.calculate_git_status(brand_path)
@@ -368,6 +383,107 @@ module Appydave
368
383
  end
369
384
  end
370
385
 
386
+ # Calculate 3-state S3 sync status for a project
387
+ def self.calculate_project_s3_sync_status(brand_arg, brand_info, project)
388
+ # Check if S3 is configured
389
+ s3_bucket = brand_info.aws.s3_bucket
390
+ return 'N/A' if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
391
+
392
+ # Use S3Operations to calculate sync status
393
+ begin
394
+ s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
395
+ s3_ops.calculate_sync_status
396
+ rescue StandardError
397
+ # S3 not accessible or other error
398
+ 'N/A'
399
+ end
400
+ end
401
+
402
+ # Calculate S3 sync timestamps for a project
403
+ def self.calculate_s3_timestamps(brand_arg, brand_info, project)
404
+ # Check if S3 is configured
405
+ s3_bucket = brand_info.aws.s3_bucket
406
+ return { last_upload: nil, last_download: nil } if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
407
+
408
+ # Use S3Operations to get timestamps
409
+ begin
410
+ s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
411
+ s3_ops.sync_timestamps
412
+ rescue StandardError
413
+ # S3 not accessible or other error
414
+ { last_upload: nil, last_download: nil }
415
+ end
416
+ end
417
+
418
+ # Collect project data for display
419
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
420
+ def self.collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: false)
421
+ project_path = Config.project_path(brand_arg, project)
422
+ size = FileHelper.calculate_directory_size(project_path)
423
+ modified = File.mtime(project_path)
424
+
425
+ # Check if project has uncommitted changes (if brand is git repo)
426
+ git_status = if is_git_repo
427
+ calculate_project_git_status(brand_path, project)
428
+ else
429
+ 'N/A'
430
+ end
431
+
432
+ # Calculate 3-state S3 sync status
433
+ s3_sync = calculate_project_s3_sync_status(brand_arg, brand_info, project)
434
+
435
+ result = {
436
+ name: project,
437
+ path: project_path,
438
+ size: size,
439
+ modified: modified,
440
+ age: format_age(modified),
441
+ stale: stale?(modified),
442
+ git_status: git_status,
443
+ s3_sync: s3_sync
444
+ }
445
+
446
+ # Add detailed fields if requested
447
+ if detailed
448
+ # Heavy files (video files in root)
449
+ heavy_count = 0
450
+ heavy_size = 0
451
+ Dir.glob(File.join(project_path, '*.{mp4,mov,avi,mkv,webm}')).each do |file|
452
+ heavy_count += 1
453
+ heavy_size += File.size(file)
454
+ end
455
+
456
+ # Light files (subtitles, images, metadata)
457
+ light_count = 0
458
+ light_size = 0
459
+ Dir.glob(File.join(project_path, '**/*.{srt,vtt,jpg,png,md,txt,json,yml}')).each do |file|
460
+ light_count += 1
461
+ light_size += File.size(file)
462
+ end
463
+
464
+ # SSD backup path (if exists)
465
+ ssd_backup = brand_info.locations.ssd_backup
466
+ ssd_path = if ssd_backup && !ssd_backup.empty? && ssd_backup != 'NOT-SET'
467
+ ssd_project_path = File.join(ssd_backup, project)
468
+ File.exist?(ssd_project_path) ? shorten_path(ssd_project_path) : nil
469
+ end
470
+
471
+ # S3 timestamps (last upload/download)
472
+ s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
473
+
474
+ result.merge!(
475
+ heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
476
+ light_files: "#{light_count} (#{format_size(light_size)})",
477
+ ssd_backup: ssd_path,
478
+ s3_last_upload: s3_timestamps[:last_upload],
479
+ s3_last_download: s3_timestamps[:last_download]
480
+ )
481
+ end
482
+
483
+ result
484
+ end
485
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
486
+
371
487
  # Calculate total size of all projects in a brand
372
488
  def self.calculate_total_size(brand, projects)
373
489
  projects.sum do |project|
@@ -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.66.0'
5
+ VERSION = '0.68.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.66.0",
3
+ "version": "0.68.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.66.0
4
+ version: 0.68.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,10 @@ 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
256
259
  - docs/guides/configuration-setup.md
257
260
  - docs/guides/platforms/windows/README.md
258
261
  - docs/guides/platforms/windows/dam-testing-plan-windows-powershell.md
@@ -302,6 +305,7 @@ files:
302
305
  - lib/appydave/tools/dam/config_loader.rb
303
306
  - lib/appydave/tools/dam/errors.rb
304
307
  - lib/appydave/tools/dam/file_helper.rb
308
+ - lib/appydave/tools/dam/fuzzy_matcher.rb
305
309
  - lib/appydave/tools/dam/git_helper.rb
306
310
  - lib/appydave/tools/dam/manifest_generator.rb
307
311
  - lib/appydave/tools/dam/project_listing.rb