rdeck 0.1.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.
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Hungarian
5
+ def initialize(cost_matrix)
6
+ @original_matrix = cost_matrix
7
+ @n = cost_matrix.size
8
+ @matrix = deep_copy(cost_matrix)
9
+ @marked_rows = Array.new(@n, false)
10
+ @marked_cols = Array.new(@n, false)
11
+ @starred = Array.new(@n) { Array.new(@n, false) }
12
+ @primed = Array.new(@n) { Array.new(@n, false) }
13
+ end
14
+
15
+ def solve
16
+ negate_matrix!
17
+
18
+ subtract_row_minimums!
19
+
20
+ subtract_column_minimums!
21
+
22
+ loop do
23
+ clear_marks!
24
+ star_zeros!
25
+
26
+ break if count_starred_zeros == @n
27
+
28
+ cover_columns!
29
+
30
+ loop do
31
+ row, col = find_uncovered_zero
32
+ if row.nil?
33
+ adjust_matrix!
34
+ next
35
+ end
36
+
37
+ @primed[row][col] = true
38
+
39
+ star_col = find_star_in_row(row)
40
+ if star_col.nil?
41
+ augment_path(row, col)
42
+ clear_primes!
43
+ break
44
+ else
45
+ @marked_rows[row] = true
46
+ @marked_cols[star_col] = false
47
+ end
48
+ end
49
+
50
+ break if count_starred_zeros == @n
51
+ end
52
+
53
+ extract_solution
54
+ end
55
+
56
+ private
57
+
58
+ def deep_copy(matrix)
59
+ matrix.map(&:dup)
60
+ end
61
+
62
+ def negate_matrix!
63
+ @n.times do |i|
64
+ @n.times do |j|
65
+ @matrix[i][j] = -@matrix[i][j]
66
+ end
67
+ end
68
+ end
69
+
70
+ def subtract_row_minimums!
71
+ @n.times do |i|
72
+ min = @matrix[i].min
73
+ @n.times do |j|
74
+ @matrix[i][j] -= min
75
+ end
76
+ end
77
+ end
78
+
79
+ def subtract_column_minimums!
80
+ @n.times do |j|
81
+ min = Array.new(@n) { |i| @matrix[i][j] }.min
82
+ @n.times do |i|
83
+ @matrix[i][j] -= min
84
+ end
85
+ end
86
+ end
87
+
88
+ def clear_marks!
89
+ @marked_rows.fill(false)
90
+ @marked_cols.fill(false)
91
+ end
92
+
93
+ def star_zeros!
94
+ @n.times do |i|
95
+ @n.times do |j|
96
+ next unless @matrix[i][j].zero?
97
+ next if row_has_star?(i) || col_has_star?(j)
98
+
99
+ @starred[i][j] = true
100
+ end
101
+ end
102
+ end
103
+
104
+ def row_has_star?(row)
105
+ @starred[row].any?
106
+ end
107
+
108
+ def col_has_star?(col)
109
+ @n.times.any? { |i| @starred[i][col] }
110
+ end
111
+
112
+ def count_starred_zeros
113
+ @starred.flatten.count(true)
114
+ end
115
+
116
+ def cover_columns!
117
+ @n.times do |j|
118
+ @marked_cols[j] = true if col_has_star?(j)
119
+ end
120
+ end
121
+
122
+ def find_uncovered_zero
123
+ @n.times do |i|
124
+ next if @marked_rows[i]
125
+
126
+ @n.times do |j|
127
+ next if @marked_cols[j]
128
+
129
+ return [i, j] if @matrix[i][j].zero?
130
+ end
131
+ end
132
+ nil
133
+ end
134
+
135
+ def find_star_in_row(row)
136
+ @n.times do |j|
137
+ return j if @starred[row][j]
138
+ end
139
+ nil
140
+ end
141
+
142
+ def find_prime_in_row(row)
143
+ @n.times do |j|
144
+ return j if @primed[row][j]
145
+ end
146
+ nil
147
+ end
148
+
149
+ def find_star_in_col(col)
150
+ @n.times do |i|
151
+ return i if @starred[i][col]
152
+ end
153
+ nil
154
+ end
155
+
156
+ def augment_path(row, col)
157
+ path = [[row, col]]
158
+
159
+ loop do
160
+ star_row = find_star_in_col(path.last[1])
161
+ break if star_row.nil?
162
+
163
+ path << [star_row, path.last[1]]
164
+
165
+ prime_col = find_prime_in_row(star_row)
166
+ path << [star_row, prime_col]
167
+ end
168
+
169
+ path.each_with_index do |(r, c), idx|
170
+ @starred[r][c] = idx.even?
171
+ end
172
+ end
173
+
174
+ def clear_primes!
175
+ @n.times do |i|
176
+ @n.times do |j|
177
+ @primed[i][j] = false
178
+ end
179
+ end
180
+ end
181
+
182
+ def adjust_matrix!
183
+ min = Float::INFINITY
184
+ @n.times do |i|
185
+ next if @marked_rows[i]
186
+
187
+ @n.times do |j|
188
+ next if @marked_cols[j]
189
+
190
+ min = @matrix[i][j] if @matrix[i][j] < min
191
+ end
192
+ end
193
+
194
+ @n.times do |i|
195
+ @n.times do |j|
196
+ if @marked_rows[i] && @marked_cols[j]
197
+ @matrix[i][j] += min
198
+ elsif !@marked_rows[i] && !@marked_cols[j]
199
+ @matrix[i][j] -= min
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ def extract_solution
206
+ solution = {}
207
+ @n.times do |i|
208
+ @n.times do |j|
209
+ solution[i] = j if @starred[i][j]
210
+ end
211
+ end
212
+ solution
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'zlib'
6
+
7
+ module Rdeck
8
+ class Image
9
+ MIME_PNG = 'image/png'
10
+ MIME_JPEG = 'image/jpeg'
11
+ MIME_GIF = 'image/gif'
12
+
13
+ attr_accessor :data, :mime_type, :url, :from_markdown, :checksum, :phash, :mod_time, :link
14
+
15
+ def initialize(path_or_url, from_markdown: false, link: '')
16
+ @from_markdown = from_markdown
17
+ @link = link
18
+ load_from(path_or_url)
19
+ end
20
+
21
+ def equivalent?(other)
22
+ return false unless other.is_a?(Image)
23
+ return false if mime_type != other.mime_type
24
+ return false if link != other.link
25
+ return true if checksum == other.checksum
26
+
27
+ false
28
+ end
29
+
30
+ def checksum
31
+ @checksum ||= Zlib.crc32(data)
32
+ end
33
+
34
+ def phash
35
+ @phash ||= calculate_phash
36
+ end
37
+
38
+ def mime_type_to_ext
39
+ case mime_type
40
+ when MIME_PNG then '.png'
41
+ when MIME_JPEG then '.jpg'
42
+ when MIME_GIF then '.gif'
43
+ else '.png'
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def load_from(path_or_url)
50
+ if path_or_url.start_with?('http://', 'https://')
51
+ load_from_url(path_or_url)
52
+ else
53
+ load_from_file(path_or_url)
54
+ end
55
+ end
56
+
57
+ def load_from_url(url_str)
58
+ @url = url_str
59
+ uri = URI.parse(url_str)
60
+ response = Net::HTTP.get_response(uri)
61
+ raise Error, "Failed to fetch image from #{url_str}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
+
63
+ @data = response.body
64
+ @mime_type = detect_mime_type(response['content-type'])
65
+ @mod_time = Time.now
66
+ end
67
+
68
+ def load_from_file(path)
69
+ raise Error, "Image file not found: #{path}" unless File.exist?(path)
70
+
71
+ @data = File.binread(path)
72
+ @mime_type = detect_mime_type_from_data(@data)
73
+ @mod_time = File.mtime(path)
74
+ @url = "file://#{File.expand_path(path)}"
75
+ end
76
+
77
+ def detect_mime_type(content_type)
78
+ return MIME_PNG if content_type&.include?('png')
79
+ return MIME_JPEG if content_type&.include?('jpeg') || content_type&.include?('jpg')
80
+ return MIME_GIF if content_type&.include?('gif')
81
+
82
+ detect_mime_type_from_data(@data)
83
+ end
84
+
85
+ def detect_mime_type_from_data(data)
86
+ return MIME_PNG if data[0, 8] == "\x89PNG\r\n\x1A\n".b
87
+ return MIME_JPEG if data[0, 2] == "\xFF\xD8".b
88
+ return MIME_GIF if %w[GIF87a GIF89a].include?(data[0, 6])
89
+
90
+ MIME_PNG
91
+ end
92
+
93
+ def calculate_phash
94
+ checksum
95
+ rescue StandardError => e
96
+ warn "Failed to calculate pHash: #{e.message}"
97
+ nil
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module Rdeck
6
+ class ImageUploader
7
+ def initialize(drive_service, folder_id: nil)
8
+ @drive_service = drive_service
9
+ @folder_id = folder_id
10
+ @uploaded_files = []
11
+ end
12
+
13
+ def upload(image)
14
+ temp_file = create_temp_file(image)
15
+
16
+ begin
17
+ file_metadata = Google::Apis::DriveV3::File.new(
18
+ name: "rdeck_temp_#{Time.now.to_i}_#{rand(10_000)}#{image.mime_type_to_ext}",
19
+ parents: @folder_id ? [@folder_id] : nil
20
+ )
21
+
22
+ file = @drive_service.create_file(
23
+ file_metadata,
24
+ fields: 'id,webContentLink,webViewLink',
25
+ upload_source: temp_file.path,
26
+ content_type: image.mime_type,
27
+ supports_all_drives: true
28
+ )
29
+
30
+ permission = Google::Apis::DriveV3::Permission.new(
31
+ type: 'anyone',
32
+ role: 'reader'
33
+ )
34
+
35
+ @drive_service.create_permission(
36
+ file.id,
37
+ permission,
38
+ supports_all_drives: true
39
+ )
40
+
41
+ @uploaded_files << file.id
42
+
43
+ file.web_content_link || file.web_view_link
44
+ ensure
45
+ temp_file.close
46
+ temp_file.unlink
47
+ end
48
+ end
49
+
50
+ def upload_batch(images)
51
+ images.map { |image| upload(image) }
52
+ end
53
+
54
+ def cleanup
55
+ @uploaded_files.each do |file_id|
56
+ @drive_service.delete_file(file_id, supports_all_drives: true)
57
+ rescue Google::Apis::Error => e
58
+ warn "Failed to delete temporary file #{file_id}: #{e.message}"
59
+ end
60
+ @uploaded_files.clear
61
+ end
62
+
63
+ private
64
+
65
+ def create_temp_file(image)
66
+ ext = image.mime_type_to_ext
67
+ temp_file = Tempfile.new(['rdeck_image', ext])
68
+ temp_file.binmode
69
+ temp_file.write(image.data)
70
+ temp_file.flush
71
+ temp_file.rewind
72
+ temp_file
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ module Markdown
5
+ class CELEvaluator
6
+ def evaluate(expression, context)
7
+ return false if expression.nil? || expression.empty?
8
+
9
+ expr = expression.dup
10
+ context.each do |key, value|
11
+ expr = replace_variable(expr, key.to_s, value)
12
+ end
13
+
14
+ expr = transform_methods(expr)
15
+
16
+ safe_eval(expr, context)
17
+ rescue StandardError => e
18
+ warn "Failed to evaluate CEL expression '#{expression}': #{e.message}"
19
+ false
20
+ end
21
+
22
+ private
23
+
24
+ def replace_variable(expr, key, value)
25
+ case value
26
+ when Integer, Float
27
+ expr.gsub(/\b#{Regexp.escape(key)}\b/, value.to_s)
28
+ when String
29
+ expr.gsub(/\b#{Regexp.escape(key)}\b/, "\"#{escape_string(value)}\"")
30
+ when Array
31
+ array_str = "[#{value.map { |v| serialize_value(v) }.join(', ')}]"
32
+ expr.gsub(/\b#{Regexp.escape(key)}\b/, array_str)
33
+ when Hash
34
+ hash_str = "{#{value.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }.join(', ')}}"
35
+ expr.gsub(/\b#{Regexp.escape(key)}\b/, hash_str)
36
+ when true, false
37
+ expr.gsub(/\b#{Regexp.escape(key)}\b/, value.to_s)
38
+ else
39
+ expr
40
+ end
41
+ end
42
+
43
+ def serialize_value(value)
44
+ case value
45
+ when Integer, Float, true, false
46
+ value.to_s
47
+ when String
48
+ "\"#{escape_string(value)}\""
49
+ when Array
50
+ "[#{value.map { |v| serialize_value(v) }.join(', ')}]"
51
+ when Hash
52
+ "{#{value.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }.join(', ')}}"
53
+ else
54
+ 'nil'
55
+ end
56
+ end
57
+
58
+ def escape_string(str)
59
+ str.gsub('"', '\\"').gsub("\n", '\\n')
60
+ end
61
+
62
+ def transform_methods(expr)
63
+ expr
64
+ .gsub('.size()', '.size')
65
+ .gsub(/\.contains\(([^)]+)\)/, '.include?(\1)')
66
+ .gsub(/\.startsWith\(([^)]+)\)/, '.start_with?(\1)')
67
+ .gsub(/\.endsWith\(([^)]+)\)/, '.end_with?(\1)')
68
+ .gsub(/\.matches\(([^)]+)\)/, '.match?(\1)')
69
+ end
70
+
71
+ def safe_eval(expr, _context)
72
+ return false unless safe_expression?(expr)
73
+
74
+ # rubocop:disable Security/Eval
75
+ eval(expr)
76
+ # rubocop:enable Security/Eval
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def safe_expression?(expr)
82
+ dangerous_patterns = [
83
+ /`/,
84
+ /system/i,
85
+ /exec/i,
86
+ /fork/i,
87
+ /spawn/i,
88
+ /load/i,
89
+ /require/i,
90
+ /File\./,
91
+ /Dir\./,
92
+ /IO\./,
93
+ /Kernel\./,
94
+ /\beval\b/,
95
+ /send/i,
96
+ /instance_eval/i,
97
+ /class_eval/i,
98
+ /module_eval/i,
99
+ /define_method/i,
100
+ /__send__/,
101
+ /const_get/i,
102
+ /const_set/i
103
+ ]
104
+
105
+ dangerous_patterns.none? { |pattern| expr.match?(pattern) }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rdeck
6
+ module Markdown
7
+ class Frontmatter
8
+ attr_accessor :presentation_id, :title, :breaks, :defaults, :code_block_to_image_command
9
+
10
+ def initialize(attrs = {})
11
+ @presentation_id = attrs[:presentationID] || attrs['presentationID']
12
+ @title = attrs[:title] || attrs['title']
13
+ @breaks = attrs[:breaks] || attrs['breaks']
14
+ @defaults = parse_defaults(attrs[:defaults] || attrs['defaults'])
15
+ @code_block_to_image_command = attrs[:codeBlockToImageCommand] || attrs['codeBlockToImageCommand']
16
+ end
17
+
18
+ def self.extract(content)
19
+ return [nil, content] unless content.start_with?("---\n")
20
+
21
+ lines = content.lines
22
+ return [nil, content] unless lines.first == "---\n"
23
+
24
+ closing_index = lines[1..].index { |line| line.strip == '---' }
25
+ return [nil, content] unless closing_index
26
+
27
+ yaml_content = lines[1..closing_index].join
28
+ body_content = lines[(closing_index + 2)..].join
29
+
30
+ begin
31
+ yaml = YAML.safe_load(yaml_content, permitted_classes: [Symbol], symbolize_names: true)
32
+ frontmatter = new(yaml || {})
33
+ [frontmatter, body_content]
34
+ rescue Psych::SyntaxError => e
35
+ warn "Failed to parse frontmatter: #{e.message}"
36
+ [nil, content]
37
+ end
38
+ end
39
+
40
+ def self.apply_to_file(file_path, title, presentation_id)
41
+ content = File.exist?(file_path) ? File.read(file_path) : ''
42
+ frontmatter, body = extract(content)
43
+
44
+ frontmatter ||= new
45
+ frontmatter.title = title if title
46
+ frontmatter.presentation_id = presentation_id if presentation_id
47
+
48
+ new_content = "#{to_yaml_frontmatter(frontmatter)}\n#{body}"
49
+ File.write(file_path, new_content)
50
+ end
51
+
52
+ def self.to_yaml_frontmatter(frontmatter)
53
+ hash = {}
54
+ hash['presentationID'] = frontmatter.presentation_id if frontmatter.presentation_id
55
+ hash['title'] = frontmatter.title if frontmatter.title
56
+ hash['breaks'] = frontmatter.breaks unless frontmatter.breaks.nil?
57
+ hash['defaults'] = frontmatter.defaults if frontmatter.defaults && !frontmatter.defaults.empty?
58
+ if frontmatter.code_block_to_image_command
59
+ hash['codeBlockToImageCommand'] =
60
+ frontmatter.code_block_to_image_command
61
+ end
62
+
63
+ "---\n#{hash.to_yaml.sub(/^---\n/, '')}---"
64
+ end
65
+
66
+ private
67
+
68
+ def parse_defaults(defaults_array)
69
+ return [] unless defaults_array.is_a?(Array)
70
+
71
+ defaults_array.map { |default| DefaultCondition.new(default) }
72
+ end
73
+ end
74
+ end
75
+ end