appydave-tools 0.11.11 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4596ba8f5d43c23761a5375cb0329b89ca6b18bb32a03686fd3427d4a73086b
4
- data.tar.gz: 2a185916599f6c9d2578345b08bbc5e845fd0642f9bebab8d70c1b94ea4cf0cf
3
+ metadata.gz: e50556323b419ef5468e5199cf148812e25ff5c6001db7d35dc7f4cd52d7bb50
4
+ data.tar.gz: 23015d1c15d2fcde38f973fed02add0b7f55d09be3acda11f06705a15fcd6fdf
5
5
  SHA512:
6
- metadata.gz: 5f4a96ad81115e20d39615547a012d58a5c6925d6f0841d31f2f91265d0412e937610229fde478e65b529c5b8cef437eed2d22f1152c4e2ad42cfa5fb45c8983
7
- data.tar.gz: ae08e2209f871885fd94ae69530b1760cdf879dec9ac185cefb467e10a6bf5873fcbd33fe648f8f6c14603abe5962b7f00a15d8e23a8105aeef83a44d7cbac29
6
+ metadata.gz: 9f097fe81dae81d638bfa6040b44f9da187c95892a903a2e2c9e91c493134fd18fe0541e0b77daa99bfc56fa84378a1527d6d43f0955d74fdc038f3e9c4271a4
7
+ data.tar.gz: bdbf1c0ec19ce47042e17a16b8af96110e0e9b037fd61336debb32a5d251d80e50ce2259d4130b9bc220e9d95404b70c34b9ae9340e8ec557a1a595e0614e388
data/.rubocop.yml CHANGED
@@ -91,6 +91,7 @@ Metrics/ParameterLists:
91
91
  Exclude:
92
92
  - "**/spec/**/*"
93
93
  - "**/models/**/*"
94
+ - "lib/appydave/tools/subtitle_master/join.rb"
94
95
  Layout/EmptyLineBetweenDefs:
95
96
  Exclude:
