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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/bin/dam +137 -0
- data/docs/README.md +187 -90
- data/docs/architecture/dam/dam-cli-enhancements.md +642 -0
- data/docs/architecture/dam/dam-cli-implementation-guide.md +1041 -0
- data/docs/architecture/dam/dam-data-model.md +466 -0
- data/docs/architecture/dam/dam-visualization-requirements.md +641 -0
- data/docs/architecture/dam/implementation-roadmap.md +328 -0
- data/docs/architecture/dam/jan-collaboration-guide.md +309 -0
- data/lib/appydave/tools/dam/s3_operations.rb +57 -5
- data/lib/appydave/tools/dam/s3_scanner.rb +139 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -0
- data/package.json +1 -1
- metadata +37 -32
- data/docs/development/CODEX-recommendations.md +0 -258
- data/docs/development/README.md +0 -100
- /data/docs/{development/pattern-comparison.md → architecture/cli/cli-pattern-comparison.md} +0 -0
- /data/docs/{development/cli-architecture-patterns.md → architecture/cli/cli-patterns.md} +0 -0
- /data/docs/{project-brand-systems-analysis.md → architecture/configuration/configuration-systems.md} +0 -0
- /data/docs/{dam → architecture/dam}/dam-vision.md +0 -0
- /data/docs/{dam/prd-client-sharing.md → architecture/dam/design-decisions/002-client-sharing.md} +0 -0
- /data/docs/{dam/prd-git-integration.md → architecture/dam/design-decisions/003-git-integration.md} +0 -0
- /data/docs/{prd-unified-brands-configuration.md → architecture/design-decisions/001-unified-brands-config.md} +0 -0
- /data/docs/{dam/session-summary-2025-11-09.md → architecture/design-decisions/session-2025-11-09.md} +0 -0
- /data/docs/{configuration/README.md → guides/configuration-setup.md} +0 -0
- /data/docs/{dam → guides/platforms}/windows/README.md +0 -0
- /data/docs/{dam → guides/platforms}/windows/dam-testing-plan-windows-powershell.md +0 -0
- /data/docs/{dam → guides/platforms}/windows/installation.md +0 -0
- /data/docs/{tools → guides/tools}/bank-reconciliation.md +0 -0
- /data/docs/{tools → guides/tools}/cli-actions.md +0 -0
- /data/docs/{tools → guides/tools}/configuration.md +0 -0
- /data/docs/{dam → guides/tools/dam}/dam-testing-plan.md +0 -0
- /data/docs/{dam/usage.md → guides/tools/dam/dam-usage.md} +0 -0
- /data/docs/{tools → guides/tools}/gpt-context.md +0 -0
- /data/docs/{tools → guides/tools}/index.md +0 -0
- /data/docs/{tools → guides/tools}/move-images.md +0 -0
- /data/docs/{tools → guides/tools}/name-manager.md +0 -0
- /data/docs/{tools → guides/tools}/prompt-tools.md +0 -0
- /data/docs/{tools → guides/tools}/subtitle-processor.md +0 -0
- /data/docs/{tools → guides/tools}/youtube-automation.md +0 -0
- /data/docs/{tools → guides/tools}/youtube-manager.md +0 -0
- /data/docs/{configuration → templates}/.env.example +0 -0
- /data/docs/{configuration → templates}/channels.example.json +0 -0
- /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
|