distorted 0.6.0 → 0.7.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/bin/console +14 -0
  4. data/bin/distorted +6 -0
  5. data/bin/setup +8 -0
  6. data/lib/distorted.rb +2 -0
  7. data/lib/distorted/checking_you_out.rb +116 -13
  8. data/lib/distorted/{types → checking_you_out}/README +0 -0
  9. data/lib/distorted/checking_you_out/application.yaml +33 -0
  10. data/lib/distorted/{types → checking_you_out}/font.yaml +0 -0
  11. data/lib/distorted/checking_you_out/image.yaml +108 -0
  12. data/lib/distorted/click_again.rb +333 -0
  13. data/lib/distorted/element_of_media.rb +2 -0
  14. data/lib/distorted/element_of_media/change.rb +119 -0
  15. data/lib/distorted/element_of_media/compound.rb +120 -0
  16. data/lib/distorted/floor.rb +17 -0
  17. data/lib/distorted/invoker.rb +97 -0
  18. data/lib/distorted/media_molecule.rb +58 -0
  19. data/lib/distorted/media_molecule/font.rb +195 -0
  20. data/lib/distorted/media_molecule/image.rb +33 -0
  21. data/lib/distorted/media_molecule/pdf.rb +44 -0
  22. data/lib/distorted/media_molecule/svg.rb +45 -0
  23. data/lib/distorted/media_molecule/text.rb +203 -0
  24. data/lib/distorted/media_molecule/video.rb +18 -0
  25. data/lib/distorted/modular_technology/gstreamer.rb +174 -0
  26. data/lib/distorted/modular_technology/vips.rb +4 -4
  27. data/lib/distorted/modular_technology/vips/foreign.rb +489 -0
  28. data/lib/distorted/modular_technology/vips/load.rb +133 -0
  29. data/lib/distorted/modular_technology/{vips_save.rb → vips/save.rb} +23 -34
  30. data/lib/distorted/monkey_business/encoding.rb +317 -0
  31. data/lib/distorted/monkey_business/hash.rb +0 -15
  32. data/lib/distorted/{modular_technology/triple_counter.rb → triple_counter.rb} +8 -1
  33. data/lib/distorted/version.rb +16 -16
  34. metadata +59 -46
  35. data/lib/distorted/injection_of_love.rb +0 -247
  36. data/lib/distorted/modular_technology/vips_load.rb +0 -77
  37. data/lib/distorted/molecule/C18H27NO3.rb +0 -10
  38. data/lib/distorted/molecule/font.rb +0 -198
  39. data/lib/distorted/molecule/image.rb +0 -36
  40. data/lib/distorted/molecule/pdf.rb +0 -119
  41. data/lib/distorted/molecule/svg.rb +0 -60
  42. data/lib/distorted/molecule/text.rb +0 -225
  43. data/lib/distorted/molecule/video.rb +0 -195
  44. data/lib/distorted/monkey_business/mnemoniq.rb +0 -8
  45. data/lib/distorted/types/application.yaml +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a96a3b49df5a194c2d18bae2a71d208e02aaad05c6af3eb5399c0f5c87167bd0
4
- data.tar.gz: 7260d18a57ab0a14008bb92712c9dec322c5be4469329a656d5ef287c09e52bf
3
+ metadata.gz: 14d224830d6e35072b97427b9a07d8a97061510259c7fa94be189509e52cf453
4
+ data.tar.gz: 724f0479db6c93ec9f00cfae008524fb51cd519db0865915bd3f7e11f97eec77
5
5
  SHA512:
6
- metadata.gz: e290cb8d83991ad9391bf3cfd1c495142692af34b3ff424da78be54439e85f38507612df20814d5cb98e935db20ea970095a6890ca3291a6d3e0e6805948d928
7
- data.tar.gz: a5388b7db8e91e16ad74080295882ad9bf106257c062ad0b54fdd60813c6300073ec25a1c031f8356c0f4b46dae3150e3bbf14a9c32466faaa848b2c8b10e445
6
+ metadata.gz: 82886187c077e5f61cf976546ea2c15e77f5b3dabc79a69071b78e0153f69a6f9a41250d4ea3a2deb66e5caea67c8f5abeb41ef7ff2b6f50af171d3e2232c821
7
+ data.tar.gz: b2f17ab2f0807cc067bbb035c0bbe559e8e3df30cbd2e29feb27d79784fcadbb035ec9016b785dc29ace3ebc234c81b71a7b6879de0c0ed76b6956bcb90f128d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cooltrainer::DistorteD
2
2
 
3
- `DistorteD-Ruby` is the core file-handling code for `DistorteD-Jekyll`.
3
+ `DistorteD-Floor` is the core file-handling code for `DistorteD-Jekyll`.
4
4
 