96
97
  - "**/spec/**/*"
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.11.11](https://github.com/appydave/appydave-tools/compare/v0.11.10...v0.11.11) (2024-11-26)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * update clean srt specs ([e0da656](https://github.com/appydave/appydave-tools/commit/e0da656a3bf04f094bc3726a588a59d99d3d24a8))
7
+
1
8
  ## [0.11.10](https://github.com/appydave/appydave-tools/compare/v0.11.9...v0.11.10) (2024-11-26)
2
9
 
3
10
 
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CHAT: https://chatgpt.com/c/67038d52-9928-8002-8063-5616f7fe7aef
5
+
6
+ # !/usr/bin/env ruby
7
+
8
+ require 'fileutils'
9
+ require 'optparse'
10
+
11
+ # Default base directory
12
+ base_dir = '/Volumes/Expansion/Sync/tube-channels/video-projects'
13
+
14
+ # Common subfolder names (for reference, not enforced)
15
+ # Common subfolders: intro, outro, content, teaser, thumb
16
+
17
+ # Parse command-line options
18
+ options = {}
19
+ OptionParser.new do |opts|
20
+ opts.banner = "Usage: #{$PROGRAM_NAME} -f <folder> <section> <prefix>"
21
+
22
+ opts.on('-f FOLDER', '--folder FOLDER', 'Specify the subfolder under video-projects') do |folder|
23
+ options[:folder] = folder
24
+ end
25
+ end.parse!
26
+
27
+ # Ensure the folder option is provided
28
+ unless options[:folder]
29
+ puts 'You must specify a folder using the -f option'
30
+ exit 1
31
+ end
32
+
33
+ # Set source and destination directories
34
+ source_dir = File.expand_path('~/Sync/smart-downloads/download-images')
35
+ dest_dir = File.join(base_dir, options[:folder], 'assets')
36
+
37
+ # Get input parameters
38
+ section = ARGV[0]
39
+ prefix = ARGV[1]
40
+
41
+ unless prefix && section
42
+ puts "Usage: #{$PROGRAM_NAME} -f <folder> <section> <prefix>"
43
+ exit 1
44
+ end
45
+
46
+ # Ensure the section subfolder exists
47
+ section_dir = File.join(dest_dir, section)
48
+ puts "Creating subfolder if it doesn't exist: #{section_dir}"
49
+ FileUtils.mkdir_p(section_dir)
50
+
51
+ puts "Source directory: #{source_dir}"
52
+
53
+ # Find and move the images
54
+ Dir.glob("#{source_dir}/*.jpg").each_with_index do |file, index|
55
+ puts "Processing #{file}, #{index}"
56
+ new_filename = "#{prefix}-#{section}-#{index + 1}.jpg"
57
+ puts "New filename: #{new_filename}"
58
+ destination = File.join(section_dir, new_filename)
59
+ puts "Destination: #{destination}"
60
+ FileUtils.mv(file, destination)
61
+ puts "Moved #{file} to #{destination}"
62
+ end
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
+
6
+ require 'appydave/tools'
7
+
8
+ # Process command line arguments for SubtitleMaster operations
9
+ class SubtitleMasterCLI
10
+ def initialize
11
+ @commands = {
12
+ 'clean' => method(:clean_subtitles),
13
+ 'correct' => method(:correct_subtitles),
14
+ 'split' => method(:split_subtitles),
15
+ 'highlight' => method(:highlight_subtitles),
16
+ 'image_prompts' => method(:generate_image_prompts)
17
+ }
18
+ end
19
+
20
+ def run
21
+ command, *args = ARGV
22
+ if @commands.key?(command)
23
+ @commands[command].call(args)
24
+ else
25
+ puts "Unknown command: #{command}"
26
+ print_help
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def clean_subtitles(args)
33
+ options = parse_options(args, 'clean')
34
+ cleaner = Appydave::Tools::SubtitleMaster::Clean.new(file_path: options[:file])
35
+ result = cleaner.clean
36
+ write_output(result, options[:output])
37
+ end
38
+
39
+ def correct_subtitles(args)
40
+ options = parse_options(args, 'correct')
41
+ corrector = Appydave::Tools::SubtitleMaster::Correct.new(options[:file])
42
+ result = corrector.correct
43
+ write_output(result, options[:output])
44
+ end
45
+
46
+ def split_subtitles(args)
47
+ options = parse_options(args, 'split', %i[words_per_group])
48
+ splitter = Appydave::Tools::SubtitleMaster::Split.new(options[:file], options[:words_per_group])
49
+ result = splitter.split
50
+ write_output(result, options[:output])
51
+ end
52
+
53
+ def highlight_subtitles(args)
54
+ options = parse_options(args, 'highlight')
55
+ highlighter = Appydave::Tools::SubtitleMaster::Highlight.new(options[:file])
56
+ result = highlighter.highlight
57
+ write_output(result, options[:output])
58
+ end
59
+
60
+ def generate_image_prompts(args)
61
+ options = parse_options(args, 'image_prompts')
62
+ image_prompter = Appydave::Tools::SubtitleMaster::ImagePrompts.new(options[:file])
63
+ result = image_prompter.generate_prompts
64
+ write_output(result, options[:output])
65
+ end
66
+
67
+ def parse_options(args, command, extra_options = [])
68
+ options = { file: nil, output: nil }
69
+ OptionParser.new do |opts|
70
+ opts.banner = "Usage: subtitle_master.rb #{command} [options]"
71
+
72
+ opts.on('-f', '--file FILE', 'SRT file to process') { |v| options[:file] = v }
73
+ opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
74
+
75
+ extra_options.each do |opt|
76
+ case opt
77
+ when :words_per_group
78
+ opts.on('-w', '--words-per-group WORDS', 'Number of words per group for splitting') { |v| options[:words_per_group] = v.to_i }
79
+ end
80
+ end
81
+
82
+ opts.on_tail('-h', '--help', 'Show this message') do
83
+ puts opts
84
+ exit
85
+ end
86
+ end.parse!(args)
87
+
88
+ unless options[:file] && options[:output]
89
+ puts 'Missing required options. Use -h for help.'
90
+ exit
91
+ end
92
+
93
+ options
94
+ end
95
+
96
+ def write_output(result, output_file)
97
+ File.write(output_file, result)
98
+ puts "Processed file written to #{output_file}"
99
+ end
100
+
101
+ def print_help
102
+ puts 'Usage: subtitle_master.rb [command] [options]'
103
+ puts 'Commands:'
104
+ puts ' clean Clean and normalize SRT files'
105
+ puts ' correct Correct common typos and mistranslations in SRT files'
106
+ puts ' split Split subtitle groups based on word count'
107
+ puts ' highlight Highlight power words in subtitles'
108
+ puts ' image_prompts Generate image prompts from subtitle text'
109
+ puts "Run 'subtitle_master.rb [command] --help' for more information on a command."
110
+ end
111
+ end
112
+
113
+ SubtitleMasterCLI.new.run
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'optparse'
5
+
4
6
  $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
7
 
6
8
  require 'appydave/tools'
@@ -10,15 +12,18 @@ class SubtitleMasterCLI
10
12
  def initialize
11
13
  @commands = {
12
14
  'clean' => method(:clean_subtitles),
13
- 'correct' => method(:correct_subtitles),
14
- 'split' => method(:split_subtitles),
15
- 'highlight' => method(:highlight_subtitles),
16
- 'image_prompts' => method(:generate_image_prompts)
15
+ 'join' => method(:join_subtitles)
17
16
  }
18
17
  end
19
18
 
20
19
  def run
21
20
  command, *args = ARGV
21
+ if command.nil?
22
+ puts 'No command provided. Use -h for help.'
23
+ print_help
24
+ exit
25
+ end
26
+
22
27
  if @commands.key?(command)
23
28
  @commands[command].call(args)
24
29
  else
@@ -30,82 +35,116 @@ class SubtitleMasterCLI
30
35
  private
31
36
 
32
37
  def clean_subtitles(args)
33
- options = parse_options(args, 'clean')
38
+ options = { file: nil, output: nil }
39
+
40
+ # Command-specific option parser
41
+ clean_parser = OptionParser.new do |opts|
42
+ opts.banner = 'Usage: subtitle_master.rb clean [options]'
43
+
44
+ opts.on('-f', '--file FILE', 'SRT file to process') do |v|
45
+ options[:file] = v
46
+ end
47
+
48
+ opts.on('-o', '--output FILE', 'Output file') do |v|
49
+ options[:output] = v
50
+ end
51
+
52
+ opts.on('-h', '--help', 'Show this message') do
53
+ puts opts
54
+ exit
55
+ end
56
+ end
57
+
58
+ begin
59
+ clean_parser.parse!(args)
60
+ rescue OptionParser::InvalidOption => e
61
+ puts "Error: #{e.message}"
62
+ puts clean_parser
63
+ exit
64
+ end
65
+
66
+ # Validate required options
67
+ if options[:file].nil? || options[:output].nil?
68
+ puts 'Error: Missing required options.'
69
+ puts clean_parser
70
+ exit
71
+ end
72
+
73
+ # Assuming `Appydave::Tools::SubtitleMaster::Clean` exists
34
74
  cleaner = Appydave::Tools::SubtitleMaster::Clean.new(file_path: options[:file])
35
- result = cleaner.clean
36
- write_output(result, options[:output])
75
+ cleaner.clean
76
+ cleaner.write(options[:output])
37
77
  end
38
78
 
39
- def correct_subtitles(args)
40
- options = parse_options(args, 'correct')
41
- corrector = Appydave::Tools::SubtitleMaster::Correct.new(options[:file])
42
- result = corrector.correct
43
- write_output(result, options[:output])
44
- end
79
+ def join_subtitles(args)
80
+ options = {
81
+ folder: './',
82
+ files: '*.srt',
83
+ sort: 'inferred',
84
+ buffer: 100,
85
+ output: 'merged.srt',
86
+ verbose: false
87
+ }
45
88
 
46
- def split_subtitles(args)
47
- options = parse_options(args, 'split', %i[words_per_group])
48
- splitter = Appydave::Tools::SubtitleMaster::Split.new(options[:file], options[:words_per_group])
49
- result = splitter.split
50
- write_output(result, options[:output])
51
- end
89
+ join_parser = OptionParser.new do |opts|
90
+ opts.banner = 'Usage: subtitle_master.rb join [options]'
52
91
 
53
- def highlight_subtitles(args)
54
- options = parse_options(args, 'highlight')
55
- highlighter = Appydave::Tools::SubtitleMaster::Highlight.new(options[:file])
56
- result = highlighter.highlight
57
- write_output(result, options[:output])
58
- end
92
+ opts.on('-d', '--directory DIR', 'Directory containing SRT files (default: current directory)') do |v|
93
+ options[:folder] = v
94
+ end
59
95
 
60
- def generate_image_prompts(args)
61
- options = parse_options(args, 'image_prompts')
62
- image_prompter = Appydave::Tools::SubtitleMaster::ImagePrompts.new(options[:file])
63
- result = image_prompter.generate_prompts
64
- write_output(result, options[:output])
65
- end
96
+ opts.on('-f', '--files PATTERN', 'File pattern (e.g., "*.srt" or "part1.srt,part2.srt")') do |v|
97
+ options[:files] = v
98
+ end
66
99
 
67
- def parse_options(args, command, extra_options = [])
68
- options = { file: nil, output: nil }
69
- OptionParser.new do |opts|
70
- opts.banner = "Usage: subtitle_master.rb #{command} [options]"
100
+ opts.on('-s', '--sort ORDER', %w[asc desc inferred], 'Sort order (asc/desc/inferred)') do |v|
101
+ options[:sort] = v
102
+ end
71
103
 
72
- opts.on('-f', '--file FILE', 'SRT file to process') { |v| options[:file] = v }
73
- opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
104
+ opts.on('-b', '--buffer MS', Integer, 'Buffer between merged files in milliseconds') do |v|
105
+ options[:buffer] = v
106
+ end
107
+
108
+ opts.on('-o', '--output FILE', 'Output file') do |v|
109
+ options[:output] = v
110
+ end
74
111
 
75
- extra_options.each do |opt|
76
- case opt
77
- when :words_per_group
78
- opts.on('-w', '--words-per-group WORDS', 'Number of words per group for splitting') { |v| options[:words_per_group] = v.to_i }
79
- end
112
+ opts.on('-L', '--log-level LEVEL', %w[none info detail], 'Log level (default: info)') do |v|
113
+ options[:log_level] = v.to_sym
80
114
  end
81
115
 
82
- opts.on_tail('-h', '--help', 'Show this message') do
116
+ opts.on('-h', '--help', 'Show this message') do
83
117
  puts opts
84
118
  exit
85
119
  end
86
- end.parse!(args)
120
+ end
87
121
 
88
- unless options[:file] && options[:output]
89
- puts 'Missing required options. Use -h for help.'
122
+ begin
123
+ join_parser.parse!(args)
124
+ rescue OptionParser::InvalidOption => e
125
+ puts "Error: #{e.message}"
126
+ puts join_parser
90
127
  exit
91
128
  end
92
129
 
93
- options
94
- end
130
+ # Validate required options
131
+ if options[:folder].nil? || options[:files].nil? || options[:output].nil?
132
+ puts 'Error: Missing required options.'
133
+ puts join_parser
134
+ exit
135
+ end
95
136
 
96
- def write_output(result, output_file)
97
- File.write(output_file, result)
98
- puts "Processed file written to #{output_file}"
137
+ # Assuming `Appydave::Tools::SubtitleMaster::Join` exists
138
+ joiner = Appydave::Tools::SubtitleMaster::Join.new(folder: options[:folder], files: options[:files], sort: options[:sort], buffer: options[:buffer], output: options[:output],
139
+ log_level: options[:log_level])
140
+ joiner.join
99
141
  end
100
142
 
101
143
  def print_help
102
144
  puts 'Usage: subtitle_master.rb [command] [options]'
103
145
  puts 'Commands:'
104
146
  puts ' clean Clean and normalize SRT files'
105
- puts ' correct Correct common typos and mistranslations in SRT files'
106
- puts ' split Split subtitle groups based on word count'
107
- puts ' highlight Highlight power words in subtitles'
108
- puts ' image_prompts Generate image prompts from subtitle text'
147
+ puts ' join Join multiple SRT files'
109
148
  puts "Run 'subtitle_master.rb [command] --help' for more information on a command."
110
149
  end
111
150
  end
@@ -0,0 +1,166 @@
1
+
2
+ # Detailed Requirements Specification
3
+
4
+ ## Command-Line Tool
5
+
6
+ ### Purpose
7
+ A CLI tool named `Join` for merging multiple SRT files into one cohesive subtitle file, handling timestamp adjustment and preserving subtitle integrity.
8
+
9
+ ---
10
+
11
+ ### Parameters
12
+
13
+ 1. **`--folder <path>`**
14
+ - **Purpose**: Specifies the folder containing SRT files.
15
+ - **Default**: Current working directory (`./`).
16
+ - **Example**: `--folder /path/to/subtitles`.
17
+
18
+ 2. **`--files <pattern>`**
19
+ - **Purpose**: Specifies specific filenames or wildcard patterns for SRT files to process.
20
+ - **Default**: `*.srt` (processes all SRT files in the folder).
21
+ - **Logic**:
22
+ - Explicit filenames: `file1.srt file2.srt` → Process in provided order.
23
+ - Wildcard patterns: `*.srt` → Resolve matching files, sort by `--sort` order.
24
+
25
+ 3. **`--sort <inferred|asc|desc>`**
26
+ - **Purpose**: Defines how files are ordered for processing.
27
+ - **Default**: `inferred`
28
+ - **Logic**:
29
+ - `inferred`:
30
+ - Explicit filenames → Preserve order.
31
+ - Wildcards → Sort alphabetically (ascending).
32
+ - `asc`: Force alphabetical order.
33
+ - `desc`: Force reverse alphabetical order.
34
+
35
+ 4. **`--buffer <milliseconds>`**
36
+ - **Purpose**: Adds a buffer between the last subtitle of one file and the first subtitle of the next.
37
+ - **Default**: `100` (100ms).
38
+ - **Example**: `--buffer 50` for a 50ms gap.
39
+
40
+ 5. **`--output <output_file.srt>`**
41
+ - **Purpose**: Specifies the name of the output merged SRT file.
42
+ - **Default**: `merged.srt` in the current folder.
43
+ - **Example**: `--output /path/to/final_output.srt`.
44
+
45
+ 6. **`--verbose`**
46
+ - **Purpose**: Enables detailed logging for steps like file resolution, timestamp adjustments, and warnings for skipped files.
47
+
48
+ ---
49
+
50
+ ## Primary Component Logic
51
+
52
+ ### Design Overview
53
+ The tool should adhere to **Single Responsibility Principle (SRP)**, favor **composition**, and nest the composed components within the `Join` class for encapsulation and reusability.
54
+
55
+ ### Primary Class: `Join`
56
+ The `Join` class acts as the main entry point and coordinates the workflow. It contains the following nested components:
57
+
58
+ 1. **`FileResolver`**
59
+ - **Responsibility**: Handles file resolution (folder, filenames, wildcards, sorting).
60
+ - **Logic**:
61
+ - Resolve files based on `--files` or `--folder`.
62
+ - Apply sorting rules (`inferred`, `asc`, `desc`).
63
+
64
+ 2. **`SRTParser`**
65
+ - **Responsibility**: Parses SRT files into structured subtitle objects.
66
+ - **Logic**:
67
+ - Read and validate SRT content.
68
+ - Split into subtitle blocks (index, start time, end time, text).
69
+ - Convert timestamps into structured objects (seconds for calculations).
70
+
71
+ 3. **`SRTMerger`**
72
+ - **Responsibility**: Combines subtitles, adjusts timestamps, and applies buffers.
73
+ - **Logic**:
74
+ - Aggregate parsed subtitles from multiple files.
75
+ - Adjust timestamps for non-overlapping entries using the buffer.
76
+
77
+ 4. **`SRTWriter`**
78
+ - **Responsibility**: Converts subtitle objects back to SRT format and writes to disk.
79
+ - **Logic**:
80
+ - Reformat subtitles with proper numbering and timestamp formatting.
81
+ - Write the final merged content to the specified output file.
82
+
83
+ ---
84
+
85
+ ### Main Workflow
86
+
87
+ 1. **Parse Arguments**
88
+ - Validate input parameters and resolve defaults.
89
+
90
+ 2. **Resolve Files**
91
+ - Use `FileResolver` to identify files based on folder, filenames, or wildcard patterns.
92
+
93
+ 3. **Parse Files**
94
+ - Use `SRTParser` to parse each file into a list of structured subtitle objects.
95
+
96
+ 4. **Merge Files**
97
+ - Use `SRTMerger` to combine subtitles, sort by start time (per file order), and apply timestamp adjustments.
98
+
99
+ 5. **Write Output**
100
+ - Use `SRTWriter` to generate the final SRT file and save it to the specified location.
101
+
102
+ ---
103
+
104
+ ## Business Rules
105
+
106
+ 1. **File Selection**
107
+ - Resolve wildcards to file lists dynamically.
108
+ - Validate file existence and skip invalid or non-SRT files with appropriate warnings.
109
+
110
+ 2. **Timestamp Handling**
111
+ - Ensure non-overlapping subtitles across files by applying the buffer.
112
+ - Maintain relative timing within each file.
113
+
114
+ 3. **Sorting Logic**
115
+ - Preserve file order for explicit filenames.
116
+ - Apply alphabetical sorting for wildcards unless overridden by `--sort`.
117
+
118
+ 4. **Error Handling**
119
+ - Log and skip malformed SRT files.
120
+ - Notify users of missing files or unsupported patterns.
121
+
122
+ 5. **Output Consistency**
123
+ - Re-number subtitles sequentially in the final output.
124
+ - Ensure proper formatting with three decimal places for milliseconds.
125
+
126
+ ---
127
+
128
+ ## Tests
129
+
130
+ ### Unit Tests
131
+
132
+ 1. **FileResolver Tests**
133
+ - Resolve explicit filenames.
134
+ - Resolve wildcard patterns (`*.srt`) and sort files.
135
+ - Handle missing or invalid files gracefully.
136
+
137
+ 2. **SRTParser Tests**
138
+ - Parse valid SRT files into structured objects.
139
+ - Detect and handle malformed SRT files.
140
+ - Validate timestamp formatting.
141
+
142
+ 3. **SRTMerger Tests**
143
+ - Combine subtitles from two or more files.
144
+ - Ensure timestamps are adjusted with the buffer.
145
+ - Handle edge cases like overlapping timestamps or missing buffer.
146
+
147
+ 4. **SRTWriter Tests**
148
+ - Convert subtitle objects to valid SRT format.
149
+ - Ensure proper numbering and formatting (e.g., three decimal places).
150
+
151
+ ---
152
+
153
+ ### Integration Tests
154
+
155
+ 1. Process a folder of SRT files and produce a merged output.
156
+ 2. Validate behavior with explicit filenames and wildcards.
157
+ 3. Verify handling of buffers for different values (`0ms`, `50ms`, etc.).
158
+ 4. Test edge cases like empty files, malformed SRTs, or mixed valid/invalid files.
159
+
160
+ ---
161
+
162
+ ### End-to-End Tests
163
+
164
+ 1. Run the tool with minimal arguments and verify the output.
165
+ 2. Test all parameter combinations (`--folder`, `--files`, `--sort`, `--buffer`, etc.).
166
+ 3. Compare merged output against manually validated reference files.
@@ -5,6 +5,8 @@ module Appydave
5
5
  module SubtitleMaster
6
6
  # Clean and normalize subtitles
7
7
  class Clean
8
+ attr_reader :content
9
+
8
10
  def initialize(file_path: nil, srt_content: nil)
9
11
  if file_path && srt_content
10
12
  raise ArgumentError, 'You cannot provide both a file path and an SRT content stream.'
@@ -24,6 +26,15 @@ module Appydave
24
26
  normalize_lines(content)
25
27
  end
26
28
 
29
+ def write(output_file)
30
+ File.write(output_file, content)
31
+ puts "Processed file written to #{output_file}"
32
+ rescue Errno::EACCES
33
+ puts "Permission denied: Unable to write to #{output_file}"
34
+ rescue StandardError => e
35
+ puts "An error occurred while writing to the file: #{e.message}"
36
+ end
37
+
27
38
  private
28
39
 
29
40
  def remove_underscores(content)
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module SubtitleMaster
6
+ # Join multiple SRT files into one
7
+ # - Supports folder, wildcards, sorting via FileResolver
8
+ class Join
9
+ # Handles file resolution (folder, wildcards, sorting)
10
+ class FileResolver
11
+ def initialize(folder:, files:, sort:)
12
+ raise ArgumentError, 'folder is required' if folder.nil?
13
+ raise ArgumentError, 'files is required' if files.nil?
14
+
15
+ @folder = folder
16
+ @files = files
17
+ @sort = sort
18
+ end
19
+
20
+ def process
21
+ # Check if folder exists before processing
22
+ raise Errno::ENOENT, "No such directory - #{@folder}" unless Dir.exist?(@folder)
23
+
24
+ file_patterns = @files.split(',').map(&:strip)
25
+ resolved_files = file_patterns.flat_map { |pattern| resolve_pattern(pattern) }
26
+ sort_files(resolved_files)
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_pattern(pattern)
32
+ if pattern.include?('*')
33
+ Dir.glob(File.join(@folder, pattern))
34
+ else
35
+ file_path = File.join(@folder, pattern)
36
+ File.exist?(file_path) ? [file_path] : []
37
+ end
38
+ end
39
+
40
+ def sort_files(files)
41
+ case @sort
42
+ when 'asc'
43
+ files.sort
44
+ when 'desc'
45
+ files.sort.reverse
46
+ else # 'inferred'
47
+ # If explicit files were provided (no wildcards), maintain order
48
+ return files unless @files.include?('*')
49
+
50
+ files.sort
51
+ end
52
+ end
53
+ end
54
+
55
+ # Parses SRT files into structured subtitle objects
56
+ class SRTParser
57
+ # Represents a single subtitle entry
58
+ class Subtitle
59
+ attr_reader :index, :start_time, :end_time, :text
60
+
61
+ def initialize(index:, start_time:, end_time:, text:)
62
+ @index = index
63
+ @start_time = parse_timestamp(start_time)
64
+ @end_time = parse_timestamp(end_time)
65
+ @text = text.strip
66
+ end
67
+
68
+ private
69
+
70
+ # Converts SRT timestamp (00:00:00,000) to seconds (float)
71
+ def parse_timestamp(timestamp)
72
+ hours, minutes, seconds_ms = timestamp.split(':')
73
+ seconds, milliseconds = seconds_ms.split(',')
74
+
75
+ (hours.to_i * 3600) +
76
+ (minutes.to_i * 60) +
77
+ seconds.to_i +
78
+ (milliseconds.to_i / 1000.0)
79
+ end
80
+ end
81
+
82
+ def parse(content)
83
+ validate_content!(content)
84
+
85
+ subtitles = []
86
+ current_block = { text: [] }
87
+
88
+ content.split("\n").each do |line|
89
+ line = line.strip
90
+
91
+ if line.empty?
92
+ process_block(current_block, subtitles) if current_block[:index]
93
+ current_block = { text: [] }
94
+ next
95
+ end
96
+
97
+ if current_block[:index].nil?
98
+ current_block[:index] = line.to_i
99
+ elsif current_block[:timestamp].nil? && line.include?(' --> ')
100
+ start_time, end_time = line.split(' --> ')
101
+ current_block[:timestamp] = { start: start_time, end: end_time }
102
+ else
103
+ current_block[:text] << line
104
+ end
105
+ end
106
+
107
+ # Process the last block if it exists
108
+ process_block(current_block, subtitles) if current_block[:index]
109
+
110
+ subtitles
111
+ end
112
+
113
+ private
114
+
115
+ def validate_content!(content)
116
+ raise ArgumentError, 'Content cannot be nil' if content.nil?
117
+ raise ArgumentError, 'Content cannot be empty' if content.strip.empty?
118
+
119
+ # Basic structure validation - should have numbers and timestamps
120
+ return if content.match?(/\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/)
121
+
122
+ raise ArgumentError, 'Invalid SRT format: missing required timestamp format'
123
+ end
124
+
125
+ def process_block(block, subtitles)
126
+ return unless block[:index] && block[:timestamp] && !block[:text].empty?
127
+
128
+ subtitles << Subtitle.new(
129
+ index: block[:index],
130
+ start_time: block[:timestamp][:start],
131
+ end_time: block[:timestamp][:end],
132
+ text: block[:text].join("\n")
133
+ )
134
+ end
135
+ end
136
+
137
+ # Merges multiple subtitle arrays while maintaining timing and adding buffers
138
+ class SRTMerger
139
+ def initialize(buffer_ms: 100)
140
+ @buffer_ms = buffer_ms.to_f
141
+ end
142
+
143
+ def merge(subtitle_arrays)
144
+ return [] if subtitle_arrays.empty?
145
+
146
+ merged = []
147
+ current_end_time = 0.0
148
+
149
+ subtitle_arrays.each do |subtitles|
150
+ next if subtitles.empty?
151
+
152
+ # Calculate offset needed for this batch of subtitles
153
+ first_subtitle = subtitles.first
154
+ offset_seconds = calculate_offset(current_end_time, first_subtitle.start_time)
155
+
156
+ # Add adjusted subtitles to merged array
157
+ subtitles.each do |subtitle|
158
+ adjusted_subtitle = adjust_subtitle_timing(subtitle, offset_seconds)
159
+ merged << adjusted_subtitle
160
+ end
161
+
162
+ # Update current_end_time for next batch
163
+ current_end_time = merged.last.end_time
164
+ end
165
+
166
+ # Renumber subtitles sequentially
167
+ merged.each_with_index do |subtitle, index|
168
+ subtitle.instance_variable_set(:@index, index + 1)
169
+ end
170
+
171
+ merged
172
+ end
173
+
174
+ private
175
+
176
+ def calculate_offset(current_end_time, next_start_time)
177
+ return 0.0 if current_end_time.zero?
178
+
179
+ buffer_seconds = @buffer_ms / 1000.0
180
+ needed_offset = current_end_time + buffer_seconds - next_start_time
181
+ [needed_offset, 0].max
182
+ end
183
+
184
+ def adjust_subtitle_timing(subtitle, offset_seconds)
185
+ # Create new Subtitle instance with adjusted timing
186
+ SRTParser::Subtitle.new(
187
+ index: subtitle.index,
188
+ start_time: format_time(subtitle.start_time + offset_seconds),
189
+ end_time: format_time(subtitle.end_time + offset_seconds),
190
+ text: subtitle.text
191
+ )
192
+ end
193
+
194
+ def format_time(seconds)
195
+ # Convert seconds back to SRT timestamp format (00:00:00,000)
196
+ hours = (seconds / 3600).floor
197
+ minutes = ((seconds % 3600) / 60).floor
198
+ seconds_remaining = seconds % 60
199
+ milliseconds = ((seconds_remaining % 1) * 1000).round
200
+
201
+ format(
202
+ '%<hours>02d:%<minutes>02d:%<seconds>02d,%<milliseconds>03d',
203
+ hours: hours,
204
+ minutes: minutes,
205
+ seconds: seconds_remaining.floor,
206
+ milliseconds: milliseconds
207
+ )
208
+ end
209
+ end
210
+
211
+ # Converts subtitle objects back to SRT format and writes to disk
212
+ class SRTWriter
213
+ def initialize(output_file)
214
+ @output_file = output_file
215
+ end
216
+
217
+ def write(subtitles)
218
+ content = format_subtitles(subtitles)
219
+ File.write(@output_file, content, encoding: 'UTF-8')
220
+ end
221
+
222
+ private
223
+
224
+ def format_subtitles(subtitles)
225
+ subtitles.each_with_index.map do |subtitle, index|
226
+ [
227
+ index + 1, # Force sequential numbering
228
+ format_timestamp_line(subtitle),
229
+ subtitle.text,
230
+ '' # Empty line between subtitle blocks
231
+ ].join("\n")
232
+ end.join("\n")
233
+ end
234
+
235
+ def format_timestamp_line(subtitle)
236
+ "#{format_timestamp(subtitle.start_time)} --> #{format_timestamp(subtitle.end_time)}"
237
+ end
238
+
239
+ def format_timestamp(seconds)
240
+ hours = (seconds / 3600).floor
241
+ minutes = ((seconds % 3600) / 60).floor
242
+ seconds_remaining = seconds % 60
243
+ milliseconds = ((seconds_remaining % 1) * 1000).round
244
+
245
+ format(
246
+ '%<hours>02d:%<minutes>02d:%<seconds>02d,%<milliseconds>03d',
247
+ hours: hours,
248
+ minutes: minutes,
249
+ seconds: seconds_remaining.floor,
250
+ milliseconds: milliseconds
251
+ )
252
+ end
253
+ end
254
+
255
+ # Simple logger for debugging
256
+ class Logger
257
+ LEVELS = { none: 0, info: 1, detail: 2 }.freeze
258
+
259
+ def initialize(level = :info)
260
+ @level = LEVELS[level] || LEVELS[:info]
261
+ end
262
+
263
+ def log(level, message)
264
+ puts message if LEVELS[level] <= @level
265
+ end
266
+ end
267
+
268
+ def initialize(folder: './', files: '*.srt', sort: 'inferred', buffer: 100, output: 'merged.srt', log_level: :info)
269
+ @folder = folder
270
+ @files = files
271
+ @sort = sort
272
+ @buffer = buffer
273
+ @output = output
274
+ @logger = Logger.new(log_level)
275
+ end
276
+
277
+ def join
278
+ @logger.log(:info, "Starting join operation in folder: #{@folder} with files: #{@files}")
279
+ resolved_files = resolve_files
280
+ @logger.log(:info, "Resolved files: #{resolved_files.join(', ')}")
281
+
282
+ subtitle_groups = parse_files(resolved_files)
283
+ @logger.log(:detail, "Parsed subtitles: #{subtitle_groups.map(&:size)} from files.")
284
+
285
+ merged_subtitles = merge_subtitles(subtitle_groups)
286
+ @logger.log(:info, "Merged #{subtitle_groups.flatten.size} subtitles into #{merged_subtitles.size} blocks.")
287
+
288
+ write_output(merged_subtitles)
289
+ @logger.log(:info, "Output written to #{@output}")
290
+ end
291
+
292
+ private
293
+
294
+ def resolve_files
295
+ resolver = FileResolver.new(
296
+ folder: @folder,
297
+ files: @files,
298
+ sort: @sort
299
+ )
300
+ resolver.process
301
+ end
302
+
303
+ def parse_files(files)
304
+ files.map do |file|
305
+ content = File.read(file, encoding: 'UTF-8')
306
+ parse_srt_content(content)
307
+ end
308
+ end
309
+
310
+ def parse_srt_content(content)
311
+ parser = SRTParser.new
312
+ parser.parse(content)
313
+ end
314
+
315
+ def merge_subtitles(subtitle_groups)
316
+ merger = SRTMerger.new(buffer_ms: @buffer)
317
+ merger.merge(subtitle_groups)
318
+ end
319
+
320
+ def write_output(subtitles)
321
+ writer = SRTWriter.new(@output)
322
+ writer.write(subtitles)
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.11.11'
5
+ VERSION = '0.12.0'
6
6
  end
7
7
  end
@@ -44,18 +44,14 @@ require 'appydave/tools/configuration/configurable'
44
44
  require 'appydave/tools/configuration/config'
45
45
  require 'appydave/tools/configuration/models/config_base'
46
46
  require 'appydave/tools/configuration/models/settings_config'
47
- # require 'appydave/tools/configuration/models/bank_reconciliation_config'
48
47
  require 'appydave/tools/configuration/models/channels_config'
49
48
  require 'appydave/tools/configuration/models/youtube_automation_config'
50
49
  require 'appydave/tools/name_manager/project_name'
51
- # require 'appydave/tools/bank_reconciliation/clean/clean_transactions'
52
- # require 'appydave/tools/bank_reconciliation/clean/read_transactions'
53
- # require 'appydave/tools/bank_reconciliation/clean/mapper'
54
- # require 'appydave/tools/bank_reconciliation/models/transaction'
55
50
 
56
51
  require 'appydave/tools/prompt_tools/prompt_completion'
57
52
 
58
53
  require 'appydave/tools/subtitle_master/clean'
54
+ require 'appydave/tools/subtitle_master/join'
59
55
 
60
56
  require 'appydave/tools/youtube_automation/gpt_agent'
61
57
 
data/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.11.11",
3
+ "version": "0.12.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "appydave-tools",
9
- "version": "0.11.11",
9
+ "version": "0.12.0",
10
10
  "devDependencies": {
11
11
  "@klueless-js/semantic-release-rubygem": "github:klueless-js/semantic-release-rubygem",
12
12
  "@semantic-release/changelog": "^6.0.3",
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.11.11",
3
+ "version": "0.12.0",
4
4
  "description": "AppyDave YouTube Automation Tools",
5
5
  "scripts": {
6
6
  "release": "semantic-release"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appydave-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.11
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cruwys
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-11-26 00:00:00.000000000 Z
11
+ date: 2024-12-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -160,8 +160,10 @@ files:
160
160
  - bin/configuration.rb
161
161
  - bin/console
162
162
  - bin/gpt_context.rb
163
+ - bin/move_images.rb
163
164
  - bin/prompt_tools.rb
164
165
  - bin/setup
166
+ - bin/subtitle_master-old.rb
165
167
  - bin/subtitle_master.rb
166
168
  - bin/youtube_automation.rb
167
169
  - bin/youtube_manager.rb
@@ -193,9 +195,11 @@ files:
193
195
  - lib/appydave/tools/name_manager/project_name.rb
194
196
  - lib/appydave/tools/prompt_tools/_doc.md
195
197
  - lib/appydave/tools/prompt_tools/prompt_completion.rb
198
+ - lib/appydave/tools/subtitle_master/_doc-clean.md
199
+ - lib/appydave/tools/subtitle_master/_doc-join.md
196
200
  - lib/appydave/tools/subtitle_master/_doc-todo.md
197
- - lib/appydave/tools/subtitle_master/_doc.md
198
201
  - lib/appydave/tools/subtitle_master/clean.rb
202
+ - lib/appydave/tools/subtitle_master/join.rb
199
203
  - lib/appydave/tools/types/array_type.rb
200
204
  - lib/appydave/tools/types/base_model.rb
201
205
  - lib/appydave/tools/types/hash_type.rb