appydave-tools 0.21.2 → 0.22.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/bin/dam +137 -0
  4. data/docs/README.md +187 -90
  5. data/docs/architecture/dam/dam-cli-enhancements.md +642 -0
  6. data/docs/architecture/dam/dam-cli-implementation-guide.md +1041 -0
  7. data/docs/architecture/dam/dam-data-model.md +466 -0
  8. data/docs/architecture/dam/dam-visualization-requirements.md +641 -0
  9. data/docs/architecture/dam/implementation-roadmap.md +328 -0
  10. data/docs/architecture/dam/jan-collaboration-guide.md +309 -0
  11. data/lib/appydave/tools/dam/s3_operations.rb +57 -5
  12. data/lib/appydave/tools/dam/s3_scanner.rb +139 -0
  13. data/lib/appydave/tools/version.rb +1 -1
  14. data/lib/appydave/tools.rb +1 -0
  15. data/package.json +1 -1
  16. metadata +37 -32
  17. data/docs/development/CODEX-recommendations.md +0 -258
  18. data/docs/development/README.md +0 -100
  19. /data/docs/{development/pattern-comparison.md → architecture/cli/cli-pattern-comparison.md} +0 -0
  20. /data/docs/{development/cli-architecture-patterns.md → architecture/cli/cli-patterns.md} +0 -0
  21. /data/docs/{project-brand-systems-analysis.md → architecture/configuration/configuration-systems.md} +0 -0
  22. /data/docs/{dam → architecture/dam}/dam-vision.md +0 -0
  23. /data/docs/{dam/prd-client-sharing.md → architecture/dam/design-decisions/002-client-sharing.md} +0 -0
  24. /data/docs/{dam/prd-git-integration.md → architecture/dam/design-decisions/003-git-integration.md} +0 -0
  25. /data/docs/{prd-unified-brands-configuration.md → architecture/design-decisions/001-unified-brands-config.md} +0 -0
  26. /data/docs/{dam/session-summary-2025-11-09.md → architecture/design-decisions/session-2025-11-09.md} +0 -0
  27. /data/docs/{configuration/README.md → guides/configuration-setup.md} +0 -0
  28. /data/docs/{dam → guides/platforms}/windows/README.md +0 -0
  29. /data/docs/{dam → guides/platforms}/windows/dam-testing-plan-windows-powershell.md +0 -0
  30. /data/docs/{dam → guides/platforms}/windows/installation.md +0 -0
  31. /data/docs/{tools → guides/tools}/bank-reconciliation.md +0 -0
  32. /data/docs/{tools → guides/tools}/cli-actions.md +0 -0
  33. /data/docs/{tools → guides/tools}/configuration.md +0 -0
  34. /data/docs/{dam → guides/tools/dam}/dam-testing-plan.md +0 -0
  35. /data/docs/{dam/usage.md → guides/tools/dam/dam-usage.md} +0 -0
  36. /data/docs/{tools → guides/tools}/gpt-context.md +0 -0
  37. /data/docs/{tools → guides/tools}/index.md +0 -0
  38. /data/docs/{tools → guides/tools}/move-images.md +0 -0
  39. /data/docs/{tools → guides/tools}/name-manager.md +0 -0
  40. /data/docs/{tools → guides/tools}/prompt-tools.md +0 -0
  41. /data/docs/{tools → guides/tools}/subtitle-processor.md +0 -0
  42. /data/docs/{tools → guides/tools}/youtube-automation.md +0 -0
  43. /data/docs/{tools → guides/tools}/youtube-manager.md +0 -0
  44. /data/docs/{configuration → templates}/.env.example +0 -0
  45. /data/docs/{configuration → templates}/channels.example.json +0 -0
  46. /data/docs/{configuration → templates}/settings.example.json +0 -0