5
5
  ## Installation
6
6
 
@@ -24,7 +24,7 @@ Or install it yourself as:
24
24
  Clone the DistorteD repository and modify your Jekyll `Gemfile` to refer to your local path instead of to the newest published version of the gem:
25
25
 
26
26
  ```
27
- gem 'distorted', :path => '~/repos/DistorteD/DistorteD-Ruby/'[, :branch => 'NEW-SENSATION']
27
+ gem 'distorted', :path => '~/repos/DistorteD/DistorteD-Floor/'[, :branch => 'NEW-SENSATION']
28
28
  ```
29
29
 
30
30
  ## License
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "distorted"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'distorted/click_again'
4
+
5
+ click = Cooltrainer::DistorteD::ClickAgain.new(ARGV, File.basename(__FILE__))
6
+ click.write(Dir.pwd)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,2 @@
1
+ #TODO: Genericize Ruby version-checking and use it here to require 2.7+
2
+ require 'distorted/invoker'
@@ -1,12 +1,83 @@
1
1
  require 'set'
2
2
 
3
+ # CYO encapsulate all concepts related to media file identification for DistorteD!
4
+
5
+ # General media type resources:
6
+ # https://www.iana.org/assignments/media-types/media-types.xhtml
7
+
8
+
9
+ # The Gem `ruby-mime-types` and its associated `mime-types-data` provide our core classes:
10
+ # https://github.com/mime-types/ruby-mime-types
3
11
  require 'mime/types'
12
+ #
13
+ # - Its MIME Types database ensures I don't have to constantly update DD with
14
+ # new filetypes as they are invented, like how AVIF is still currently brand-new as of 2021.
15
+ # - Our main Type search method — :OUT() — wraps `MIME::Types.type_for`/`MIME::Types[]`
16
+ # to identify media types based on filename alone (e.g. even for hypothetical files).
17
+ # - The Object we give callers will be a `MIME::Type`, not anything wrapped/renamed.
18
+ # - I use its Loader class and YAML structure to ship additional Type definitions local to DD.
19
+ #
20
+ # NOTE: Type objects returned from the unwrapped MIME::Types interfaces will not have equality
21
+ # with instances of the same media type from CYO! This is because we load our own database.
22
+ # irb(main)> MIME::Types['image/jpeg'].object_id
23
+ # => 136400
24
+ # irb(main)> CHECKING::YOU::OUT['image/jpeg'].object_id
25
+ # => 90800
26
+
27
+
28
+ # The Gem `ruby-filemagic` provides the ability to inspect the magic bytes of actual on-disk files.
29
+ # https://github.com/blackwinter/ruby-filemagic
30
+ # http://blackwinter.github.io/ruby-filemagic/
31
+ #
32
+ # NOTE: Unmaintained!
33
+ # https://github.com/blackwinter/ruby-filemagic/commit/e1f2efd07da4130484f06f58fed016d9eddb4818
34
+ #
35
+ # Might consider replacing this with an FFI filemagic to eliminate the native-code compilation.
36
+ # https://rubygems.org/gems/glongman-ffiruby-filemagic/
37
+ # https://stuart.com/blog/ruby-bindings-extensions/
4
38
  require 'ruby-filemagic'
5
39
 
40
+
41
+ # Monkey-patch some DistorteD-specific methods into MIME::Type objects.
6
42
  module MIME
7
43
  class Type
8
- # Give MIME::Type objects an easy way to get the DistorteD saver method name.
9
- def distorted_method
44
+
45
+ # Provide a few variations on the base :distorted_method for mixed workflows
46
+ # where it isn't feasible to overload a single method name and call :super.
47
+ # Jekyll, for example, renders its output markup upfront, collects all of
48
+ # the StaticFiles (or StaticStatic-includers, in our case), then calls their
49
+ # :write methods all at once after the rest of the site is built,
50
+ # and this precludes us from easily sharing method names between layers.
51
+ DISTORTED_METHOD_PREFIXES = Hash[
52
+ :buffer => 'to'.freeze,
53
+ :file => 'write'.freeze,
54
+ :template => 'render'.freeze,
55
+ ]
56
+ SUB_TYPE_SEPARATORS = /[-_+\.]/
57
+
58
+ # Returns a Symbol name of the method that should return a String buffer containing the file in this Type.
59
+ def distorted_buffer_method; "#{DISTORTED_METHOD_PREFIXES[:buffer]}_#{distorted_method_suffix}".to_sym; end
60
+
61
+ # Returns a Symbol name of the method that should write a file of this Type to a given path on a filesystem.
62
+ def distorted_file_method; "#{DISTORTED_METHOD_PREFIXES[:file]}_#{distorted_method_suffix}".to_sym; end
63
+
64
+ # Returns a Symbol name of the method that should returns a context-appropriate Object
65
+ # for displaying the file as this Type.
66
+ # Might be e.g. a String buffer containing Rendered Liquid in Jekylland,
67
+ # or a Type-appropriate frame in some GUI toolkit in DD-Booth.
68
+ def distorted_template_method; "#{DISTORTED_METHOD_PREFIXES[:template]}_#{distorted_method_suffix}".to_sym; end
69
+
70
+ # Returns an Array[Array[String]] of human-readable keys we can use for our YAML config,
71
+ # e.g. :media_type 'image' & :sub_type 'svg+xml' would be split to ['image', 'svg'].
72
+ # `nil` `:sub_type`s will just be compacted out.
73
+ # Every non-nil :media_type will also request a key path [media_type, '*']
74
+ # to allow for similar-type defaults, e.g. every image type outputting a fallback.
75
+ def settings_paths; [[self.media_type, '*'.freeze], [self.media_type, self.sub_type&.split('+'.freeze)&.first].compact]; end
76
+
77
+ private
78
+
79
+ # Provide a consistent base method name for context-specific DistorteD operations.
80
+ def distorted_method_suffix
10
81
  # Standardize MIME::Types' media_type+sub_type to DistorteD method mapping
