astro-subframe-organizer 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +737 -0
  4. data/exe/astro-subframe-organizer +8 -0
  5. data/lib/astro_subframe_organizer/astrophoto.rb +122 -0
  6. data/lib/astro_subframe_organizer/commands/cleanup/empty_directories.rb +27 -0
  7. data/lib/astro_subframe_organizer/commands/cleanup/thumbnails.rb +38 -0
  8. data/lib/astro_subframe_organizer/commands/cleanup/unorganize.rb +25 -0
  9. data/lib/astro_subframe_organizer/commands/equipment_options.rb +30 -0
  10. data/lib/astro_subframe_organizer/commands/init.rb +54 -0
  11. data/lib/astro_subframe_organizer/commands/inspect.rb +101 -0
  12. data/lib/astro_subframe_organizer/commands/organize/base.rb +43 -0
  13. data/lib/astro_subframe_organizer/commands/organize/bias.rb +13 -0
  14. data/lib/astro_subframe_organizer/commands/organize/darks.rb +12 -0
  15. data/lib/astro_subframe_organizer/commands/organize/flats.rb +14 -0
  16. data/lib/astro_subframe_organizer/commands/organize/lights.rb +14 -0
  17. data/lib/astro_subframe_organizer/commands/raw/rename_from_exif.rb +45 -0
  18. data/lib/astro_subframe_organizer/commands/raw/revert_name.rb +28 -0
  19. data/lib/astro_subframe_organizer/commands/run.rb +28 -0
  20. data/lib/astro_subframe_organizer/commands/shared_options.rb +29 -0
  21. data/lib/astro_subframe_organizer/commands/version.rb +15 -0
  22. data/lib/astro_subframe_organizer/commands.rb +64 -0
  23. data/lib/astro_subframe_organizer/config.rb +140 -0
  24. data/lib/astro_subframe_organizer/equipment/camera.rb +14 -0
  25. data/lib/astro_subframe_organizer/equipment/filter.rb +14 -0
  26. data/lib/astro_subframe_organizer/equipment/telescope.rb +14 -0
  27. data/lib/astro_subframe_organizer/equipment_selector.rb +103 -0
  28. data/lib/astro_subframe_organizer/file_metadata.rb +135 -0
  29. data/lib/astro_subframe_organizer/file_set.rb +107 -0
  30. data/lib/astro_subframe_organizer/filename_parser.rb +106 -0
  31. data/lib/astro_subframe_organizer/filename_parsers/cr2_filename_parser.rb +67 -0
  32. data/lib/astro_subframe_organizer/filename_parsers/fits_filename_parser.rb +67 -0
  33. data/lib/astro_subframe_organizer/filename_parsers/fits_header_parser.rb +120 -0
  34. data/lib/astro_subframe_organizer/fits_organizer.rb +154 -0
  35. data/lib/astro_subframe_organizer/logging.rb +10 -0
  36. data/lib/astro_subframe_organizer/organizer.rb +125 -0
  37. data/lib/astro_subframe_organizer/path_builder.rb +55 -0
  38. data/lib/astro_subframe_organizer/path_builders/base_path_builder.rb +33 -0
  39. data/lib/astro_subframe_organizer/path_builders/bias_path_builder.rb +38 -0
  40. data/lib/astro_subframe_organizer/path_builders/dark_path_builder.rb +61 -0
  41. data/lib/astro_subframe_organizer/path_builders/flat_path_builder.rb +48 -0
  42. data/lib/astro_subframe_organizer/path_builders/light_path_builder.rb +53 -0
  43. data/lib/astro_subframe_organizer/utils/empty_directory_cleaner.rb +36 -0
  44. data/lib/astro_subframe_organizer/utils/exif_renamer.rb +132 -0
  45. data/lib/astro_subframe_organizer/utils/exposure_format.rb +25 -0
  46. data/lib/astro_subframe_organizer/utils/file_utils.rb +10 -0
  47. data/lib/astro_subframe_organizer/utils/fits_stripper.rb +115 -0
  48. data/lib/astro_subframe_organizer/utils/raw_stripper.rb +47 -0
  49. data/lib/astro_subframe_organizer/utils/thumbnail_cleaner.rb +26 -0
  50. data/lib/astro_subframe_organizer/utils/unorganizer.rb +54 -0
  51. data/lib/astro_subframe_organizer/version.rb +5 -0
  52. data/lib/astro_subframe_organizer.rb +103 -0
  53. metadata +182 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ require 'astro_subframe_organizer/commands/shared_options'
