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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module PathBuilders
5
+ # Builds folder paths for dark calibration frames.
6
+ #
7
+ # Dark frames are organized into folders by the following keywords, which allow WBPP
8
+ # to automatically match darks to lights during calibration:
9
+ #
10
+ # **Normal Darks:**
11
+ # - Dark_ISO_<value>_EXP_<value>_CCD-TEMP_<value>_CAMERA_<model>_MONTH_<YYYY-MM>
12
+ #
13
+ # These keywords enable matching darks to lights based on ISO, exposure time, CCD
14
+ # temperature, and the season (month) when captured. Temperature matching in WBPP
15
+ # allows for +/- 1°C variation to accommodate uncooled cameras.
16
+ #
17
+ # **Flat Darks (short exposure darks < 10 seconds):**
18
+ # - DarkFlat_FLATSET_<date>_ISO_<value>_EXP_<value>_Bin_<value>_CAMERA_<model>
19
+ #
20
+ # Flat darks are grouped with their corresponding flat set using the FLATSET keyword.
21
+ # Temperature is not included since flats and flat darks are captured at roughly the
22
+ # same time under similar conditions. These are organized together so WBPP can load
23
+ # them as a unit during the flat field calibration step.
24
+ #
25
+ # See README.md for details on WBPP_Darks and WBPP_Flats process icons.
26
+ class DarkPathBuilder < BasePathBuilder
27
+ # @return [String] The folder path for this dark frame
28
+ def build
29
+ if @metadata.dark_flat?
30
+ build_flat_dark_path
31
+ else
32
+ build_normal_dark_path
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def build_normal_dark_path
39
+ [
40
+ 'Dark',
41
+ iso_or_gain,
42
+ "EXP_#{@metadata.exposure}",
43
+ "CCD-TEMP_#{@metadata.rounded_ccd_temp}",
44
+ "CAMERA_#{@metadata.camera || '????'}",
45
+ "MONTH_#{@metadata.month}",
46
+ ].compact.join('_')
47
+ end
48
+
49
+ def build_flat_dark_path
50
+ [
51
+ 'DarkFlat',
52
+ "FLATSET_#{@metadata.flatset_id}",
53
+ iso_or_gain,
54
+ "EXP_#{@metadata.exposure}",
55
+ "Bin_#{@metadata.bin}",
56
+ "CAMERA_#{@metadata.camera || '????'}",
57
+ ].compact.join('_')
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module PathBuilders
5
+ # Builds folder paths for flat field frames.
6
+ #
7
+ # Flat frames are organized by the following keywords, which allow WBPP to
8
+ # automatically match flats to lights during the flat field calibration step:
9
+ #
10
+ # Flat_FLATSET_<date>_ISO_<value>_EXP_<value>_Bin_<value>_TELESCOPE_<name>_FILTER_<name>_CAMERA_<model>
11
+ #
12
+ # **Keyword meanings:**
13
+ # - FLATSET: Date-based grouping (typically the morning after lights were captured)
14
+ # - ROTATION: Normalized rotation angle (module 180 to ignore meridian flip, only present if rotation is in FITS)
15
+ # - ISO: Camera ISO setting (if ISO is recorded, mutually exclusive with GAIN)
16
+ # - GAIN: Camera gain setting (if gain is recorded, mutually exclusive with ISO)
17
+ # - EXP: Exposure time (note: WBPP requires matching EXP on flats to lights in WBPP_Integration)
18
+ # - Bin: Binning mode
19
+ # - TELESCOPE: Optical equipment used (refractor, Newtonian, etc.)
20
+ # - FILTER: Filter used (luminance, Ha, OIII, etc.)
21
+ # - CAMERA: Camera model
22
+ #
23
+ # These flats are typically grouped together with their corresponding flat darks in the
24
+ # same FLATSET directory so WBPP can load both in one step during the WBPP_Flats process.
25
+ #
26
+ # Important: After generating master flats in WBPP, remove the "EXP_*" segment from the
27
+ # filename so that WBPP_Integration can automatically match them to lights. Keywords must
28
+ # match exactly between files for WBPP to match them.
29
+ #
30
+ # See README.md for details on WBPP_Flats process icon and keyword matching.
31
+ class FlatPathBuilder < BasePathBuilder
32
+ # @return [String] The folder path for this flat frame
33
+ def build
34
+ [
35
+ 'Flat',
36
+ "FLATSET_#{@metadata.flatset_id}",
37
+ @metadata.normalized_rotation&.then { |r| "ROTATION_#{r}deg" },
38
+ iso_or_gain,
39
+ "EXP_#{@metadata.exposure}",
40
+ "Bin_#{@metadata.bin}",
41
+ "TELESCOPE_#{@metadata.telescope || '????'}",
42
+ "FILTER_#{@metadata.filter || '????'}",
43
+ "CAMERA_#{@metadata.camera || '????'}",
44
+ ].compact.join('_')
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module PathBuilders
5
+ # rubocop:disable Layout/LineLength
6
+ # Builds folder paths for light (science) frames.
7
+ #
8
+ # Light frames are organized by the following keywords, which allow WBPP to
9
+ # automatically match lights to calibration frames (darks and flats) during integration:
10
+ #
11
+ # **Pattern:**
12
+ # Light_<target>_PANE_<pane>_FLATSET_<date>_ROTATION_<angle>deg_ISO_<value>_EXP_<value>_Bin_<value>_CCD-TEMP_<value>_TELESCOPE_<name>_FILTER_<name>_CAMERA_<model>
13
+ #
14
+ # **Keyword meanings:**
15
+ # - Target: Object being imaged (e.g., M42, NGC1977)
16
+ # - PANE: Mosaic pane identifier (e.g., 1-1, 1-2) - optional
17
+ # - FLATSET: Date-based grouping matching the flats/darks captured for this light set
18
+ # - ROTATION: Normalized rotation angle (module 180 to ignore meridian flip, only present if rotation is in FITS)
19
+ # - ISO/GAIN: Camera ISO or GAIN setting (must match darks and flats)
20
+ # - EXP: Exposure time (must match darks and flats)
21
+ # - Bin: Binning mode (must match darks and flats)
22
+ # - CCD-TEMP: Rounded CCD temperature (used to group frames for calibration consistency)
23
+ # - TELESCOPE: Optical equipment used
24
+ # - FILTER: Filter used
25
+ # - CAMERA: Camera model
26
+ #
27
+ # The keywords are used by WBPP_Integration to automatically select matching darks,
28
+ # flats, and biases for each light frame during the calibration and integration process.
29
+ #
30
+ # See README.md for details on WBPP_Integration process icon and keyword matching.
31
+ # rubocop:enable Layout/LineLength
32
+ class LightPathBuilder < BasePathBuilder
33
+ # @return [String] The folder path for this light frame
34
+ def build
35
+ pane_segment = @metadata.mosaic_pane ? "_PANE_#{@metadata.mosaic_pane}" : ''
36
+ prefix = "Light_#{@metadata.target}#{pane_segment}"
37
+
38
+ [
39
+ prefix,
40
+ "FLATSET_#{@metadata.flatset_id}",
41
+ @metadata.normalized_rotation&.then { |r| "ROTATION_#{r}deg" },
42
+ iso_or_gain,
43
+ "EXP_#{@metadata.exposure}",
44
+ "Bin_#{@metadata.bin}",
45
+ "CCD-TEMP_#{@metadata.rounded_ccd_temp}",
46
+ "TELESCOPE_#{@metadata.telescope || '????'}",
47
+ "FILTER_#{@metadata.filter || '????'}",
48
+ "CAMERA_#{@metadata.camera || '????'}",
49
+ ].compact.join('_')
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Utils
5
+ # A cleanup utility that removes empty directories from `path` and all
6
+ # its subdirectories. Useful after organizing and moving subframes.
7
+ class EmptyDirectoryCleaner
8
+ include Logging
9
+
10
+ attr_reader :path
11
+
12
+ def initialize(path = Dir.pwd)
13
+ @path = path
14
+ end
15
+
16
+ # Removes empty directories under the given directory.
17
+ def cleanup(dry_run: false, verbose: false)
18
+ logger.info 'Cleaning up empty directories...'
19
+
20
+ Dir.glob('**//*/', base: path).reverse_each do |dir|
21
+ full_path = File.join(path, dir)
22
+ entries = Dir.entries(full_path) - ['.', '..', '.DS_Store']
23
+
24
+ next unless entries.empty?
25
+
26
+ unless dry_run
27
+ ds_store = File.join(full_path, '.DS_Store')
28
+ FileUtils.rm_f(ds_store, verbose: verbose)
29
+ FileUtils.rmdir(full_path, verbose: verbose)
30
+ end
31
+ logger.debug "rmdir #{full_path}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'exiftool_vendored'
4
+
5
+ module AstroSubframeOrganizer
6
+ module Utils
7
+ # Renames CR2 files based on EXIF data to match the standard subframe naming
8
+ # convention: TYPE_TARGET_EXPTIME_BIN_CAMERA_ISO_DATETIME_TEMP_SEQ.CR2
9
+ class ExifRenamer
10
+ include Logging
11
+ include ExposureFormat
12
+
13
+ RAW_NAME_PATTERN = /^(IMG_|DSC_|_DSC|DSCN)/
14
+
15
+ EXIF_DT_FORMAT = '%Y:%m:%d %H:%M:%S%z'
16
+
17
+ attr_reader :path
18
+
19
+ def initialize(path = Dir.pwd)
20
+ @path = path
21
+ end
22
+
23
+ def rename(type:, target: nil, dry_run: false)
24
+ Exiftool.command = 'exiftool.exe' if Gem.win_platform?
25
+
26
+ cr2_files = find_cr2_files
27
+
28
+ if cr2_files.empty?
29
+ logger.warn 'No CR2 files found.'
30
+ return
31
+ end
32
+
33
+ e = Exiftool.new(cr2_files)
34
+ bar = TTY::ProgressBar.new(
35
+ 'Renameing files from EXIF data [:bar] :current/:total (:percent) :eta',
36
+ total: e.files_with_results.size,
37
+ )
38
+
39
+ e.files_with_results.each do |cr2|
40
+ exif = e.result_for(cr2)
41
+ rename_file(cr2, exif, type: type, target: target, dry_run: dry_run, bar: bar)
42
+ end
43
+ end
44
+
45
+ def already_named?(files)
46
+ files.none? { |file| File.basename(file).match?(RAW_NAME_PATTERN) }
47
+ end
48
+
49
+ def find_cr2_files
50
+ exts = Config.raw_extensions.flat_map { |ext| [ext.downcase, ext.upcase] }
51
+ Dir.glob(exts.map { |e| "**/*#{e}" }, base: path)
52
+ .map { |f| File.expand_path(File.join(path, f)) }
53
+ .uniq
54
+ end
55
+
56
+ def revert(dry_run: false)
57
+ cr2_files = find_cr2_files # recursive search
58
+
59
+ if cr2_files.empty?
60
+ logger.warn 'No CR2 files found.'
61
+ return
62
+ end
63
+
64
+ cr2_files.each_with_index do |file, index|
65
+ idx = derive_sequence_number_from_filename(file) || (index + 1)
66
+ filename = "IMG_#{idx.to_s.rjust(4, '0')}.CR2"
67
+ target_file = File.join(File.dirname(file), filename)
68
+
69
+ logger.info "Renaming #{File.basename(file)} to #{filename}"
70
+ FileUtils.move(file, target_file, verbose: dry_run, noop: dry_run) unless File.exist?(target_file)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def rename_file(cr2, exif, type:, target:, dry_run:, bar: nil)
77
+ filename = build_filename(exif, type: type, target: target)
78
+ target_file = File.join(File.dirname(cr2), filename)
79
+
80
+ if File.exist?(target_file)
81
+ msg = "Skipping #{cr2}, target #{target_file} already exists."
82
+ bar ? bar.log(msg) : logger.warn(msg)
83
+ return
84
+ end
85
+
86
+ FileUtils.mv(cr2, target_file, verbose: dry_run, noop: dry_run)
87
+ end
88
+
89
+ def get_exif_value(exif, mapping_key)
90
+ tags = Config.exif_tag_mappings[mapping_key] || []
91
+ tags.each do |tag|
92
+ return exif[tag] if exif[tag]
93
+ end
94
+ nil
95
+ end
96
+
97
+ def derive_sequence_number_from_filename(path)
98
+ File.basename(path, '.*').split(/[_-]/).last.to_i
99
+ end
100
+
101
+ def build_filename(exif, type:, target:)
102
+ exp_str = format_exposure(get_exif_value(exif, 'exposure'))
103
+ created_at = resolve_time(exif).strftime(FILENAME_DT_FORMAT)
104
+ ccd_temp = format('%.1fC', get_exif_value(exif, 'temperature').to_f)
105
+ seq_num = derive_sequence_number_from_filename(exif.source_file).to_s.rjust(4, '0')
106
+ camera = resolve_camera(get_exif_value(exif, 'model'))
107
+
108
+ [type, target, exp_str, 'Bin1', camera, "ISO#{get_exif_value(exif, 'iso')}", created_at, ccd_temp, seq_num] # rubocop:disable Style/StringConcatenation
109
+ .compact
110
+ .join('_') + '.CR2'
111
+ end
112
+
113
+ def resolve_camera(cam_model)
114
+ camera = Equipment::Camera.all.find { |c| cam_model.include?(c) }
115
+ if camera.nil?
116
+ logger.warn "Camera #{cam_model} did not match any expected models."
117
+ cam_model
118
+ else
119
+ camera
120
+ end
121
+ end
122
+
123
+ def resolve_time(exif)
124
+ dt = get_exif_value(exif, 'timestamp')
125
+ tz = exif[:time_zone] || '+00:00'
126
+ Time.strptime("#{dt}#{tz}", EXIF_DT_FORMAT)
127
+ rescue ArgumentError
128
+ Time.parse(dt)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Utils
5
+ # Utility module to format exposure times in a human-readable way for path building and filename generation.
6
+ module ExposureFormat
7
+ # Formats an exposure time in seconds into a string with appropriate units (s, ms, or us).
8
+ # @param exp_time [Numeric] The exposure time in seconds
9
+ # @return [String] The formatted exposure time with units (e.g., "30.0s", "500.0ms", "250.0us")
10
+ def format_exposure(exp_time)
11
+ exp_time = exp_time.to_f
12
+ unit = 's'
13
+ if exp_time < 1.0
14
+ exp_time *= 1000
15
+ unit = 'ms'
16
+ end
17
+ if exp_time < 1.0
18
+ exp_time *= 1000
19
+ unit = 'us'
20
+ end
21
+ format('%<time>.1f%<unit>s', time: exp_time, unit: unit)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module FileUtils # rubocop:disable Style/Documentation
6
+ # We redefine the internal output method used by FileUtils
7
+ def self.fu_output_message(msg)
8
+ AstroSubframeOrganizer.logger.info(msg)
9
+ end
10
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Utils
5
+ # Strips image data and identifying location information from FITS files.
6
+ # The resulting file is valid FITS with NAXIS set to 0, meaning no
7
+ # data array follows the header block.
8
+ #
9
+ # Location headers stripped: SITELAT, SITELONG, SITEELEV, RA, DEC,
10
+ # OBJCTRA, OBJCTDEC, CRVAL1, CRVAL2, CRPIX1, CRPIX2, and WCS keywords.
11
+ class FitsStripper
12
+ BLOCK_SIZE = 2880
13
+ CARD_SIZE = 80
14
+ CARDS_PER_BLOCK = BLOCK_SIZE / CARD_SIZE
15
+
16
+ LOCATION_HEADERS = %w[
17
+ SITELAT
18
+ SITELONG
19
+ SITEELEV
20
+ RA
21
+ DEC
22
+ OBJCTRA
23
+ OBJCTDEC
24
+ ].freeze
25
+
26
+ # WCS (World Coordinate System) headers that encode pointing information
27
+ WCS_PREFIXES = %w[CRVAL CRPIX CD1_ CD2_ A_ B_ AP_ BP_ CTYPE CUNIT].freeze
28
+
29
+ def self.strip(input_path, output_path = nil)
30
+ new(input_path, output_path).strip
31
+ end
32
+
33
+ def initialize(input_path, output_path = nil)
34
+ @input_path = input_path
35
+ @output_path = output_path || input_path # default to in-place
36
+ end
37
+
38
+ def already_stripped?
39
+ File.open(@input_path, 'rb') do |f|
40
+ loop do
41
+ block = f.read(BLOCK_SIZE)
42
+ return false unless block&.length == BLOCK_SIZE
43
+
44
+ cards = block.scan(/.{#{CARD_SIZE}}/mo)
45
+ naxis_card = cards.find { |c| c.start_with?('NAXIS =') }
46
+ return naxis_card.match?(/=\s+0\b/) if naxis_card
47
+
48
+ return false if header_end?(block)
49
+ end
50
+ end
51
+ false
52
+ end
53
+
54
+ def strip
55
+ header_blocks = read_header_blocks
56
+ patched = patch_naxis(header_blocks)
57
+ patched = strip_location_headers(patched)
58
+ File.binwrite(@output_path, patched)
59
+ @output_path
60
+ end
61
+
62
+ private
63
+
64
+ def read_header_blocks
65
+ blocks = []
66
+ File.open(@input_path, 'rb') do |f|
67
+ loop do
68
+ block = f.read(BLOCK_SIZE)
69
+ break unless block&.length == BLOCK_SIZE
70
+
71
+ blocks << block
72
+ break if header_end?(block)
73
+ end
74
+ end
75
+ blocks.join
76
+ end
77
+
78
+ def patch_naxis(header_data)
79
+ header_data.gsub(/NAXIS =\s+\d+/) { |m| 'NAXIS = 0'.ljust(m.length) }
80
+ end
81
+
82
+ def strip_location_headers(header_data)
83
+ cards = header_data.scan(/.{#{CARD_SIZE}}/mo)
84
+ cards.map do |card|
85
+ if location_header?(card)
86
+ blank_card
87
+ else
88
+ card
89
+ end
90
+ end.join
91
+ end
92
+
93
+ def location_header?(card)
94
+ key = card[0, 8].strip
95
+ LOCATION_HEADERS.include?(key) ||
96
+ WCS_PREFIXES.any? { |prefix| key.start_with?(prefix) }
97
+ end
98
+
99
+ def blank_card
100
+ ' ' * CARD_SIZE
101
+ end
102
+
103
+ def header_end?(block)
104
+ block.scan(/.{#{CARD_SIZE}}/mo).any? { |card| card.start_with?('END ') }
105
+ end
106
+
107
+ def stripped_path(input_path)
108
+ dir = File.dirname(input_path)
109
+ basename = File.basename(input_path, '.*')
110
+ ext = File.extname(input_path)
111
+ File.join(dir, "#{basename}_stripped#{ext}")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'exiftool_vendored'
5
+
6
+ module AstroSubframeOrganizer
7
+ module Utils
8
+ # Strips RAW files down to their metadata by creating a MIE (Metadata Information Extraction) file using exiftool.
9
+ # This is useful for users who want to keep metadata but remove large image data from their RAW files, for example
10
+ # to generate test fixtures where metadata is important, but image data is not needed.
11
+ class RawStripper
12
+ def initialize(input_path, output_path)
13
+ @input = input_path
14
+ @output = output_path
15
+ end
16
+
17
+ def already_stripped?
18
+ # A stripped RAW (MIE file) is typically under 1MB,
19
+ # whereas a real RAW is 20MB+.
20
+ return false unless File.exist?(@output)
21
+
22
+ File.size(@output) < 1_048_576
23
+ end
24
+
25
+ def strip # rubocop:disable Naming/PredicateMethod
26
+ # We use exiftool to create a Metadata Information Extraction (MIE) file.
27
+ # This contains all metadata but zero image data.
28
+ # We then save it with the original extension so the parsers recognize it.
29
+ tmp_mie = "#{@output}.mie"
30
+ Exiftool.command = 'exiftool.exe' if Gem.win_platform?
31
+
32
+ # -o specifies the output file. exiftool creates a MIE file if the extension is .mie
33
+ # -all:all ensures we copy all metadata blocks (EXIF, MakerNotes, etc.)
34
+ success = system("#{Exiftool.command} -o \"#{tmp_mie}\" -all:all \"#{@input}\" > /dev/null 2>&1")
35
+
36
+ if success && File.exist?(tmp_mie)
37
+ FileUtils.mkdir_p(File.dirname(@output))
38
+ FileUtils.mv(tmp_mie, @output, force: true)
39
+ true
40
+ else
41
+ FileUtils.rm_f(tmp_mie)
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Utils
5
+ # A cleanup utility that removes thumbnail images with filenames mattching `pattern`
6
+ # from `path` and all its subdirectories.
7
+ class ThumbnailCleaner
8
+ include Logging
9
+
10
+ ASIAIR_THUMBNAIL_PATTERN = '**/**_thn.jpg'
11
+
12
+ attr_reader :path
13
+
14
+ def initialize(path = Dir.pwd)
15
+ @path = path
16
+ end
17
+
18
+ # Removes all the jpg thumbnails under the given directory.
19
+ def cleanup(pattern: ASIAIR_THUMBNAIL_PATTERN, dry_run: false, verbose: false)
20
+ logger.info 'Removing jpg thumbnails...'
21
+ Dir.glob([pattern], base: path)
22
+ .each { |thumbnail| FileUtils.rm File.join(path, thumbnail), noop: dry_run, verbose: verbose }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module Utils
5
+ # Moves all FITS and CR2 files from subdirectories back into the target directory.
6
+ # Useful for undoing an organize run so files can be re-organized with different settings.
7
+ class Unorganizer
8
+ include Logging
9
+
10
+ attr_reader :path
11
+
12
+ def initialize(path = Dir.pwd)
13
+ @path = path
14
+ end
15
+
16
+ def unorganize(dry_run: false, verbose: false)
17
+ files = find_organized_files
18
+
19
+ if files.empty?
20
+ logger.info 'No organized files found.'
21
+ return
22
+ end
23
+
24
+ logger.info "Preparing to move #{files.size} files to #{path}..."
25
+
26
+ bar = TTY::ProgressBar.new('Moving files [:bar] :current/:total (:percent) :eta', total: files.size)
27
+
28
+ files.each do |file|
29
+ dest = File.join(path, File.basename(file))
30
+ if File.exist?(dest)
31
+ bar.log "Skipping #{File.basename(file)}, already exists in #{path}."
32
+ next
33
+ end
34
+ FileUtils.mv(file, dest, verbose: verbose || dry_run, noop: dry_run)
35
+ bar.advance(1)
36
+ end
37
+
38
+ cleanup_empty_dirs(dry_run: dry_run) unless dry_run
39
+ end
40
+
41
+ private
42
+
43
+ def find_organized_files
44
+ Dir.glob(['**/*.fit', '**/*.FIT', '**/*.cr2', '**/*.CR2'], base: path)
45
+ .map { |f| File.join(path, f) }
46
+ .reject { |f| File.dirname(f) == path }
47
+ end
48
+
49
+ def cleanup_empty_dirs(dry_run: false, verbose: false)
50
+ EmptyDirectoryCleaner.new(path).cleanup(dry_run: dry_run, verbose: verbose)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ VERSION = '0.0.2'
5
+ end