11
82
  # by replacing all the combining characters with underscores (snake case)
12
83
  # to match Ruby conventions:
@@ -18,20 +89,29 @@ module MIME
18
89
  # :to_application_vnd_openxmlformats_officedocument_wordprocessingml_document
19
90
  # which would most likely be defined by the :included method of a library-specific
20
91
  # module for handling OpenXML MS Office documents.
21
- "to_#{self.media_type}_#{self.sub_type.gsub(/[-+\.]/, '_'.freeze)}".to_sym
22
- end
92
+ "#{self.media_type}_#{self.sub_type.gsub(SUB_TYPE_SEPARATORS, '_'.freeze)}"
93
+ end # distorted_method_suffix
23
94
  end
24
95
  end
25
96
 
97
+
26
98
  module CHECKING
27
99
  class YOU
28
100
 
101
+ # Returns a single Type with Array-style access.
102
+ class OUT
103
+ def self.[](type)
104
+ CHECKING::YOU::types[type].first
105
+ end
106
+ end
107
+
29
108
  # Returns a Set of MIME::Type for a given file path, by default only
30
109
  # based on the file extension. If the file extension is unavailable—
31
110
  # or if `so_deep` is enabled—the `path` will be used as an actual
32
111
  # path to look at the magic bytes with ruby-filemagic.
33
- def self.OUT(path, so_deep: false)
34
- unless so_deep || types.type_for(path).empty?
112
+ def self.OUT(path, so_deep: false, only_one_test: false)
113
+ return Set[] if path.nil?
114
+ if not (only_one_test || types.type_for(path).empty?)
35
115
  # NOTE: `type_for`'s return order is supposed to be deterministic:
36
116
  # https://github.com/mime-types/ruby-mime-types/issues/148
37
117
  # My use case so far has never required order but has required
@@ -43,7 +123,7 @@ module CHECKING
43
123
  # irb(main)> MIME::Types.type_for('lol.ttf')).to_set
44
124
  # => #<Set: {#<MIME::Type: font/ttf>, #<MIME::Type: application/font-sfnt>, #<MIME::Type: application/x-font-truetype>, #<MIME::Type: application/x-font-ttf>}>
45
125
  return types.type_for(path).to_set
46
- else
126
+ elsif (so_deep && path[0] != '.'.freeze) # Support taking hypothetical file extensions (e.g. '.jpg') without stat()ing anything.
47
127
  # Did we fail to guess any MIME::Types from the given filename?
48
128
  # We're going to have to look at the actual file
49
129
  # (or at least its first four bytes).
@@ -63,18 +143,31 @@ module CHECKING
63
143
  # irb(main)> "image/svg+xml; charset=us-ascii".split(';').first
64
144
  # => "image/svg+xml"
65
145
  mime = types[fm.file(path, false).split(';'.freeze).first].to_set
66
- end
67
- end
68
- end
146
+ end # FileMagic.open
147
+ else
148
+ # TODO: Warn here that we may need a custom type!
149
+ #p "NO MATCH FOR #{path}"
150
+ Set[]
151
+ end # if
152
+ end # self.OUT()
69
153
 
70
154
  # Returns a Set of MIME::Type objects matching a String search key of the
71
155
  # format MEDIA_TYPE/SUB_TYPE.
72
156
  # This can return multiple Types, e.g. 'font/collection' TTC/OTC variations:
73
157
  # [#<MIME::Type: font/collection>, #<MIME::Type: font/collection>]
