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