@@ -0,0 +1,1041 @@
1
+ # DAM CLI Implementation Guide
2
+
3
+ **Practical code-level guide for implementing CLI enhancements**
4
+
5
+ This document provides the technical details needed to implement the CLI changes specified in [dam-cli-enhancements.md](dam-cli-enhancements.md). It includes code locations, existing patterns, granular task breakdowns, and implementation examples.
6
+
7
+ ---
8
+
9
+ ## 📂 Codebase Structure
10
+
11
+ ### Current DAM Code Organization
12
+
13
+ ```
14
+ bin/
15
+ └── dam # Main CLI executable (VatCLI class)
16
+
17
+ lib/appydave/tools/dam/
18
+ ├── config.rb # Brand configuration and path resolution
19
+ ├── config_loader.rb # Legacy config loader (deprecated)
20
+ ├── manifest_generator.rb # ✅ EXISTS - Generates brand-level manifests
21
+ ├── project_listing.rb # List brands and projects
22
+ ├── project_resolver.rb # Short name expansion (b65 → b65-full-name)
23
+ ├── s3_operations.rb # ✅ EXISTS - S3 upload/download/status
24
+ ├── share_operations.rb # S3 pre-signed URL generation
25
+ ├── status.rb # Unified status command
26
+ ├── sync_from_ssd.rb # SSD → Local sync
27
+ ├── repo_status.rb # Git status checking
28
+ ├── repo_sync.rb # Git pull operations
29
+ └── repo_push.rb # Git push operations
30
+
31
+ spec/appydave/tools/dam/
32
+ └── ... (corresponding spec files)
33
+
34
+ ~/.config/appydave/
35
+ └── brands.json # Brand configuration (6 brands)
36
+
37
+ /Users/[user]/dev/video-projects/
38
+ ├── v-appydave/
39
+ │ ├── projects.json # Brand-level manifest (generated)
40
+ │ ├── b64-project-name/
41
+ │ │ ├── .project-manifest.json # 🆕 NEW - Project-level manifest (optional)
42
+ │ │ ├── recordings/
43
+ │ │ ├── s3-staging/
44
+ │ │ └── ...
45
+ │ └── ...
46
+ └── ...
47
+ ```
48
+
49
+ ### Key Files to Modify/Create
50
+
51
+ | File | Status | Purpose |
52
+ |------|--------|---------|
53
+ | `bin/dam` | ✏️ MODIFY | Add new command handlers |
54
+ | `lib/appydave/tools/dam/manifest_generator.rb` | ✏️ MODIFY | Enhance with S3 scan data merging |
55
+ | `lib/appydave/tools/dam/s3_scanner.rb` | 🆕 NEW | Query AWS S3 for file listings |
56
+ | `lib/appydave/tools/dam/project_manifest_generator.rb` | 🆕 NEW | Generate project-level manifests with tree |
57
+ | `spec/appydave/tools/dam/s3_scanner_spec.rb` | 🆕 NEW | Specs for S3 scanner |
58
+ | `spec/appydave/tools/dam/project_manifest_generator_spec.rb` | 🆕 NEW | Specs for project manifest generator |
59
+
60
+ ---
61
+
62
+ ## 🔧 Existing Patterns to Follow
63
+
64
+ ### 1. CLI Command Structure (bin/dam)
65
+
66
+ **Pattern:** Commands defined in hash, methods handle arguments
67
+
68
+ ```ruby
69
+ class VatCLI
70
+ def initialize
71
+ @commands = {
72
+ 'help' => method(:help_command),
73
+ 'list' => method(:list_command),
74
+ 'manifest' => method(:manifest_command),
75
+ # ADD NEW COMMANDS HERE:
76
+ 's3-scan' => method(:s3_scan_command),
77
+ 'project-manifest' => method(:project_manifest_command)
78
+ }
79
+ end
80
+
81
+ def s3_scan_command(args)
82
+ all_brands = args.include?('--all')
83
+ args = args.reject { |arg| arg.start_with?('--') }
84
+ brand_arg = args[0]
85
+
86
+ if all_brands
87
+ scan_all_brands
88
+ elsif brand_arg
89
+ scan_single_brand(brand_arg)
90
+ else
91
+ show_s3_scan_usage
92
+ end
93
+ rescue StandardError => e
94
+ puts "❌ Error: #{e.message}"
95
+ exit 1
96
+ end
97
+ end
98
+ ```
99
+
100
+ ### 2. Brand Info Loading
101
+
102
+ **Pattern:** Use `Config` to load brand info from `brands.json`
103
+
104
+ ```ruby
105
+ def load_brand_info(brand)
106
+ Appydave::Tools::Configuration::Config.configure
107
+ Appydave::Tools::Configuration::Config.brands.get_brand(brand)
108
+ end
109
+
110
+ # Brand info structure (from brands.json):
111
+ # {
112
+ # "key" => "appydave",
113
+ # "name" => "AppyDave",
114
+ # "aws" => {
115
+ # "profile" => "david-appydave",
116
+ # "region" => "ap-southeast-1",
117
+ # "s3_bucket" => "appydave-video-projects",
118
+ # "s3_prefix" => "staging/v-appydave/"
119
+ # },
120
+ # "locations" => {
121
+ # "video_projects" => "/path/to/v-appydave",
122
+ # "ssd_backup" => "/Volumes/T7/..."
123
+ # }
124
+ # }
125
+ ```
126
+
127
+ ### 3. S3 Client Creation
128
+
129
+ **Pattern:** Use AWS SDK with shared credentials (from `s3_operations.rb:53-68`)
130
+
131
+ ```ruby
132
+ def create_s3_client(brand_info)
133
+ profile_name = brand_info.aws.profile
134
+ raise "AWS profile not configured" if profile_name.nil? || profile_name.empty?
135
+
136
+ credentials = Aws::SharedCredentials.new(profile_name: profile_name)
137
+
138
+ Aws::S3::Client.new(
139
+ credentials: credentials,
140
+ region: brand_info.aws.region,
141
+ http_wire_trace: false,
142
+ ssl_verify_peer: false # Workaround for OpenSSL 3.4.x CRL issues
143
+ )
144
+ end
145
+ ```
146
+
147
+ ### 4. Manifest Generation (brand-level)
148
+
149
+ **Pattern:** Scan filesystem, build project array, write JSON (from `manifest_generator.rb:21-70`)
150
+
151
+ ```ruby
152
+ def generate(output_file: nil)
153
+ output_file ||= File.join(brand_path, 'projects.json')
154
+
155
+ # Collect project IDs from filesystem
156
+ all_project_ids = collect_project_ids(ssd_backup, ssd_available)
157
+
158
+ # Build entries with storage detection
159
+ projects = build_project_entries(all_project_ids, ssd_backup, ssd_available)
160
+
161
+ # Calculate disk usage
162
+ disk_usage = calculate_disk_usage(projects, ssd_backup)
163
+
164
+ # Build manifest
165
+ manifest = {
166
+ config: {
167
+ brand: brand,
168
+ local_base: brand_path,
169
+ ssd_base: ssd_backup,
170
+ last_updated: Time.now.utc.iso8601
171
+ }.merge(disk_usage),
172
+ projects: projects
173
+ }
174
+
175
+ # Write to file
176
+ File.write(output_file, JSON.pretty_generate(manifest))
177
+ end
178
+ ```
179
+
180
+ ### 5. Progress Output
181
+
182
+ **Pattern:** Use emoji and clear status messages
183
+
184
+ ```ruby
185
+ puts "📊 Generating manifest for #{brand}..."
186
+ puts "✅ Generated #{output_file}"
187
+ puts "❌ Error: #{e.message}"
188
+ puts "⚠️ Warning: #{warning_message}"
189
+ ```
190
+
191
+ ---
192
+
193
+ ## 📋 Phase-by-Phase Implementation
194
+
195
+ ### Phase 1: Naming Consolidation (Optional - Can defer)
196
+
197
+ **Status:** LOW PRIORITY - Can implement later without breaking existing functionality
198
+
199
+ **Tasks:**
200
+ - [ ] Add `ad-dam` symlink in `bin/` directory
201
+ - [ ] Add deprecation warning to `dam` executable
202
+ - [ ] Update documentation references
203
+ - [ ] Schedule removal for v1.0.0
204
+
205
+ **Skip this phase for now** - Focus on Phase 2 (S3 Scan) which delivers immediate value.
206
+
207
+ ---
208
+
209
+ ### Phase 2: Brand-Level S3 Scan ⭐ HIGH PRIORITY
210
+
211
+ **Goal:** Query AWS S3 to get actual file listings (not just local `s3-staging/` folder presence)
212
+
213
+ #### Task 2.1: Create S3Scanner Class
214
+
215
+ **Location:** `lib/appydave/tools/dam/s3_scanner.rb`
216
+
217
+ **Dependencies:** `aws-sdk-s3` (already in gemspec ✅)
218
+
219
+ **Implementation:**
220
+
221
+ ```ruby
222
+ # frozen_string_literal: true
223
+
224
+ require 'aws-sdk-s3'
225
+
226
+ module Appydave
227
+ module Tools
228
+ module Dam
229
+ # Scan S3 bucket for project files
230
+ class S3Scanner
231
+ attr_reader :brand_info, :brand, :s3_client
232
+
233
+ def initialize(brand, brand_info: nil, s3_client: nil)
234
+ @brand_info = brand_info || load_brand_info(brand)
235
+ @brand = @brand_info.key
236
+ @s3_client = s3_client || create_s3_client(@brand_info)
237
+ end
238
+
239
+ # Scan S3 for a specific project
240
+ # @param project_id [String] Project ID (e.g., "b65-guy-monroe-marketing-plan")
241
+ # @return [Hash] S3 file data with :file_count, :total_bytes, :last_modified
242
+ def scan_project(project_id)
243
+ bucket = @brand_info.aws.s3_bucket
244
+ prefix = File.join(@brand_info.aws.s3_prefix, project_id)
245
+
246
+ puts "🔍 Scanning S3: s3://#{bucket}/#{prefix}/"
247
+
248
+ files = list_s3_objects(bucket, prefix)
249
+
250
+ if files.empty?
251
+ return {
252
+ exists: false,
253
+ file_count: 0,
254
+ total_bytes: 0,
255
+ last_modified: nil
256
+ }
257
+ end
258
+
259
+ total_bytes = files.sum { |obj| obj.size }
260
+ last_modified = files.map(&:last_modified).max
261
+
262
+ {
263
+ exists: true,
264
+ file_count: files.size,
265
+ total_bytes: total_bytes,
266
+ last_modified: last_modified.utc.iso8601
267
+ }
268
+ rescue Aws::S3::Errors::ServiceError => e
269
+ puts "⚠️ S3 scan failed for #{project_id}: #{e.message}"
270
+ { exists: false, file_count: 0, total_bytes: 0, last_modified: nil, error: e.message }
271
+ end
272
+
273
+ # Scan all projects in brand's S3 bucket
274
+ # @return [Hash] Map of project_id => scan result
275
+ def scan_all_projects
276
+ bucket = @brand_info.aws.s3_bucket
277
+ prefix = @brand_info.aws.s3_prefix
278
+
279
+ puts "🔍 Scanning all projects in S3: s3://#{bucket}/#{prefix}"
280
+
281
+ # List all "directories" (prefixes) under brand prefix
282
+ project_prefixes = list_s3_prefixes(bucket, prefix)
283
+
284
+ results = {}
285
+ project_prefixes.each do |project_id|
286
+ results[project_id] = scan_project(project_id)
287
+ end
288
+
289
+ results
290
+ end
291
+
292
+ private
293
+
294
+ def load_brand_info(brand)
295
+ Appydave::Tools::Configuration::Config.configure
296
+ Appydave::Tools::Configuration::Config.brands.get_brand(brand)
297
+ end
298
+
299
+ def create_s3_client(brand_info)
300
+ profile_name = brand_info.aws.profile
301
+ raise "AWS profile not configured for brand '#{@brand}'" if profile_name.nil? || profile_name.empty?
302
+
303
+ credentials = Aws::SharedCredentials.new(profile_name: profile_name)
304
+
305
+ Aws::S3::Client.new(
306
+ credentials: credentials,
307
+ region: brand_info.aws.region,
308
+ http_wire_trace: false,
309
+ ssl_verify_peer: false
310
+ )
311
+ end
312
+
313
+ # List all objects under a prefix
314
+ def list_s3_objects(bucket, prefix)
315
+ objects = []
316
+ continuation_token = nil
317
+
318
+ loop do
319
+ resp = s3_client.list_objects_v2(
320
+ bucket: bucket,
321
+ prefix: prefix,
322
+ continuation_token: continuation_token
323
+ )
324
+
325
+ objects.concat(resp.contents)
326
+ break unless resp.is_truncated
327
+
328
+ continuation_token = resp.next_continuation_token
329
+ end
330
+
331
+ objects
332
+ end
333
+
334
+ # List project-level prefixes (directories) under brand prefix
335
+ def list_s3_prefixes(bucket, prefix)
336
+ resp = s3_client.list_objects_v2(
337
+ bucket: bucket,
338
+ prefix: prefix,
339
+ delimiter: '/'
340
+ )
341
+
342
+ # common_prefixes returns array of prefixes like "staging/v-appydave/b65-guy-monroe/"
343
+ resp.common_prefixes.map do |cp|
344
+ # Extract project ID from prefix
345
+ File.basename(cp.prefix.chomp('/'))
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+ ```
353
+
354
+ #### Task 2.2: Add CLI Command Handler
355
+
356
+ **Location:** `bin/dam` (add method)
357
+
358
+ ```ruby
359
+ def s3_scan_command(args)
360
+ all_brands = args.include?('--all')
361
+ args = args.reject { |arg| arg.start_with?('--') }
362
+ brand_arg = args[0]
363
+
364
+ if all_brands
365
+ scan_all_brands_s3
366
+ elsif brand_arg
367
+ scan_single_brand_s3(brand_arg)
368
+ else
369
+ puts 'Usage: dam s3-scan <brand> [--all]'
370
+ puts ''
371
+ puts 'Scan S3 bucket to update project manifests with actual S3 file data.'
372
+ puts ''
373
+ puts 'Examples:'
374
+ puts ' dam s3-scan appydave # Scan AppyDave S3 bucket'
375
+ puts ' dam s3-scan --all # Scan all brands'
376
+ exit 1
377
+ end
378
+ rescue StandardError => e
379
+ puts "❌ Error: #{e.message}"
380
+ exit 1
381
+ end
382
+
383
+ def scan_single_brand_s3(brand_arg)
384
+ brand_key = brand_arg
385
+ scanner = Appydave::Tools::Dam::S3Scanner.new(brand_key)
386
+
387
+ # Scan all projects
388
+ results = scanner.scan_all_projects
389
+
390
+ # Load existing manifest
391
+ Appydave::Tools::Configuration::Config.configure
392
+ brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand_key)
393
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand_key)
394
+ manifest_path = File.join(brand_path, 'projects.json')
395
+
396
+ unless File.exist?(manifest_path)
397
+ puts "❌ Manifest not found: #{manifest_path}"
398
+ puts " Run: dam manifest #{brand_key}"
399
+ exit 1
400
+ end
401
+
402
+ manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
403
+
404
+ # Merge S3 scan data into manifest
405
+ manifest[:projects].each do |project|
406
+ project_id = project[:id]
407
+ s3_data = results[project_id]
408
+ next unless s3_data
409
+
410
+ project[:storage][:s3] = s3_data
411
+ end
412
+
413
+ # Update timestamp
414
+ manifest[:config][:last_updated] = Time.now.utc.iso8601
415
+ manifest[:config][:note] = 'Auto-generated manifest with S3 scan data. Regenerate with: dam s3-scan'
416
+
417
+ # Write updated manifest
418
+ File.write(manifest_path, JSON.pretty_generate(manifest))
419
+
420
+ puts "✅ Updated manifest with S3 data: #{manifest_path}"
421
+ puts " Scanned #{results.size} projects"
422
+ end
423
+
424
+ def scan_all_brands_s3
425
+ Appydave::Tools::Configuration::Config.configure
426
+ brands_config = Appydave::Tools::Configuration::Config.brands
427
+
428
+ brands_config.brands.each do |brand_info|
429
+ brand_key = brand_info.key
430
+ puts ''
431
+ puts '=' * 60
432
+ scan_single_brand_s3(brand_key)
433
+ end
434
+ end
435
+ ```
436
+
437
+ #### Task 2.3: Update ManifestGenerator to Support S3 Data
438
+
439
+ **Location:** `lib/appydave/tools/dam/manifest_generator.rb`
440
+
441
+ **Change:** Modify `build_project_entry` to accept optional S3 scan data
442
+
443
+ ```ruby
444
+ def build_project_entry(project_id, ssd_backup, ssd_available, s3_scan_data: nil)
445
+ # ... existing local/SSD detection code ...
446
+
447
+ # S3 detection (use scan data if available, otherwise check local folder)
448
+ s3_info = if s3_scan_data && s3_scan_data[project_id]
449
+ s3_scan_data[project_id]
450
+ else
451
+ # Fallback to local s3-staging folder check
452
+ s3_staging_path = File.join(local_path, 's3-staging')
453
+ s3_exists = local_exists && Dir.exist?(s3_staging_path)
454
+ { exists: s3_exists }
455
+ end
456
+
457
+ {
458
+ id: project_id,
459
+ type: type,
460
+ storage: {
461
+ ssd: { ... },
462
+ s3: s3_info, # Now includes file_count, total_bytes, last_modified if scanned
463
+ local: { ... }
464
+ }
465
+ }
466
+ end
467
+ ```
468
+
469
+ #### Task 2.4: Write Specs
470
+
471
+ **Location:** `spec/appydave/tools/dam/s3_scanner_spec.rb`
472
+
473
+ ```ruby
474
+ # frozen_string_literal: true
475
+
476
+ require 'spec_helper'
477
+
478
+ RSpec.describe Appydave::Tools::Dam::S3Scanner do
479
+ let(:brand) { 'appydave' }
480
+ let(:brand_info) { double('brand_info', key: 'appydave', aws: aws_config) }
481
+ let(:aws_config) do
482
+ double('aws_config',
483
+ profile: 'david-appydave',
484
+ region: 'ap-southeast-1',
485
+ s3_bucket: 'test-bucket',
486
+ s3_prefix: 'staging/v-appydave/')
487
+ end
488
+ let(:s3_client) { instance_double(Aws::S3::Client) }
489
+
490
+ subject { described_class.new(brand, brand_info: brand_info, s3_client: s3_client) }
491
+
492
+ describe '#scan_project' do
493
+ let(:project_id) { 'b65-test-project' }
494
+
495
+ context 'when project has files in S3' do
496
+ it 'returns S3 file data' do
497
+ # Mock S3 response
498
+ resp = double('response',
499
+ contents: [
500
+ double('object', size: 1000, last_modified: Time.now),
501
+ double('object', size: 2000, last_modified: Time.now - 3600)
502
+ ],
503
+ is_truncated: false)
504
+
505
+ allow(s3_client).to receive(:list_objects_v2).and_return(resp)
506
+
507
+ result = subject.scan_project(project_id)
508
+
509
+ expect(result[:exists]).to be true
510
+ expect(result[:file_count]).to eq 2
511
+ expect(result[:total_bytes]).to eq 3000
512
+ expect(result[:last_modified]).to be_a(String)
513
+ end
514
+ end
515
+
516
+ context 'when project has no files in S3' do
517
+ it 'returns empty data' do
518
+ resp = double('response', contents: [], is_truncated: false)
519
+ allow(s3_client).to receive(:list_objects_v2).and_return(resp)
520
+
521
+ result = subject.scan_project(project_id)
522
+
523
+ expect(result[:exists]).to be false
524
+ expect(result[:file_count]).to eq 0
525
+ end
526
+ end
527
+ end
528
+ end
529
+ ```
530
+
531
+ #### Task 2.5: Test with Real Data
532
+
533
+ ```bash
534
+ # 1. Generate base manifest (if not exists)
535
+ dam manifest appydave
536
+
537
+ # 2. Run S3 scan
538
+ dam s3-scan appydave
539
+
540
+ # 3. Verify manifest was updated
541
+ cat /Users/davidcruwys/dev/video-projects/v-appydave/projects.json | jq '.projects[0].storage.s3'
542
+
543
+ # Expected output:
544
+ # {
545
+ # "exists": true,
546
+ # "file_count": 15,
547
+ # "total_bytes": 1234567890,
548
+ # "last_modified": "2025-11-18T12:00:00Z"
549
+ # }
550
+ ```
551
+
552
+ ---
553
+
554
+ ### Phase 3: Project-Level Manifests
555
+
556
+ **Goal:** Generate detailed file tree for individual projects
557
+
558
+ #### Task 3.1: Create ProjectManifestGenerator Class
559
+
560
+ **Location:** `lib/appydave/tools/dam/project_manifest_generator.rb`
561
+
562
+ ```ruby
563
+ # frozen_string_literal: true
564
+
565
+ require 'json'
566
+ require 'fileutils'
567
+
568
+ module Appydave
569
+ module Tools
570
+ module Dam
571
+ # Generate detailed manifest for a single project
572
+ class ProjectManifestGenerator
573
+ attr_reader :brand, :project_id, :brand_path, :project_path
574
+
575
+ def initialize(brand, project_id)
576
+ @brand = brand
577
+ @project_id = project_id
578
+ @brand_path = Config.brand_path(brand)
579
+ @project_path = File.join(@brand_path, @project_id)
580
+
581
+ unless Dir.exist?(@project_path)
582
+ raise "Project not found: #{@project_path}"
583
+ end
584
+ end
585
+
586
+ def generate(output_file: nil)
587
+ output_file ||= File.join(project_path, '.project-manifest.json')
588
+
589
+ puts "📊 Generating project manifest for #{brand}/#{project_id}..."
590
+
591
+ # Build directory tree
592
+ tree = build_directory_tree(project_path)
593
+
594
+ # Determine project type
595
+ type = determine_project_type
596
+
597
+ # Build manifest
598
+ manifest = {
599
+ project_id: project_id,
600
+ brand: brand,
601
+ type: type,
602
+ generated_at: Time.now.utc.iso8601,
603
+ tree: tree
604
+ }
605
+
606
+ # Write to file
607
+ File.write(output_file, JSON.pretty_generate(manifest))
608
+
609
+ puts "✅ Generated #{output_file}"
610
+ puts " Total size: #{format_bytes(tree[:total_bytes])}"
611
+ puts " Total files: #{tree[:file_count]}"
612
+
613
+ manifest
614
+ end
615
+
616
+ private
617
+
618
+ def build_directory_tree(dir_path, max_depth: 3, current_depth: 0)
619
+ return nil if current_depth >= max_depth
620
+
621
+ entries = Dir.entries(dir_path).reject { |e| e.start_with?('.') }
622
+
623
+ subdirectories = {}
624
+ file_count = 0
625
+ total_bytes = 0
626
+
627
+ entries.each do |entry|
628
+ full_path = File.join(dir_path, entry)
629
+
630
+ if File.directory?(full_path)
631
+ # Recurse into subdirectory
632
+ subtree = build_directory_tree(full_path, max_depth: max_depth, current_depth: current_depth + 1)
633
+ if subtree
634
+ subdirectories[entry] = subtree
635
+ file_count += subtree[:file_count]
636
+ total_bytes += subtree[:total_bytes]
637
+ end
638
+ elsif File.file?(full_path)
639
+ # Count file
640
+ file_count += 1
641
+ total_bytes += File.size(full_path)
642
+ end
643
+ end
644
+
645
+ {
646
+ type: 'directory',
647
+ file_count: file_count,
648
+ total_bytes: total_bytes,
649
+ subdirectories: subdirectories
650
+ }
651
+ end
652
+
653
+ def determine_project_type
654
+ # Check for storyline.json
655
+ storyline_path = File.join(project_path, 'data', 'storyline.json')
656
+ return 'storyline' if File.exist?(storyline_path)
657
+
658
+ # Check for FliVideo pattern
659
+ return 'flivideo' if project_id =~ /^[a-z]\d{2}-/
660
+
661
+ 'general'
662
+ end
663
+
664
+ def format_bytes(bytes)
665
+ if bytes < 1024 * 1024
666
+ "#{(bytes / 1024.0).round(1)} KB"
667
+ elsif bytes < 1024 * 1024 * 1024
668
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
669
+ else
670
+ "#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
671
+ end
672
+ end
673
+ end
674
+ end
675
+ end
676
+ end
677
+ ```
678
+
679
+ #### Task 3.2: Add CLI Command
680
+
681
+ **Location:** `bin/dam`
682
+
683
+ ```ruby
684
+ def project_manifest_command(args)
685
+ args = args.reject { |arg| arg.start_with?('--') }
686
+ brand_arg = args[0]
687
+ project_arg = args[1]
688
+
689
+ if brand_arg.nil? || project_arg.nil?
690
+ puts 'Usage: dam project-manifest <brand> <project>'
691
+ puts ''
692
+ puts 'Generate detailed file tree manifest for a project.'
693
+ puts ''
694
+ puts 'Examples:'
695
+ puts ' dam project-manifest appydave b65'
696
+ puts ' dam project-manifest voz boy-baker'
697
+ exit 1
698
+ end
699
+
700
+ brand_key = brand_arg
701
+ project_id = Appydave::Tools::Dam::ProjectResolver.resolve(brand_arg, project_arg)
702
+
703
+ generator = Appydave::Tools::Dam::ProjectManifestGenerator.new(brand_key, project_id)
704
+ generator.generate
705
+ rescue StandardError => e
706
+ puts "❌ Error: #{e.message}"
707
+ exit 1
708
+ end
709
+ ```
710
+
711
+ #### Task 3.3: Add to .gitignore
712
+
713
+ **Location:** `/Users/davidcruwys/dev/ad/appydave-tools/.gitignore`
714
+
715
+ Add:
716
+ ```
717
+ # DAM project manifests (transient/generated)
718
+ .project-manifest.json
719
+ ```
720
+
721
+ ---
722
+
723
+ ### Phase 4: Bulk Operations
724
+
725
+ **Goal:** Add `manifest all` and `refresh` commands
726
+
727
+ #### Task 4.1: Implement `refresh` Command
728
+
729
+ **Location:** `bin/dam`
730
+
731
+ ```ruby
732
+ def refresh_command(args)
733
+ all_brands = args.include?('--all')
734
+ args = args.reject { |arg| arg.start_with?('--') }
735
+ brand_arg = args[0]
736
+
737
+ if all_brands
738
+ refresh_all_brands
739
+ elsif brand_arg
740
+ refresh_single_brand(brand_arg)
741
+ else
742
+ puts 'Usage: dam refresh <brand> [--all]'
743
+ puts ''
744
+ puts 'Refresh manifest and S3 scan data for a brand.'
745
+ puts ''
746
+ puts 'Examples:'
747
+ puts ' dam refresh appydave # Refresh AppyDave'
748
+ puts ' dam refresh --all # Refresh all brands'
749
+ exit 1
750
+ end
751
+ rescue StandardError => e
752
+ puts "❌ Error: #{e.message}"
753
+ exit 1
754
+ end
755
+
756
+ def refresh_single_brand(brand_arg)
757
+ puts "🔄 Refreshing #{brand_arg}..."
758
+
759
+ # Step 1: Generate manifest
760
+ generate_single_manifest(brand_arg)
761
+
762
+ # Step 2: Scan S3
763
+ scan_single_brand_s3(brand_arg)
764
+
765
+ puts ''
766
+ puts "✅ Refresh complete for #{brand_arg}"
767
+ end
768
+
769
+ def refresh_all_brands
770
+ Appydave::Tools::Configuration::Config.configure
771
+ brands_config = Appydave::Tools::Configuration::Config.brands
772
+
773
+ brands_config.brands.each do |brand_info|
774
+ brand_key = brand_info.key
775
+ puts ''
776
+ puts '=' * 60
777
+ refresh_single_brand(brand_key)
778
+ end
779
+ end
780
+ ```
781
+
782
+ #### Task 4.2: Update `manifest` Command to Support `all`
783
+
784
+ Already implemented in `bin/dam:195-210` ✅
785
+
786
+ ---
787
+
788
+ ## 🧪 Testing Strategy
789
+
790
+ ### Unit Tests
791
+
792
+ **Spec files to create:**
793
+ 1. `spec/appydave/tools/dam/s3_scanner_spec.rb` - Mock S3 client
794
+ 2. `spec/appydave/tools/dam/project_manifest_generator_spec.rb` - Use temp directories
795
+
796
+ **Example test structure:**
797
+
798
+ ```ruby
799
+ RSpec.describe Appydave::Tools::Dam::S3Scanner do
800
+ let(:s3_client) { instance_double(Aws::S3::Client) }
801
+ subject { described_class.new('appydave', s3_client: s3_client) }
802
+
803
+ describe '#scan_project' do
804
+ it 'handles empty S3 prefix' do
805
+ # ... mock S3 response with no files ...
806
+ end
807
+
808
+ it 'calculates total bytes correctly' do
809
+ # ... mock S3 response with multiple files ...
810
+ end
811
+
812
+ it 'handles pagination' do
813
+ # ... mock S3 response with is_truncated: true ...
814
+ end
815
+ end
816
+ end
817
+ ```
818
+
819
+ ### Integration Tests
820
+
821
+ **Manual testing with real data:**
822
+
823
+ ```bash
824
+ # Test S3 Scanner
825
+ dam s3-scan appydave
826
+ cat ~/dev/video-projects/v-appydave/projects.json | jq '.projects[0].storage.s3'
827
+
828
+ # Test Project Manifest
829
+ dam project-manifest appydave b65
830
+ cat ~/dev/video-projects/v-appydave/b65-*/. project-manifest.json | jq '.tree'
831
+
832
+ # Test Refresh
833
+ dam refresh appydave
834
+ ```
835
+
836
+ ### Test Data Setup
837
+
838
+ **Option 1: Use real brands**
839
+ - Test against `appydave` brand (David's machine)
840
+ - Test against `voz` brand (different S3 bucket)
841
+
842
+ **Option 2: Create test brand**
843
+ - Add test brand to `~/.config/appydave/brands.json`
844
+ - Use separate S3 bucket: `test-video-projects`
845
+ - Add test projects with known file counts
846
+
847
+ ---
848
+
849
+ ## 🚀 Implementation Sequence
850
+
851
+ **Recommended order (maximize value early):**
852
+
853
+ ### Week 1: S3 Scanning (Phase 2)
854
+ - **Day 1-2:** Create `S3Scanner` class and specs
855
+ - **Day 3:** Add CLI command handler
856
+ - **Day 4:** Test with real AppyDave data
857
+ - **Day 5:** Update `ManifestGenerator` to merge S3 data
858
+
859
+ **Deliverable:** `dam s3-scan appydave` command works, updates `projects.json` with real S3 file counts
860
+
861
+ ### Week 2: Project Manifests (Phase 3)
862
+ - **Day 1-2:** Create `ProjectManifestGenerator` class and specs
863
+ - **Day 3:** Add CLI command handler
864
+ - **Day 4:** Test with b65 project
865
+ - **Day 5:** Add to `.gitignore`, verify transient behavior
866
+
867
+ **Deliverable:** `dam project-manifest appydave b65` generates `.project-manifest.json` with tree
868
+
869
+ ### Week 3: Bulk Operations (Phase 4)
870
+ - **Day 1:** Implement `refresh` command
871
+ - **Day 2:** Test `refresh --all` with all brands
872
+ - **Day 3:** Performance optimization (parallel S3 scans?)
873
+ - **Day 4-5:** Documentation updates, edge case testing
874
+
875
+ **Deliverable:** `dam refresh --all` updates all 6 brands in one command
876
+
877
+ ### Week 4: Polish (Phase 5)
878
+ - **Day 1:** Add transcript detection to manifests
879
+ - **Day 2:** Add brand color configuration
880
+ - **Day 3:** Add project type confidence scores
881
+ - **Day 4-5:** Final testing, README updates
882
+
883
+ **Deliverable:** Enhanced manifests with all metadata fields
884
+
885
+ ---
886
+
887
+ ## 💡 Code Examples
888
+
889
+ ### Example 1: S3 Pagination Handling
890
+
891
+ ```ruby
892
+ def list_s3_objects(bucket, prefix)
893
+ objects = []
894
+ continuation_token = nil
895
+
896
+ loop do
897
+ resp = s3_client.list_objects_v2(
898
+ bucket: bucket,
899
+ prefix: prefix,
900
+ continuation_token: continuation_token
901
+ )
902
+
903
+ objects.concat(resp.contents)
904
+ break unless resp.is_truncated
905
+
906
+ continuation_token = resp.next_continuation_token
907
+ end
908
+
909
+ objects
910
+ end
911
+ ```
912
+
913
+ ### Example 2: Tree Builder Algorithm
914
+
915
+ ```ruby
916
+ def build_directory_tree(dir_path, max_depth: 3, current_depth: 0)
917
+ return nil if current_depth >= max_depth
918
+
919
+ subdirectories = {}
920
+ file_count = 0
921
+ total_bytes = 0
922
+
923
+ Dir.entries(dir_path).reject { |e| e.start_with?('.') }.each do |entry|
924
+ full_path = File.join(dir_path, entry)
925
+
926
+ if File.directory?(full_path)
927
+ subtree = build_directory_tree(full_path, max_depth: max_depth, current_depth: current_depth + 1)
928
+ if subtree
929
+ subdirectories[entry] = subtree
930
+ file_count += subtree[:file_count]
931
+ total_bytes += subtree[:total_bytes]
932
+ end
933
+ elsif File.file?(full_path)
934
+ file_count += 1
935
+ total_bytes += File.size(full_path)
936
+ end
937
+ end
938
+
939
+ {
940
+ type: 'directory',
941
+ file_count: file_count,
942
+ total_bytes: total_bytes,
943
+ subdirectories: subdirectories
944
+ }
945
+ end
946
+ ```
947
+
948
+ ### Example 3: Manifest Merging
949
+
950
+ ```ruby
951
+ # Load existing manifest
952
+ manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
953
+
954
+ # Scan S3
955
+ scanner = S3Scanner.new(brand)
956
+ s3_data = scanner.scan_all_projects
957
+
958
+ # Merge data
959
+ manifest[:projects].each do |project|
960
+ project_id = project[:id]
961
+ if s3_data.key?(project_id)
962
+ project[:storage][:s3].merge!(s3_data[project_id])
963
+ end
964
+ end
965
+
966
+ # Write back
967
+ File.write(manifest_path, JSON.pretty_generate(manifest))
968
+ ```
969
+
970
+ ---
971
+
972
+ ## ⚠️ Edge Cases & Error Handling
973
+
974
+ ### S3 Errors
975
+
976
+ ```ruby
977
+ rescue Aws::S3::Errors::NoSuchBucket => e
978
+ puts "❌ S3 bucket does not exist: #{bucket}"
979
+ puts " Check brands.json configuration for #{brand}"
980
+ exit 1
981
+ rescue Aws::S3::Errors::AccessDenied => e
982
+ puts "❌ Access denied to S3 bucket: #{bucket}"
983
+ puts " Verify AWS credentials for profile: #{profile_name}"
984
+ exit 1
985
+ rescue Aws::S3::Errors::ServiceError => e
986
+ puts "❌ S3 service error: #{e.message}"
987
+ exit 1
988
+ end
989
+ ```
990
+
991
+ ### Missing Manifests
992
+
993
+ ```ruby
994
+ unless File.exist?(manifest_path)
995
+ puts "❌ Manifest not found: #{manifest_path}"
996
+ puts " Run: dam manifest #{brand}"
997
+ puts " Then retry: dam s3-scan #{brand}"
998
+ exit 1
999
+ end
1000
+ ```
1001
+
1002
+ ### Empty Projects
1003
+
1004
+ ```ruby
1005
+ if projects.empty?
1006
+ puts "⚠️ No projects found for brand #{brand}"
1007
+ puts " This may indicate:"
1008
+ puts " - Brand directory is empty"
1009
+ puts " - SSD is not mounted"
1010
+ puts " - Configuration error"
1011
+ return { success: false, brand: brand, path: nil }
1012
+ end
1013
+ ```
1014
+
1015
+ ---
1016
+
1017
+ ## 📚 Dependencies
1018
+
1019
+ ### Ruby Gems (Already in Gemspec)
1020
+
1021
+ - ✅ `aws-sdk-s3` ~> 1 - S3 operations
1022
+ - ✅ `json` - JSON parsing/generation (built-in)
1023
+ - ✅ `fileutils` - File operations (built-in)
1024
+ - ✅ `digest` - MD5 hashing (built-in)
1025
+
1026
+ ### External
1027
+
1028
+ - AWS credentials configured in `~/.aws/credentials`
1029
+ - Brand configuration in `~/.config/appydave/brands.json`
1030
+
1031
+ ---
1032
+
1033
+ ## 🔗 Related Documentation
1034
+
1035
+ - [dam-cli-enhancements.md](dam-cli-enhancements.md) - Requirements specification
1036
+ - [dam-data-model.md](dam-data-model.md) - Entity schema and manifest structure
1037
+ - [implementation-roadmap.md](implementation-roadmap.md) - Epic organization
1038
+
1039
+ ---
1040
+
1041
+ **Last updated:** 2025-11-18