74
- def self.IN(type)
75
- types[type, :complete => type.is_a?(Regexp)].to_set
158
+ def self.IN(wanted_type_or_types)
159
+ if wanted_type_or_types.is_a?(Enumerable)
160
+ # Support taking a list of String types for Molecules whose Type support
161
+ # isn't easily expressable as a single Regexp.
162
+ types.select{ |type| wanted_type_or_types.include?(type.to_s) }
163
+ else
164
+ # Might be a single String or Regexp
165
+ types[wanted_type_or_types, :complete => wanted_type_or_types.is_a?(Regexp)].to_set
166
+ end
76
167
  end
77
168
 
169
+ protected
170
+
78
171
  # Returns the MIME::Types container or loads one
79
172
  def self.types
80
173
  @@types ||= types_loader
@@ -88,17 +181,27 @@ module CHECKING
88
181
  # Load the upstream mime-types-data by providing a nil `path`:
89
182
  # path || ENV['RUBY_MIME_TYPES_DATA'] || MIME::Types::Data::PATH
90
183
  loader = MIME::Types::Loader.new(nil, container)
184
+ # TODO: Log this once I figure out a nice way to wrap Jekyll logger too.
185
+ # irb> loader.load_columnar => #<MIME::Types: 2277 variants, 1195 extensions>
91
186
  loader.load_columnar
92
187
 
93
188
  # Change default JPEG file extension from .jpeg to .jpg
94
189
  # because it pisses me off lol
95
190
  container['image/jpeg'].last.preferred_extension = 'jpg'
96
191
 
192
+ # Add a missing extension to MPEG-DASH manifests:
193
+ # irb> MIME::Types['application/dash+xml'].first
194
+ # => #<MIME::Type: application/dash+xml>
195
+ # irb> MIME::Types['application/dash+xml'].first.preferred_extension
196
+ # => nil
197
+ # https://www.iana.org/assignments/media-types/application/dash+xml
198
+ container['application/dash+xml'].last.preferred_extension = 'mpd'
199
+
97
200
  # Override the loader's path with the path to our local data directory
98
201
  # after we've loaded the upstream data.
99
202
  # :@path is set up in Loader::initialize and only has an attr_reader
100
203
  # but we can reach in and change it.
101
- loader.instance_variable_set(:@path, File.join(__dir__, 'types'.freeze))
204
+ loader.instance_variable_set(:@path, File.join(__dir__, 'checking_you_out'.freeze))
102
205
 
103
206
  # Load our local types data. The YAML files are separated by type,
104
207
  # and :load_yaml will load all of them in the :@path we just set.