6
+ require 'astro_subframe_organizer/commands/equipment_options'
7
+ require 'astro_subframe_organizer/commands/init'
8
+ require 'astro_subframe_organizer/commands/run'
9
+ require 'astro_subframe_organizer/commands/version'
10
+ require 'astro_subframe_organizer/commands/inspect'
11
+ require 'astro_subframe_organizer/commands/organize/base'
12
+ require 'astro_subframe_organizer/commands/organize/lights'
13
+ require 'astro_subframe_organizer/commands/organize/darks'
14
+ require 'astro_subframe_organizer/commands/organize/flats'
15
+ require 'astro_subframe_organizer/commands/organize/bias'
16
+ require 'astro_subframe_organizer/commands/raw/rename_from_exif'
17
+ require 'astro_subframe_organizer/commands/raw/revert_name'
18
+ require 'astro_subframe_organizer/commands/cleanup/thumbnails'
19
+ require 'astro_subframe_organizer/commands/cleanup/empty_directories'
20
+ require 'astro_subframe_organizer/commands/cleanup/unorganize'
21
+
22
+ module AstroSubframeOrganizer
23
+ # The main command registry for the AstroSubframeOrganizer CLI, using Dry::CLI to define and
24
+ # organize commands and their aliases. This module registers all the available commands for
25
+ # the CLI, including:
26
+ #
27
+ # - Version display
28
+ # - Initialization of configuration
29
+ # - Running the organizer in interactive mode
30
+ # - Inspecting file metadata
31
+ # - Organizing different types of subframes (lights, darks, flats, biases)
32
+ # - Renaming RAW files based on EXIF data
33
+ # - Cleaning up thumbnails and empty directories
34
+ # - Reverting organization changes
35
+ module Commands
36
+ extend Dry::CLI::Registry
37
+
38
+ register 'version', Version, aliases: ['v', '-v', '--version']
39
+ register 'init', Init, aliases: ['--init']
40
+ register 'run', Run, aliases: %w[-i --interactive]
41
+ register 'inspect', Inspect, aliases: %w[metadata view headers]
42
+
43
+ register 'lights', Organize::Lights, aliases: %w[light --light --lights]
44
+ register 'darks', Organize::Darks, aliases: %w[dark --dark --darks]
45
+ register 'flats', Organize::Flats, aliases: %w[flat --flat --flats]
46
+ register 'biases', Organize::Bias, aliases: %w[bias --bias --biases]
47
+ register 'unorganize', Cleanup::Unorganize, aliases: %w[reset revert undo]
48
+
49
+ register 'cleanup', aliases: %w[clean] do |prefix|
50
+ prefix.register 'thumbnails',
51
+ Cleanup::Thumbnails,
52
+ aliases: %w[thn thm th thumbs]
53
+
54
+ prefix.register 'empty-directories',
55
+ Cleanup::EmptyDirectories,
56
+ aliases: %w[empty empties empty-folders]
57
+ end
58
+
59
+ register 'raw' do |prefix|
60
+ prefix.register 'rename', Raw::RenameFromExif, aliases: %w[autoname]
61
+ prefix.register 'revert', Raw::RevertToRaw, aliases: %w[undo reset]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ # Ensure standard streams are unbuffered for reliable output capture in CI (especially Windows)
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ module AstroSubframeOrganizer
10
+ # Configuration management for user-customizable options
11
+ class Config
12
+ DEFAULT_CONFIG = {
13
+ 'telescopes' => %w[
14
+ RedCat51
15
+ ZhumellZ130
16
+ AperturaAD8
17
+ MeadeDS90
18
+ CanonEFS1855
19
+ ],
20
+ 'filters' => %w[
21
+ BaaderMoon
22
+ NBZ
23
+ NoFilter
24
+ ],
25
+ 'cameras' => [
26
+ 'T7',
27
+ '183MC',
28
+ 'ZWO ASI183MC Pro',
29
+ 'Canon EOS 1500D',
30
+ ],
31
+ 'temperature_tolerance' => 5.0,
32
+ 'fits_extensions' => %w[.fit .fits .fts],
33
+ 'raw_extensions' => %w[.cr2 .cr3 .nef .arw .orf .raf .dng],
34
+ 'exif_tag_mappings' => {
35
+ 'temperature' => %i[camera_temperature sensor_temperature ambient_temperature],
36
+ 'iso' => %i[iso base_iso],
37
+ 'exposure' => %i[exposure_time],
38
+ 'model' => %i[model],
39
+ 'timestamp' => %i[date_time_original],
40
+ },
41
+ 'fits_header_mappings' => {
42
+ 'temperature' => %w[CCD-TEMP SET-TEMP TEMP],
43
+ 'gain' => %w[GAIN GAINVAL],
44
+ 'exposure' => %w[EXPOSURE EXPTIME],
45
+ 'filter' => %w[FILTER FILTERNAME],
46
+ 'telescope' => %w[TELESCOP],
47
+ 'target' => %w[OBJECT TARGET],
48
+ 'camera' => %w[INSTRUME],
49
+ 'binning' => %w[XBINNING CCDXBIN BINNING],
50
+ 'type' => %w[IMAGETYP FRAME],
51
+ 'date_obs' => %w[DATE-OBS DATE],
52
+ 'rotation' => %w[ROTATANG ANGLE POSANGLE ROTATOR ROTAT OBJCTROT CCDROTSA],
53
+ 'iso' => %w[ISO],
54
+ },
55
+ 'telescope_ignore_patterns' => [
56
+ 'Mount',
57
+ 'EQMod',
58
+ 'AM5',
59
+ 'AM3',
60
+ 'RST-135',
61
+ 'Star Adventurer',
62
+ ],
63
+ }.freeze
64
+
65
+ def self.custom_config_file
66
+ ENV.fetch('ASTRO_SUBFRAME_ORGANIZER_CONFIG', nil)
67
+ end
68
+
69
+ # Returns the expanded path to the configuration file.
70
+ def self.config_file
71
+ path = custom_config_file || '~/astro-subframe-organizer-config.yml'
72
+ File.expand_path(path)
73
+ end
74
+
75
+ def self.load
76
+ @load ||=
77
+ if File.exist?(config_file)
78
+ # Normalize path separators for consistent logging/testing across platforms
79
+ AstroSubframeOrganizer.logger.info "Using config file at #{config_file.tr('\\', '/')}"
80
+ DEFAULT_CONFIG.merge(YAML.safe_load_file(config_file, permitted_classes: [Symbol, DateTime]))
81
+ elsif custom_config_file
82
+ AstroSubframeOrganizer.logger.error("Unable to find #{config_file.tr('\\', '/')}. Check path and try again.")
83
+ exit(1)
84
+ else
85
+ AstroSubframeOrganizer.logger.info "Using config file at #{config_file.tr('\\', '/')}"
86
+ DEFAULT_CONFIG
87
+ end
88
+ rescue StandardError => e
89
+ AstroSubframeOrganizer.logger.error("Failed to parse #{config_file}: #{e}")
90
+ end
91
+
92
+ def self.all_telescopes
93
+ load['telescopes']
94
+ end
95
+
96
+ def self.all_filters
97
+ load['filters']
98
+ end
99
+
100
+ def self.all_cameras
101
+ load['cameras']
102
+ end
103
+
104
+ def self.temperature_tolerance
105
+ load['temperature_tolerance']&.to_f || 5.0
106
+ end
107
+
108
+ def self.fits_extensions
109
+ load['fits_extensions'] || DEFAULT_CONFIG['fits_extensions']
110
+ end
111
+
112
+ def self.raw_extensions
113
+ load['raw_extensions'] || DEFAULT_CONFIG['raw_extensions']
114
+ end
115
+
116
+ def self.exif_tag_mappings
117
+ load['exif_tag_mappings'] || DEFAULT_CONFIG['exif_tag_mappings']
118
+ end
119
+
120
+ def self.fits_header_mappings
121
+ load['fits_header_mappings'] || DEFAULT_CONFIG['fits_header_mappings']
122
+ end
123
+
124
+ def self.telescope_ignore_patterns
125
+ patterns = load['telescope_ignore_patterns'] || DEFAULT_CONFIG['telescope_ignore_patterns']
126
+ patterns.map { |p| Regexp.new(Regexp.escape(p), Regexp::IGNORECASE) }
127
+ end
128
+
129
+ def self.create_default_config
130
+ # Only include basic equipment and settings in the generated template.
131
+ # Advanced mappings and ignore patterns are handled via application defaults
132
+ # unless explicitly overridden by the user.
133
+ template = DEFAULT_CONFIG.slice(
134
+ 'telescopes', 'filters', 'cameras', 'temperature_tolerance'
135
+ )
136
+
137
+ File.write(config_file, template.to_yaml)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Equipment
5
+ # Add your filters to `~/astro-subframe-organizer-config.yml`. If there is more
6
+ # than one, and no camera is automatically detected, you will be prompted
7
+ # to choose one of them when organizing subframes.
8
+ class Camera
9
+ def self.all
10
+ Config.all_cameras
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Equipment
5
+ # Add your filters to `~/astro-subframe-organizer-config.yml`. If there is more
6
+ # than one, you will be prompted to choose one of them when organizing flats
7
+ # and lights.
8
+ class Filter
9
+ def self.all
10
+ Config.all_filters
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Equipment
5
+ # Add your telescopes to `~/astro-subframe-organizer-config.yml`. If there is more
6
+ # than one, you will be prompted to choose one of them when organizing flats
7
+ # and lights.
8
+ class Telescope
9
+ def self.all
10
+ Config.all_telescopes
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Handles interactive selection of equipment based on configuration and FITS header detection.
5
+ class EquipmentSelector
6
+ include Equipment
7
+ include Logging
8
+
9
+ attr_accessor :telescope, :camera, :filter
10
+
11
+ def initialize(
12
+ prompt = AstroSubframeOrganizer.prompt,
13
+ telescopes: Telescope.all,
14
+ cameras: Camera.all,
15
+ filters: Filter.all
16
+ )
17
+ @telescopes = telescopes
18
+ @cameras = cameras
19
+ @filters = filters
20
+ @prompt = prompt
21
+ end
22
+
23
+ def choose_telescope(index = nil)
24
+ generic_choose(telescope, @telescopes, 'telescope', index, 'What telescope is this set for?')
25
+ end
26
+
27
+ def choose_telescope_or_confirm(detected:)
28
+ if detected && Config.telescope_ignore_patterns.any? { |p| detected.match?(p) }
29
+ logger.info "Ignoring detected mount name: '#{detected}'"
30
+ detected = nil
31
+ end
32
+
33
+ if detected
34
+ suggestion = "If '#{detected}' is a mount name, consider adding it to 'telescope_ignore_patterns' " \
35
+ 'in your config.'
36
+ end
37
+ generic_choose_or_confirm(telescope, @telescopes, 'telescope', 'TELESCOP', detected, suggestion: suggestion)
38
+ end
39
+
40
+ def choose_camera(index = nil)
41
+ generic_choose(camera, @cameras, 'camera', index)
42
+ end
43
+
44
+ def choose_camera_or_confirm(detected:)
45
+ generic_choose_or_confirm(camera, @cameras, 'camera', 'INSTRUME', detected)
46
+ end
47
+
48
+ def choose_filter(index = nil)
49
+ generic_choose(filter, @filters, 'filter', index)
50
+ end
51
+
52
+ def choose_filter_or_confirm(detected:)
53
+ generic_choose_or_confirm(filter, @filters, 'filter', 'FILTER', detected)
54
+ end
55
+
56
+ private
57
+
58
+ def generic_choose(current_value, collection, label, index = nil, custom_prompt = nil)
59
+ return current_value if current_value
60
+
61
+ if index && collection[index]
62
+ collection[index]
63
+ elsif collection.one?
64
+ collection.first
65
+ else
66
+ prompt_text = custom_prompt || "What #{label} is used with this set?"
67
+ choose(prompt_text, collection)
68
+ end.tap { |chosen| logger.info("Selected #{label.capitalize}: #{chosen}") }
69
+ end
70
+
71
+ def generic_choose_or_confirm(
72
+ current_value,
73
+ collection,
74
+ label,
75
+ header_name,
76
+ detected,
77
+ suggestion: nil
78
+ )
79
+ if current_value
80
+ logger.warn "Using #{label} #{current_value}, but detected #{detected}" if detected && current_value != detected
81
+ return current_value
82
+ end
83
+
84
+ if detected && collection.include?(detected)
85
+ detected
86
+ elsif detected
87
+ logger.warn "#{header_name} header '#{detected}' is not in the configured #{label} list."
88
+ logger.info suggestion if suggestion
89
+ choose(
90
+ "#{header_name} is '#{detected}' — select the actual #{label} or confirm:",
91
+ [detected] + collection,
92
+ ).tap { |chosen| logger.info("Selected #{label.capitalize}: #{chosen}") }
93
+ else
94
+ logger.warn "#{label.capitalize} auto-detect failed."
95
+ send("choose_#{label}")
96
+ end
97
+ end
98
+
99
+ def choose(prompt, options)
100
+ @prompt.enum_select prompt, options
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Value object representing parsed metadata from an astrophotography file
5
+ class FileMetadata
6
+ attr_accessor :path, :type, :target, :camera, :telescope, :filter, :dark_flat
7
+ attr_reader :filename,
8
+ :file_format,
9
+ :exposure,
10
+ :bin,
11
+ :gain,
12
+ :iso,
13
+ :created_at,
14
+ :ccd_temp,
15
+ :image_index,
16
+ :mosaic_pane,
17
+ :rotation
18
+
19
+ def initialize( # rubocop:disable Metrics/ParameterLists
20
+ type:, path:, filename:, file_format:, exposure: nil, bin: nil, camera: nil,
21
+ iso: nil, gain: nil, created_at: nil, ccd_temp: nil, image_index: nil,
22
+ target: nil, telescope: nil, filter: nil, dark_flat: false, mosaic_pane: nil,
23
+ rotation: nil
24
+ )
25
+ @type = type
26
+ @path = path
27
+ @filename = filename
28
+ @file_format = file_format
29
+ @exposure = exposure
30
+ @bin = bin
31
+ @camera = camera
32
+ @iso = iso
33
+ @gain = gain
34
+ @created_at = created_at
35
+ @ccd_temp = ccd_temp
36
+ @image_index = image_index
37
+ @target = target
38
+ @telescope = telescope
39
+ @filter = filter
40
+ @dark_flat = dark_flat
41
+ @mosaic_pane = mosaic_pane
42
+ @rotation = rotation
43
+ end
44
+
45
+ # Factory method to create FileMetadata from parsed parser result
46
+ def self.from_parsed_data(parsed_data)
47
+ return parsed_data if parsed_data.instance_of? FileMetadata
48
+
49
+ new(
50
+ type: parsed_data[:type],
51
+ path: parsed_data[:path],
52
+ filename: parsed_data[:filename],
53
+ file_format: parsed_data[:file_format],
54
+ target: parsed_data[:target],
55
+ mosaic_pane: parsed_data[:mosaic_pane],
56
+ exposure: parsed_data[:exposure],
57
+ bin: parsed_data[:bin],
58
+ camera: parsed_data[:camera],
59
+ iso: parsed_data[:iso],
60
+ gain: parsed_data[:gain],
61
+ created_at: parsed_data[:created_at],
62
+ ccd_temp: parsed_data[:ccd_temp],
63
+ image_index: parsed_data[:image_index],
64
+ telescope: parsed_data[:telescope],
65
+ filter: parsed_data[:filter],
66
+ dark_flat: parsed_data[:dark_flat] || false,
67
+ rotation: parsed_data[:rotation],
68
+ )
69
+ end
70
+
71
+ def dark_flat?
72
+ dark_flat
73
+ end
74
+
75
+ # True if the dark is likely a dark flat and hasn't already been organized as dark flat
76
+ def maybe_flat_dark?
77
+ exp_val = exposure.to_f
78
+ exp_units = exposure.gsub(exp_val.to_s, '')
79
+ exp_in_seconds = case exp_units
80
+ when 's'
81
+ exp_val
82
+ when 'ms'
83
+ exp_val / 1000.0
84
+ when 'us'
85
+ exp_val / 1_000_000.0
86
+ end
87
+ type == 'Dark' && exp_in_seconds <= 10.0 && !dark_flat?
88
+ end
89
+
90
+ # The date formatted like '20220508'. If pictures taken in latter half of day,
91
+ # assume flatset will be generated the next day
92
+ def flatset_id
93
+ if type == 'Light' && created_at.hour >= 12
94
+ created_at.next_day.strftime('%Y%m%d')
95
+ else
96
+ created_at.strftime('%Y%m%d')
97
+ end
98
+ end
99
+
100
+ # The Year-Month in which image was taken. Useful for grouping darks by season
101
+ def month
102
+ created_at.strftime('%Y-%m')
103
+ end
104
+
105
+ # Current directory of the file
106
+ def current_dir
107
+ File.dirname(path)
108
+ end
109
+
110
+ # True if the path is already at target destination
111
+ def already_moved?(target_path)
112
+ p1 = File.expand_path(path)
113
+ p2 = File.expand_path(target_path)
114
+
115
+ # On Windows, path comparison should be case-insensitive
116
+ Gem.win_platform? ? p1.casecmp?(p2) : p1 == p2
117
+ end
118
+
119
+ def rounded_ccd_temp(tolerance: Config.temperature_tolerance)
120
+ return nil if ccd_temp.nil?
121
+
122
+ temp_value = ccd_temp.to_f
123
+ rounded = (temp_value / tolerance).round * tolerance
124
+ # Master darks output by PixInsight have CCD-TEMP formatted `CCD-TEMP_-10.` for some reason.
125
+ format('%.1fC', rounded).gsub('0C', '')
126
+ end
127
+
128
+ def normalized_rotation
129
+ return nil if rotation.nil?
130
+
131
+ angle = rotation.to_i % 180
132
+ format('%d', angle)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Value object representing a set of files that belong together based on type and metadata.
5
+ class FileSet
6
+ include Enumerable
7
+
8
+ attr_reader :files
9
+
10
+ def initialize(files)
11
+ @files = files
12
+ end
13
+
14
+ # Factory method to create list of FileSet instances from a list of FileMetadata objects,
15
+ # grouping them by type and relevant metadata.
16
+ def self.from_files(files, type:)
17
+ files.select { |file| file.type == type }
18
+ # Normalize path for sorting: unified separators and consistent case (for Windows)
19
+ .sort_by { |file| [File.dirname(file.path).tr('\\', '/').downcase, file.filename.downcase] }
20
+ .slice_when { |a, b| new_group?(a, b) }
21
+ .map { |group| new(group) }
22
+ end
23
+
24
+ def self.new_group?(first, second)
25
+ first.image_index.to_i > second.image_index.to_i ||
26
+ first.camera != second.camera ||
27
+ first.telescope != second.telescope ||
28
+ first.filter != second.filter
29
+ end
30
+
31
+ def name
32
+ "#{type} set #{files.first.filename}..#{files.last.filename}"
33
+ end
34
+
35
+ def each(&)
36
+ @files.each(&)
37
+ end
38
+
39
+ def size
40
+ @files.size
41
+ end
42
+
43
+ def type
44
+ files.first.type
45
+ end
46
+
47
+ def current_dir
48
+ files.first.current_dir
49
+ end
50
+
51
+ def already_moved?
52
+ files.all?(&:already_moved?)
53
+ end
54
+
55
+ def all_unmoved?
56
+ files.all? { |file| file.path != file.target_path }
57
+ end
58
+
59
+ def any_unmoved?
60
+ files.any? { |file| file.path != file.target_path }
61
+ end
62
+
63
+ def camera_candidates
64
+ files.filter_map(&:camera).uniq
65
+ end
66
+
67
+ def camera
68
+ camera_candidates.one? ? camera_candidates.first : nil
69
+ end
70
+
71
+ def apply_camera!(camera)
72
+ files.each { |file| file.camera = camera }
73
+ end
74
+
75
+ def telescope_candidates
76
+ files.filter_map(&:telescope).uniq
77
+ end
78
+
79
+ def telescope
80
+ telescope_candidates.one? ? telescope_candidates.first : nil
81
+ end
82
+
83
+ def apply_telescope!(telescope)
84
+ files.each { |file| file.telescope = telescope }
85
+ end
86
+
87
+ def filter_candidates
88
+ files.filter_map(&:filter).uniq
89
+ end
90
+
91
+ def filter
92
+ filter_candidates.one? ? filter_candidates.first : nil
93
+ end
94
+
95
+ def apply_filter!(filter)
96
+ files.each { |file| file.filter = filter }
97
+ end
98
+
99
+ def maybe_flat_dark?
100
+ files.all?(&:maybe_flat_dark?)
101
+ end
102
+
103
+ def mark_dark_flat!
104
+ files.each { |file| file.dark_flat = true }
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module AstroSubframeOrganizer
6
+ # Base class for parsing astrophotography filenames.
7
+ #
8
+ # This class uses the Strategy pattern to handle format-specific filename parsing.
9
+ # The factory method `for_file` creates the appropriate parser subclass based on file extension.
10
+ #
11
+ # Subclasses must implement the `parse` method, which returns a hash of metadata keys
12
+ # extracted from the filename.
13
+ #
14
+ # **Usage:**
15
+ # parser = FilenameParser.for_file('/path/to/Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit')
16
+ # metadata_hash = parser.parse
17
+ #
18
+ # See FitsFilenameParser and CR2FilenameParser for format-specific implementations.
19
+ class FilenameParser
20
+ extend Forwardable
21
+
22
+ attr_reader :filename, :path, :result
23
+
24
+ def_delegators :result,
25
+ :type,
26
+ :exposure,
27
+ :bin,
28
+ :camera,
29
+ :gain,
30
+ :iso,
31
+ :created_at,
32
+ :ccd_temp,
33
+ :image_index,
34
+ :telescope,
35
+ :filter,
36
+ :target,
37
+ :dark_flat,
38
+ :mosaic_pane
39
+
40
+ def initialize(path)
41
+ @path = path
42
+ @filename = File.basename(path)
43
+ end
44
+
45
+ # Returns a hash of parsed metadata.
46
+ #
47
+ # Subclasses must implement this method.
48
+ #
49
+ # @return [FileMetadata] Parsed metadata with attributes like :type, :target, :exposure, etc.
50
+ # @raise [NotImplementedError] If not implemented by subclass
51
+ def parse
52
+ raise NotImplementedError, 'Subclasses must implement #parse'
53
+ end
54
+
55
+ # Factory method to create the appropriate parser for a file.
56
+ #
57
+ # Determines the correct parser based on file extension (.fit or .cr2).
58
+ # Case-insensitive for file extensions.
59
+ #
60
+ # @param path [String] Full path to the image file
61
+ # @return [FitsFilenameParser, CR2FilenameParser] Appropriate parser instance
62
+ # @raise [ArgumentError] If file format is not supported
63
+ def self.for_file(path, use_headers: true)
64
+ ext = File.extname(path).downcase
65
+ if Config.fits_extensions.include?(ext)
66
+ if use_headers
67
+ FilenameParsers::FitsHeaderParser.new(path)
68
+ else
69
+ FilenameParsers::FitsFilenameParser.new(path)
70
+ end
71
+ elsif Config.raw_extensions.include?(ext)
72
+ FilenameParsers::CR2FilenameParser.new(path)
73
+ else
74
+ raise ArgumentError, "Unsupported file format: #{path}"
75
+ end
76
+ end
77
+
78
+ protected
79
+
80
+ # Extracts metadata often embedded in the directory structure.
81
+ #
82
+ # @return [Hash] Metadata found in path (telescope, filter, dark_flat)
83
+ def extract_metadata_from_path
84
+ {
85
+ telescope: path.match(%r{TELESCOPE_([^_/\\]+).*})&.captures&.first,
86
+ filter: path.match(%r{FILTER_([^_/\\]+).*})&.captures&.first,
87
+ dark_flat: path.match?(/DarkFlat/i),
88
+ }
89
+ end
90
+
91
+ # Removes file extension from filename.
92
+ #
93
+ # @return [String] Filename without extension
94
+ def extract_base_name
95
+ File.basename(@filename, '.*')
96
+ end
97
+
98
+ # Splits filename into parts separated by underscores.
99
+ #
100
+ # @param base_name [String] Filename without extension
101
+ # @return [Array<String>] Parts of the filename
102
+ def parse_parts(base_name)
103
+ base_name.split('_')
104
+ end
105
+ end
106
+ end