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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/CLAUDE.md +33 -0
- data/bin/dam +1 -1
- data/docs/ai-instructions/behavioral-regression-audit.md +659 -0
- data/docs/code-quality/behavioral-audit-2025-01-22.md +659 -0
- data/docs/code-quality/uat-plan-2025-01-22.md +374 -0
- data/lib/appydave/tools/dam/brand_resolver.rb +7 -1
- data/lib/appydave/tools/dam/errors.rb +9 -1
- data/lib/appydave/tools/dam/fuzzy_matcher.rb +63 -0
- data/lib/appydave/tools/dam/project_listing.rb +159 -43
- data/lib/appydave/tools/dam/s3_operations.rb +95 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -0
- data/package.json +1 -1
- metadata +5 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
data[:
|
|
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}"
|
data/lib/appydave/tools.rb
CHANGED
|
@@ -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
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.
|
|
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
|