@@ -0,0 +1,33 @@
1
+ # Define our own type to trigger fallback copy instead of abusing application/x-imagemap :)
2
+ - !ruby/object:MIME::Type
3
+ content-type: application/x.distorted.never-let-you-down
4
+ xrefs:
5
+ person:
6
+ - okeeblow
7
+ registered: false
8
+
9
+
10
+ # MATLAB files supported by VIPS Magick foreign loader.
11
+ - !ruby/object:MIME::Type
12
+ content-type: application/x-matlab-data
13
+ encoding: 8bit
14
+ extensions:
15
+ - mat
16
+ xrefs_urls:
17
+ - "http://justsolve.archiveteam.org/wiki/MAT"
18
+ registered: false
19
+
20
+
21
+ # Microsoft SilverLight multiple-zoom tiled-raster image archive formats.
22
+ # I made this content-type up based on the convention of other 'vnd.microsoft' Types,
23
+ # e.g. #<MIME::Type: application/vnd.microsoft.windows.thumbnail-cache>
24
+ - !ruby/object:MIME::Type
25
+ content-type: application/vnd.microsoft.deep-zoom
26
+ extensions:
27
+ - dz
28
+ - dzi
29
+ - dzc
30
+ - xml
31
+ xrefs_urls:
32
+ - "http://msdn.microsoft.com/en-us/library/cc645077(VS.95).aspx"
33
+ registered: false
@@ -0,0 +1,108 @@
1
+ # Define our own type to trigger a legacy <img> element src for <picture>
2
+ - !ruby/object:MIME::Type
3
+ content-type: image/x.distorted.fallback
4
+ xrefs:
5
+ person:
6
+ - okeeblow
7
+ registered: false
8
+
9
+
10
+ # libvips' internal format, unlikely to be used but may as well be supported :)
11
+ - !ruby/object:MIME::Type
12
+ content-type: image/vips
13
+ extensions:
14
+ - v
15
+ - vips
16
+ xrefs_urls:
17
+ - "http://fileformats.archiveteam.org/wiki/VIPS"
18
+ registered: false
19
+
20
+
21
+ # OpenEXR stuff
22
+ - !ruby/object:MIME::Type
23
+ content-type: image/x-exr
24
+ extensions:
25
+ - exr
26
+ xrefs_urls:
27
+ - "https://www.nationalarchives.gov.uk/PRONOM/fmt/1001"
28
+ - "https://en.wikipedia.org/wiki/OpenEXR"
29
+ - "http://fileformats.archiveteam.org/wiki/OpenEXR"
30
+
31
+ - !ruby/object:MIME::Type
32
+ content-type: image/vnd.radiance
33
+ extensions:
34
+ - hdr
35
+ xrefs_urls:
36
+ - "https://en.wikipedia.org/wiki/RGBE_image_format"
37
+ registered: false
38
+
39
+ - !ruby/object:MIME::Type
40
+ content-type: image/fits
41
+ extensions:
42
+ - fits
43
+ - fit
44
+ - fts
45
+ xrefs_urls:
46
+ - "https://www.iana.org/assignments/media-types/image/fits"
47
+ - "https://www.cv.nrao.edu/fits/"
48
+ registered: true
49
+
50
+ # End OpenEXR stuff
51
+
52
+
53
+ # OpenSlide stuff — https://openslide.org/formats/
54
+
55
+ - !ruby/object:MIME::Type
56
+ content-type: image/vnd.scanscope.virtual.slide
57
+ extensions:
58
+ - svs # Excluding tiff since that has a generic entry.
59
+ xrefs_urls:
60
+ - "https://openslide.org/formats/aperio/"
61
+ - "https://docs.openmicroscopy.org/bio-formats/latest/formats/aperio-svs-tiff.html"
62
+ - "http://justsolve.archiveteam.org/wiki/Aperio_SVS"
63
+ registered: false
64
+
65
+ - !ruby/object:MIME::Type
66
+ content-type: image/vnd.hamamatsu
67
+ extensions:
68
+ - vms
69
+ - vmu
70
+ - ndpi
71
+ xrefs_urls:
72
+ - "https://openslide.org/formats/hamamatsu/"
73
+ registered: false
74
+
75
+ - !ruby/object:MIME::Type
76
+ content-type: image/vnd.sakura
77
+ extensions:
78
+ - svslide
79
+ xrefs_urls:
80
+ - "https://openslide.org/formats/sakura/"
81
+ registered: false
82
+
83
+ - !ruby/object:MIME::Type
84
+ content-type: image/vnd.mirax
85
+ extensions:
86
+ - mrxs
87
+ xrefs_urls:
88
+ - "https://openslide.org/formats/mirax/"
89
+ registered: false
90
+
91
+ - !ruby/object:MIME::Type
92
+ content-type: image/vnd.leica
93
+ extensions:
94
+ - scn
95
+ xrefs_urls:
96
+ - "https://openslide.org/formats/leica/"
97
+ registered: false
98
+
99
+ - !ruby/object:MIME::Type
100
+ content-type: image/vnd.ventana
101
+ extensions:
102
+ - bif
103
+ xrefs_urls:
104
+ - "https://openslide.org/formats/ventana/"
105
+ registered: false
106
+
107
+
108
+ # End OpenSlide stuff
@@ -0,0 +1,333 @@
1
+ require 'set'
2
+ require 'distorted/monkey_business/set'
3
+
4
+ require 'optparse'
5
+ require 'shellwords' # Necessary for inclusion in OptionParser coercions list.
6
+
7
+ require 'distorted/invoker'
8
+ require 'distorted/checking_you_out'
9
+ require 'distorted/element_of_media/compound'
10
+
11
+
12
+ module Cooltrainer; end
13
+ module Cooltrainer::DistorteD; end
14
+
15
+ class Cooltrainer::DistorteD::ClickAgain
16
+
17
+ include Cooltrainer::DistorteD::Invoker # MediaMolecule plugger
18
+
19
+ attr_reader :global_options, :lower_options, :outer_options
20
+
21
+
22
+ # Set up and parse a given Array of command-line switches based on
23
+ # our global OptionParser and its Type/Molecule-specific sub-commands.
24
+ #
25
+ # :argv will be operated on destructively!
26
+ # Consider passing a duplicate of ARGV instead of passing it directly.
27
+ def initialize(argv, exe_name)
28
+
29
+ # Partition argv into (switches and their arguments) and (filenames or wanted type Strings)
30
+ switches, @get_out = partition_argv(argv)
31
+
32
+ # Initialize Hashes to store our three types of Options using a small
33
+ # custom subclass that will store items as a Set but won't store :nil alone.
34
+ @global_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
35
+ @lower_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
36
+ @outer_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
37
+ # Temporary Array for unmatched Switches when parsing subcommands.
38
+ sorry_try_again = Array.new
39
+
40
+ # Pass our executable name in for the global OptionParser's banner String,
41
+ # then parse the complete/raw user-given-arguments-list first with this Parser.
42
+ #
43
+ # I am intentionally using OptionParser's non-POSIXy :permute! method
44
+ # instead of the POSIX-compatible :order! method,
45
+ # because I want to :)
46
+ # Otherwise users would have to define all switch arguments
47
+ # ahead of all positional arguments in the command,
48
+ # and I think that would be frustrating and silly.
49
+ #
50
+ # In strictly-POSIX mode, one would have to call e.g.
51
+ # `distorted -o image/png inputfile.webp outfilewithnofileextension`
52
+ # instead of
53
+ # `distorted inputfile.webp -o image/png outfilewithnofileextension`,
54
+ # which I find to be much more intuitive.
55
+ #
56
+ # Note that `:parse!` would call one of the other of :order!/:permute! based on
57
+ # an invironment variable `POSIXLY_CORRECT`. Talk about a footgun!
58
+ # Be explicit!!
59
+ global = global_options(exe_name)
60
+ begin
61
+ switches = global.permute!(switches, into: @global_options)
62
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
63
+ nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
64
+ #if switches&.first&.chr == '-'.freeze
65
+ # sorry_try_again.unshift(switches.shift)
66
+ #end
67
+ retry
68
+ end
69
+ switches.unshift(*sorry_try_again.reverse)
70
+
71
+ # The global OptionParser#permute! call will strip our `:argv` Array of
72
+ # any `--help` or Molecule-picking switches.
73
+ # Molecule-specific switches (both 'lower' and 'outer') and positional
74
+ # file-name arguments remain.
75
+ #
76
+ # The first remaining `argv` will be our input filename if one was given!
77
+ #
78
+ # NOTE: Never assume this filename will be a complete, absolute, usable path.
79
+ # POSIX shells do not do tilde expansion, for example, on quoted switch arguments,
80
+ # so a quoted filename argument '~/cover.png' will come through to Ruby-land
81
+ # as the literal String '~/cover.png' while the same filename argument sans-quotes
82
+ # will be expanded to e.g. '/home/okeeblow/cover.png' (based on `$HOME` env var).
83
+ # Additional Ruby-side path validation will almost certainly be needed!
84
+ # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_01
85
+ @name = @get_out&.shift
86
+
87
+ # Print some sort of help message or list of supported input/output Types
88
+ # if no source filename was given.
89
+ unless @name
90
+ puts case
91
+ when @global_options.has_key?(:help) then global
92
+ when @global_options.has_key?(:"lower-world")
93
+ "Supported input media types:\n#{lower_world.keys.join("\n")}"
94
+ when @global_options.has_key?(:"outer-limits")
95
+ "Supported output media types:\n#{outer_limits(all: true).values.map{|m| m.keys}.join("\n")}"
96
+ else global
97
+ end
98
+ exit
99
+ end
100
+
101
+ # Here's that additional filename validation I was talking about.
102
+ # I don't do this as a one-shot with the argv.shift because
103
+ # File::expand_path raises an error on :nil argument,
104
+ # and we already checked for that when we checked for 'help' switches.
105
+ @name = File.expand_path(@name)
106
+
107
+ # Check for 'help' switches *again* now that we have a source file path,
108
+ # because the output can be file-specific instead of generic.
109
+ # This is where we display subcommands' help!
110
+ specific_help = case
111
+ when @get_out.empty?
112
+ # Only input filename given; no outputs; nothing left to do!
113
+ lower_subcommands.merge(outer_subcommands).values.unshift(Hash[:DistorteD => [global]]).map { |l|
114
+ l.values.join("\n")
115
+ }.join("\n")
116
+ when @global_options.has_key?(:help), @global_options.has_key?(:"lower-world")
117
+ lower_subcommands.values.map { |l|
118
+ l.values.join("\n")
119
+ }.join("\n")
120
+ when @global_options.has_key?(:"outer-limits")
121
+ # Trigger this help message on `-o` iff that switch is used bare.
122
+ # If `-o` is given an argument it will inform the MIME::Type
123
+ # of the same-index output file, e.g.
124
+ # `-o image/png -o image/webp pngnoextension webpnoextension`
125
+ # will work exactly as that example implies.
126
+ @global_options.dig(:"outer-limits")&.empty? ?
127
+ outer_subcommands.values.map { |o|
128
+ o.values.join("\n")
129
+ }.join("\n") : nil
130
+ else nil
131
+ end
132
+ if specific_help
133
+ puts specific_help
134
+ exit
135
+ end
136
+
137
+ # Our "subcommands" are additional instances of OptionParser,
138
+ # one for every MediaMolecule that can load the source file,
139
+ # and one for every intended output variation.
140
+ lower_subcommands.each_pair { |type, molecule_commands|
141
+ molecule_commands.each_pair { |molecule, subcommand|
142
+ begin
143
+ switches = subcommand.permute!(switches, into: @lower_options[type][molecule])
144
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
145
+ nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
146
+ retry
147
+ end
148
+ switches.unshift(*sorry_try_again.reverse)
149
+ @lower_options[type][molecule].store(:molecule, molecule)
150
+ }
151
+ }
152
+ outer_subcommands.each_pair { |molecule, type_commands|
153
+ type_commands.each_pair { |type, subcommand|
154
+ begin
155
+ switches = subcommand.permute!(switches, into: @outer_options[molecule][type])
156
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
157
+ nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
158
+ retry
159
+ end
160
+ switches.unshift(*sorry_try_again.reverse)
161
+ @outer_options[molecule][type].store(:molecule, molecule)
162
+ }
163
+ }
164
+ end
165
+
166
+ # Writes all intended output files to a given directory.
167
+ def write(dest_root)
168
+ changes.each { |change|
169
+ if self.respond_to?(change.type.distorted_file_method)
170
+ # WISHLIST: Remove the empty final positional Hash argument once we require a Ruby version
171
+ # that will not perform the implicit Change-to-Hash conversion due to Change's
172
+ # implementation of :to_hash. Ruby 2.7 will complain but still do the conversion,
173
+ # breaking downstream callers that want a Struct they can call arbitrary key methods on.
174
+ # https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
175
+ self.send(change.type.distorted_file_method, dest_root, change, **{})
176
+ else
177
+ raise MediaTypeOutputNotImplementedError.new(change.name, change.type, self.class.name)
178
+ end
179
+ }
180
+ end
181
+
182
+ private
183
+
184
+ # Partitions the raw `argv` into two buckets — outer_limits (String relative filenames or "media/type" Strings) and switches/arguments.
185
+ #
186
+ # References:
187
+ # - glibc Program Argument Syntax Conventions https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
188
+ # - POSIX Utility Argument Syntax https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_01
189
+ # - Windows Command-line syntax key https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/command-line-syntax-key
190
+ #
191
+ # The filenames will be used as the source file (first member) and destination file(s) (any others).
192
+ # The switches/arguments will be passed to our global OptionParser's `:parse_in_order`
193
+ # which will return the unused remainder.
194
+ #
195
+ # I think it should be possible to achieve this same effect with our global OptionParser alone
196
+ # by specifying two required NoArgument Switches (source and first destination filename),
197
+ # specifying multiple optional NoArgument Switches (additional destinations),
198
+ # and parsing the unmodified `:argv` in permutation mode.
199
+ # I can't figure out how to wrangle OptionParser into doing that rn tho so welp here we are.
200
+ #
201
+ # There is a built-in Enumeraable#partition, but:
202
+ # - It doesn't take an accumulator variable natively.
203
+ # - I'm bad at chaining Enumerators and idk how to chain in a `:with_object` without returning only that object.
204
+ # - `:with_object` treats scalar types as immutable which precludes cleanly passing a boolean flag variable between iterations.
205
+ def partition_argv(argv)
206
+ switches, @get_out = argv.each_with_object(
207
+ # Accumulate to a three-key Hash containing the two wanted buckets and the flag that will be discarded.
208
+ Hash[:switches => Array.new, :get_out => Array.new, :want_value => false]
209
+ ) { |arg, partition|
210
+ # Switches and their values will be:
211
+ # - Any argument beginning with a single dash, e.g. long switches like '--crop' or short switches like '-Q90'.
212
+ # - Any non-dash argument if :want_value is flagged, e.g. the 'attention' value for the '--crop' switch.
213
+ # Filenames will be:
214
+ # - Anything else :)
215
+ if partition.fetch(:want_value) and not arg[0] == '-'.freeze
216
+ # `ARGV` Strings are frozen, so we have to replace instead of directly concat
217
+ partition[:switches].push(partition[:switches].pop.yield_self { |last| "#{last}#{'='.freeze unless arg[0] == '='.freeze}#{arg}" })
218
+ else
219
+ partition[(arg[0] == '-'.freeze or partition.fetch(:want_value)) ? :switches : :get_out].push(arg)
220
+ end
221
+ # The *next* argument should be a value for this iteration's argument iff:
222
+ # - This iteration is a long switch with no included value, e.g. '--crop' but not '--crop=attention'.
223
+ # - This iteration is a short switch with no included value, e.g. '-Q' but not '-Q90' or '-Q=90'.
224
+ partition.store(:want_value, [
225
+ arg[0] == '-'.freeze, # e.g. '--crop' or '-Q'
226
+ !arg.include?('='.freeze), # e.g. not '--crop=attention' or '-Q=90'
227
+ [
228
+ arg[1] == '-'.freeze, # e.g. '--crop'
229
+ [arg[1] == '-'.freeze, arg.length > 2].none? # e.g. not '-Q90'
230
+ ].any?
231
+ ].all?)
232
+ }.values.select(&Array.method(:===)) # Return only the Array members of the Hash
233
+ end
234
+
235
+ # Generic top-level OptionParser
236
+ def global_options(exe_name)
237
+ OptionParser.new do |opts|
238
+ opts.banner = "Usage: #{exe_name} [OPTION]… SOURCE DEST [DEST]…"
239
+ opts.on_tail('-h', '--help', 'Show this message')
240
+ opts.on('-v', '--[no-]verbose', 'Run verbosely')
241
+ opts.on('-l', '--lower-world', 'Show supported input media types')
242
+ opts.on('-o', '--outer-limits', 'Show supported output media types')
243
+ end
244
+ end
245
+
246
+ # Returns an Array[Change] for every intended output variation.
247
+ def changes
248
+ @changes ||= begin
249
+ # TODO: Consume @lower_options as well, and figure out how to specify Molecule
250
+ # for future situations where multiple Molecules may overlap.
251
+ # Until then, just collapse @outer_options to one Hash and take anything we find for our Type.
252
+ combined_outer_options = @outer_options.each_with_object(Array.new) { |(molecule,type_options),combined| combined.push(type_options) }.reduce(&:merge)
253
+ @get_out.each_with_object(Array[]) { |out, wanted|
254
+ # TODO: Nice way to check format for Type string here.
255
+ # Should be e.g. "image/png"
256
+ if CHECKING::YOU::OUT[out].nil?
257
+ name = out
258
+ type = CHECKING::YOU::OUT(out).first
259
+ else
260
+ name = @name
261
+ type = CHECKING::YOU::OUT[out]
262
+ end
263
+ wanted.push(Cooltrainer::Change.new(type, src: name, **(combined_outer_options.fetch(type, {}))))
264
+ }
265
+ end
266
+ end
267
+
268
+ # Returns an absolute String path to the source file.
269
+ def path
270
+ File.expand_path(@name)
271
+ end
272
+
273
+ # This is a CLI, so we always want to write new files when called.
274
+ def modified?
275
+ true
276
+ end
277
+
278
+ # And again.
279
+ def write?
280
+ true
281
+ end
282
+
283
+ # Generate an OptionParser for a flat Enumerable of Compounds
284
+ COMPOUND_OPTIONPARSER = Proc.new { |compounds, from, to|
285
+ OptionParser.new(banner = "#{from.to_s} ⟹ #{to.to_s}:") { |subopt|
286
+ compounds.map { |compound|
287
+ next if compound.nil?
288
+ parts = Array[
289
+ *compound.to_options,
290
+ ]
291
+ if compound.default == true
292
+ parts.append(TrueClass)
293
+ elsif compound.default == false
294
+ parts.append(FalseClass)
295
+ elsif compound.valid.is_a?(Range)
296
+ # TODO: decide how to handle Ranges that might be hundreds/thousands of items in length.
297
+ elsif compound.valid.is_a?(Enumerable)
298
+ parts.append(compound.valid.to_a)
299
+ elsif Cooltrainer::OPTIONPARSER_COERSIONS.include?(compound.valid.class)
300
+ parts.append(compound.valid)
301
+ end
302
+ parts.append(compound.blurb)
303
+ subopt.on(*parts)
304
+ }
305
+ subopt.on_tail('-h', '--help', 'Show this message')
306
+ }
307
+ }
308
+
309
+ # Generate a Hash[MIME::Type] => Hash[MediaMolecule] => OptionParser
310
+ # for file-specific input options.
311
+ def lower_subcommands
312
+ type_mars.each_with_object(Hash[]) { |type, commands|
313
+ lower_world[type].each_pair { |molecule, aka|
314
+ commands.update(type => {
315
+ molecule => COMPOUND_OPTIONPARSER.call(aka.values.to_set, type, molecule)
316
+ }) { |k,o,n| o.merge(n) }
317
+ }
318
+ }
319
+ end
320
+
321
+ # Generate a Hash[MediaMolecule] => Hash[MIME::Type] => OptionParser
322
+ # for file-specific output options.
323
+ def outer_subcommands(all: false)
324
+ outer_limits(all: all).each_with_object(Hash[]) { |(molecule, types), commands|
325
+ types.each_pair { |type, aka|
326
+ commands.update(molecule => {
327
+ type => COMPOUND_OPTIONPARSER.call(aka.values.to_set, molecule, type)
328
+ }) { |k,o,n| o.merge(n) }
329
+ }
330
+ }
331
+ end
332
+
333
+ end