canvas_link_migrator 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 993c3428360b15aea7b4eca2ee43652342f4a700e23837794ffe913e1572dea1
4
+ data.tar.gz: 5081c3aa602c6cf78fc3bd1e152a4c8e12f419fc6853ab72a9d09f86ba42498e
5
+ SHA512:
6
+ metadata.gz: d8d3068542e30d89ddffb809c95b59ba1a2665a515a65d8f6359ec3c1f7fd204560794a3d016fc861490561215e82aad126fdb1ff0ef0f9cd8d20c6ebc12c7bb
7
+ data.tar.gz: b18abcf3c0b4e325cca73e10a905fea2d09c0f444230766eae955f842f8bde394765254cc9c9626ee7d6510251069e0aeed9061c81eb2c93df6b52e485aa5212
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (C) 2023 - present Instructure, Inc.
5
+ #
6
+ # This file is part of Canvas.
7
+ #
8
+ # Canvas is free software: you can redistribute it and/or modify it under
9
+ # the terms of the GNU Affero General Public License as published by the Free
10
+ # Software Foundation, version 3 of the License.
11
+ #
12
+ # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
13
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
+ # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15
+ # details.
16
+ #
17
+ # You should have received a copy of the GNU Affero General Public License along
18
+ # with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ require "active_support/core_ext/module"
21
+
22
+ module CanvasLinkMigrator
23
+ class ImportedHtmlConverter
24
+ attr_reader :link_parser, :link_resolver, :migration_id_converter
25
+
26
+ def initialize(resource_map: nil, migration_id_converter: nil)
27
+ @migration_id_converter = migration_id_converter || ResourceMapService.new(resource_map)
28
+ @link_parser = LinkParser.new(@migration_id_converter)
29
+ @link_resolver = LinkResolver.new(@migration_id_converter)
30
+ end
31
+
32
+ delegate :convert, to: :link_parser
33
+ delegate :resolver_links!, to: :link_resolver
34
+
35
+ def convert_exported_html(input_html)
36
+ new_html = link_parser.convert(input_html, "type", "lookup_id", "field")
37
+ replace!(new_html)
38
+
39
+ # missing links comes back as a list for all types and fields, but if the user's only
40
+ # sending one piece of html at a time, we only need the first set of missing links,
41
+ # and only the actual missing links, not the look up information we set in this method
42
+ bad_links = missing_links&.first&.dig(:missing_links)
43
+ link_parser.reset!
44
+ [new_html, bad_links]
45
+ end
46
+
47
+ def replace!(placeholder_html)
48
+ link_map = link_parser.unresolved_link_map
49
+ return unless link_map.present?
50
+
51
+ link_resolver.resolve_links!(link_map)
52
+ LinkReplacer.sub_placeholders!(placeholder_html, link_map.values.map(&:values).flatten)
53
+ placeholder_html
54
+ end
55
+
56
+ def missing_links
57
+ link_parser.unresolved_link_map.each_with_object([]) do |(item_key, field_links), bad_links|
58
+ field_links.each do |field, links|
59
+ unresolved_links = links.select { |link| link[:replaced] && (link[:missing_url] || !link[:new_value]) }
60
+ unresolved_links = unresolved_links.map { |link| link.slice(:link_type, :missing_url) }
61
+ next unless unresolved_links.any?
62
+
63
+ bad_links << { object_lookup_id: item_key[:migration_id], object_type: item_key[:type], object_field: field, missing_links: unresolved_links }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (C) 2015 - present Instructure, Inc.
5
+ #
6
+ # This file is part of Canvas.
7
+ #
8
+ # Canvas is free software: you can redistribute it and/or modify it under
9
+ # the terms of the GNU Affero General Public License as published by the Free
10
+ # Software Foundation, version 3 of the License.
11
+ #
12
+ # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
13
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
+ # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15
+ # details.
16
+ #
17
+ # You should have received a copy of the GNU Affero General Public License along
18
+ # with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ require "nokogiri"
21
+ require "digest"
22
+
23
+ module CanvasLinkMigrator
24
+ class LinkParser
25
+ REFERENCE_KEYWORDS = %w[CANVAS_COURSE_REFERENCE CANVAS_OBJECT_REFERENCE WIKI_REFERENCE IMS_CC_FILEBASE IMS-CC-FILEBASE].freeze
26
+ LINK_PLACEHOLDER = "LINK.PLACEHOLDER"
27
+ KNOWN_REFERENCE_TYPES = %w[
28
+ announcements
29
+ appointment_participants
30
+ assignment_groups
31
+ assignments
32
+ attachments
33
+ calendar_events
34
+ context_external_tools
35
+ context_module_tags
36
+ context_modules
37
+ course_paces
38
+ created_learning_outcomes
39
+ discussion_entries
40
+ discussion_topics
41
+ external_feeds
42
+ grading_standards
43
+ groups
44
+ learning_outcome_groups
45
+ learning_outcome_links
46
+ learning_outcomes
47
+ linked_learning_outcomes
48
+ media_attachments_iframe
49
+ modules
50
+ pages
51
+ quizzes
52
+ rubrics
53
+ wiki
54
+ wiki_pages
55
+ ].freeze
56
+ CONTAINER_TYPES = %w[div p body].freeze
57
+ LINK_ATTRS = %w[rel href src srcset data value longdesc data-download-url].freeze
58
+ RCE_MEDIA_TYPES = %w[audio video].freeze
59
+
60
+ attr_reader :unresolved_link_map, :migration_query_service
61
+
62
+ def initialize(migration_query_service)
63
+ @migration_query_service = migration_query_service
64
+ reset!
65
+ end
66
+
67
+ def reset!
68
+ @unresolved_link_map = {}
69
+ end
70
+
71
+ def add_unresolved_link(link, item_type, mig_id, field)
72
+ key = { type: item_type, migration_id: mig_id }
73
+ @unresolved_link_map[key] ||= {}
74
+ @unresolved_link_map[key][field] ||= []
75
+ @unresolved_link_map[key][field] << link
76
+ end
77
+
78
+ def placeholder(old_value)
79
+ "#{LINK_PLACEHOLDER}_#{Digest::MD5.hexdigest(old_value)}"
80
+ end
81
+
82
+ def convert(html, item_type, mig_id, field, remove_outer_nodes_if_one_child: nil)
83
+ mig_id = mig_id.to_s
84
+ doc = Nokogiri::HTML5(html || "")
85
+
86
+ # Replace source tags with iframes
87
+ doc.search("source[data-media-id]").each do |source|
88
+ next unless RCE_MEDIA_TYPES.include?(source.parent.name)
89
+
90
+ media_node = source.parent
91
+ media_node.name = "iframe"
92
+ media_node["src"] = source["src"]
93
+ source.remove
94
+ end
95
+
96
+ doc.search("*").each do |node|
97
+ LINK_ATTRS.each do |attr|
98
+ convert_link(node, attr, item_type, mig_id, field)
99
+ end
100
+ end
101
+
102
+ node = doc.at_css("body")
103
+ return "" unless node
104
+
105
+ if remove_outer_nodes_if_one_child
106
+ while node.children.size == 1 && node.child.child
107
+ break unless CONTAINER_TYPES.member?(node.child.name) && node.child.attributes.blank?
108
+
109
+ node = node.child
110
+ end
111
+ end
112
+
113
+ node.inner_html
114
+ rescue Nokogiri::SyntaxError
115
+ ""
116
+ end
117
+
118
+ def convert_link(node, attr, item_type, mig_id, field)
119
+ return unless node[attr].present?
120
+
121
+ if attr == "value" &&
122
+ !(node[attr] =~ /IMS(?:-|_)CC(?:-|_)FILEBASE/ || node[attr].include?("CANVAS_COURSE_REFERENCE"))
123
+ return
124
+ end
125
+
126
+ url = node[attr].dup
127
+ REFERENCE_KEYWORDS.each do |ref|
128
+ url.gsub!("%24#{ref}%24", "$#{ref}$")
129
+ end
130
+
131
+ result = parse_url(url, node, attr)
132
+ if result[:resolved]
133
+ # resolved, just replace and carry on
134
+ new_url = result[:new_url] || url
135
+ unless CanvasLinkMigrator.relative_url?(new_url)
136
+ # perform configured substitutions
137
+ if (processed_url = @migration_query_service.process_domain_substitutions(new_url))
138
+ new_url = processed_url
139
+ end
140
+ # relative-ize absolute links outside the course but inside our domain
141
+ # (analogous to what is done in Api#process_incoming_html_content)
142
+ begin
143
+ uri = URI.parse(new_url)
144
+ account_hosts = @migration_query_service.context_hosts.map { |h| h.split(":").first }
145
+ if account_hosts.include?(uri.host)
146
+ uri.scheme = uri.host = uri.port = nil
147
+ new_url = uri.to_s
148
+ end
149
+ rescue URI::InvalidURIError, URI::InvalidComponentError
150
+ nil
151
+ end
152
+ end
153
+ node[attr] = new_url
154
+ else
155
+ result.delete(:resolved)
156
+ if result[:link_type] == :media_object
157
+ # because we may actually change the media comment node itself
158
+ # (rather than just replacing a value), we're going to
159
+ # replace the entire node with a placeholder
160
+ result[:old_value] = node.to_xml
161
+ result[:placeholder] = placeholder(result[:old_value])
162
+ placeholder_node = Nokogiri::HTML5.fragment(result[:placeholder])
163
+
164
+ node.replace(placeholder_node)
165
+ else
166
+ result[:old_value] = node[attr]
167
+ result[:placeholder] = placeholder(result[:old_value])
168
+ node[attr] = result[:placeholder]
169
+ end
170
+ add_unresolved_link(result, item_type, mig_id, field)
171
+ end
172
+ end
173
+
174
+ def unresolved(type, data = {})
175
+ { resolved: false, link_type: type }.merge(data)
176
+ end
177
+
178
+ def resolved(new_url = nil)
179
+ { resolved: true, new_url: new_url }
180
+ end
181
+
182
+ # returns a hash with resolution status and data to hold onto if unresolved
183
+ def parse_url(url, node, attr)
184
+ if url =~ /wiki_page_migration_id=(.*)/
185
+ unresolved(:wiki_page, migration_id: $1)
186
+ elsif url =~ /discussion_topic_migration_id=(.*)/
187
+ unresolved(:discussion_topic, migration_id: $1)
188
+ elsif url =~ %r{\$CANVAS_COURSE_REFERENCE\$/modules/items/([^?]*)(\?.*)?}
189
+ unresolved(:module_item, migration_id: $1, query: $2)
190
+ elsif url =~ %r{\$CANVAS_COURSE_REFERENCE\$/file_ref/([^/?#]+)(.*)}
191
+ unresolved(:file_ref,
192
+ migration_id: $1,
193
+ rest: $2,
194
+ in_media_iframe: attr == "src" && ["iframe", "source"].include?(node.name) && node["data-media-id"])
195
+ elsif url =~ %r{(?:\$CANVAS_OBJECT_REFERENCE\$|\$WIKI_REFERENCE\$)/([^/]*)/([^?]*)(\?.*)?}
196
+ if KNOWN_REFERENCE_TYPES.include?($1)
197
+ unresolved(:object, type: $1, migration_id: $2, query: $3)
198
+ else
199
+ # If the `type` is not known, there's something amiss...
200
+ @migration_query_service.report_link_parse_warning($1)
201
+ resolved(url)
202
+ end
203
+ elsif url =~ %r{\$CANVAS_COURSE_REFERENCE\$/(.*)}
204
+ resolved("#{@migration_query_service.context_path}/#{$1}")
205
+
206
+ elsif url =~ %r{\$IMS(?:-|_)CC(?:-|_)FILEBASE\$/(.*)}
207
+ rel_path = URI::DEFAULT_PARSER.unescape($1)
208
+ if (attr == "href" && node["class"]&.include?("instructure_inline_media_comment")) ||
209
+ (attr == "src" && ["iframe", "source"].include?(node.name) && node["data-media-id"])
210
+ unresolved(:media_object, rel_path: rel_path)
211
+ else
212
+ unresolved(:file, rel_path: rel_path)
213
+ end
214
+ elsif (attr == "href" && node["class"]&.include?("instructure_inline_media_comment")) ||
215
+ (attr == "src" && ["iframe", "source"].include?(node.name) && node["data-media-id"])
216
+ # Course copy media reference, leave it alone
217
+ resolved
218
+ elsif @migration_query_service.supports_embedded_images && attr == "src" && (info_match = url.match(%r{\Adata:(?<mime_type>[-\w]+/[-\w+.]+)?;base64,(?<image>.*)}m))
219
+ result = @migration_query_service.link_embedded_image(info_match)
220
+ if result[:resolved]
221
+ resolved(result[:url])
222
+ else
223
+ unresolved(:file, rel_path: result[:url])
224
+ end
225
+ elsif # rubocop:disable Lint/DuplicateBranch
226
+ # Equation image, leave it alone
227
+ (attr == "src" && node["class"] && node["class"].include?("equation_image")) || # rubocop:disable Layout/ConditionPosition
228
+ # The file is in the context of an AQ, leave the link alone
229
+ url =~ %r{\A/assessment_questions/\d+/files/\d+} ||
230
+ # This points to a specific file already, leave it alone
231
+ url =~ %r{\A/courses/\d+/files/\d+} ||
232
+ !@migration_query_service.fix_relative_urls? ||
233
+ # It's just a link to an anchor, leave it alone
234
+ url.start_with?("#")
235
+ resolved
236
+ elsif CanvasLinkMigrator.relative_url?(url)
237
+ unresolved(:file, rel_path: URI::DEFAULT_PARSER.unescape(url))
238
+ else # rubocop:disable Lint/DuplicateBranch
239
+ resolved
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (C) 2015 - present Instructure, Inc.
5
+ #
6
+ # This file is part of Canvas.
7
+ #
8
+ # Canvas is free software: you can redistribute it and/or modify it under
9
+ # the terms of the GNU Affero General Public License as published by the Free
10
+ # Software Foundation, version 3 of the License.
11
+ #
12
+ # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
13
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
+ # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15
+ # details.
16
+ #
17
+ # You should have received a copy of the GNU Affero General Public License along
18
+ # with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ module CanvasLinkMigrator
21
+ class LinkReplacer
22
+ # returns false if no substitutions were made
23
+ def self.sub_placeholders!(html, links)
24
+ subbed = false
25
+ links.each do |link|
26
+ new_value = link[:new_value] || link[:old_value]
27
+ if html.gsub!(link[:placeholder], new_value)
28
+ link[:replaced] = true
29
+ subbed = true
30
+ end
31
+ end
32
+ subbed
33
+ end
34
+
35
+ def self.recursively_sub_placeholders!(object, links)
36
+ subbed = false
37
+ case object
38
+ when Hash
39
+ object.each_value { |o| subbed = true if recursively_sub_placeholders!(o, links) }
40
+ when Array
41
+ object.each { |o| subbed = true if recursively_sub_placeholders!(o, links) }
42
+ when String
43
+ subbed = sub_placeholders!(object, links)
44
+ end
45
+ subbed
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (C) 2015 - present Instructure, Inc.
5
+ #
6
+ # This file is part of Canvas.
7
+ #
8
+ # Canvas is free software: you can redistribute it and/or modify it under
9
+ # the terms of the GNU Affero General Public License as published by the Free
10
+ # Software Foundation, version 3 of the License.
11
+ #
12
+ # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
13
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
+ # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15
+ # details.
16
+ #
17
+ # You should have received a copy of the GNU Affero General Public License along
18
+ # with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ require "active_support/core_ext/object"
21
+
22
+ module CanvasLinkMigrator
23
+ class LinkResolver
24
+ def initialize(migration_id_converter)
25
+ @migration_id_converter = migration_id_converter
26
+ end
27
+
28
+ def resolve_links!(link_map)
29
+ link_map.each_value do |field_links|
30
+ field_links.each_value do |links|
31
+ links.each do |link|
32
+ resolve_link!(link)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def context_path
39
+ @migration_id_converter.context_path
40
+ end
41
+
42
+ # finds the :new_value to use to replace the placeholder
43
+ def resolve_link!(link)
44
+ case link[:link_type]
45
+ when :wiki_page
46
+ if (linked_wiki_url = @migration_id_converter.convert_wiki_page_migration_id_to_slug(link[:migration_id]))
47
+ link[:new_value] = "#{context_path}/pages/#{linked_wiki_url}#{link[:query]}"
48
+ end
49
+ when :discussion_topic
50
+ if (linked_topic_id = @migration_id_converter.convert_discussion_topic_migration_id(link[:migration_id]))
51
+ link[:new_value] = "#{context_path}/discussion_topics/#{linked_topic_id}#{link[:query]}"
52
+ end
53
+ when :module_item
54
+ if (tag_id = @migration_id_converter.convert_context_module_tag_migration_id(link[:migration_id]))
55
+ link[:new_value] = "#{context_path}/modules/items/#{tag_id}#{link[:query]}"
56
+ end
57
+ when :object
58
+ type = link[:type]
59
+ migration_id = link[:migration_id]
60
+
61
+ type_for_url = type
62
+ type = "context_modules" if type == "modules"
63
+ type = "pages" if type == "wiki"
64
+ if type == "pages"
65
+ query = resolve_module_item_query(nil, link[:query])
66
+ link[:new_value] = "#{context_path}/pages/#{migration_id}#{query}"
67
+ elsif type == "attachments"
68
+ att_id = @migration_id_converter.convert_attachment_migration_id(migration_id)
69
+ if att_id
70
+ link[:new_value] = "#{context_path}/files/#{att_id}/preview"
71
+ end
72
+ elsif type == "media_attachments_iframe"
73
+ att_id = @migration_id_converter.convert_attachment_migration_id(migration_id)
74
+ link[:new_value] = att_id ? "/media_attachments_iframe/#{att_id}#{link[:query]}" : link[:old_value]
75
+ else
76
+ object_id = @migration_id_converter.convert_migration_id(type, migration_id)
77
+ if object_id
78
+ query = resolve_module_item_query(nil, link[:query])
79
+ link[:new_value] = "#{context_path}/#{type_for_url}/#{object_id}#{query}"
80
+ end
81
+ end
82
+ when :media_object
83
+ # because we actually might change the node itself
84
+ # this part is a little trickier
85
+ # tl;dr we've replaced the entire node with the placeholder
86
+ # see LinkParser for details
87
+ rel_path = link[:rel_path]
88
+ node = Nokogiri::HTML5.fragment(link[:old_value]).children.first
89
+ new_url = resolve_media_comment_data(node, rel_path)
90
+ new_url ||= resolve_relative_file_url(rel_path)
91
+
92
+ unless new_url
93
+ new_url ||= missing_relative_file_url(rel_path)
94
+ link[:missing_url] = new_url
95
+ end
96
+ if ["iframe", "source"].include?(node.name)
97
+ node["src"] = new_url
98
+ else
99
+ node["href"] = new_url
100
+ end
101
+ link[:new_value] = node.to_s
102
+ when :file
103
+ rel_path = link[:rel_path]
104
+ new_url = resolve_relative_file_url(rel_path)
105
+ unless new_url
106
+ new_url = missing_relative_file_url(rel_path)
107
+ link[:missing_url] = new_url
108
+ end
109
+ link[:new_value] = new_url
110
+ when :file_ref
111
+ file_id = @migration_id_converter.convert_attachment_migration_id(link[:migration_id])
112
+ if file_id
113
+ rest = link[:rest].presence || "/preview"
114
+
115
+ # Icon Maker files should not have the course
116
+ # context prepended to the URL. This prevents
117
+ # redirects to non cross-origin friendly urls
118
+ # during a file fetch
119
+ if rest.include?("icon_maker_icon=1")
120
+ link[:new_value] = "/files/#{file_id}#{rest}"
121
+ else
122
+ link[:new_value] = "#{context_path}/files/#{file_id}#{rest}"
123
+ link[:new_value] = "/media_objects_iframe?mediahref=#{link[:new_value]}" if link[:in_media_iframe]
124
+ end
125
+ end
126
+ else
127
+ raise "unrecognized link_type (#{link[:link_type]}) in unresolved link"
128
+ end
129
+ end
130
+
131
+ def resolve_module_item_query(_context, query)
132
+ return query unless query&.include?("module_item_id=")
133
+
134
+ original_param = query.sub("?", "").split("&").detect { |p| p.include?("module_item_id=") }
135
+ mig_id = original_param.split("=").last
136
+ tag_id = @migration_id_converter.convert_context_module_tag_migration_id(mig_id)
137
+ return query unless tag_id
138
+
139
+ new_param = "module_item_id=#{tag_id}"
140
+ query.sub(original_param, new_param)
141
+ end
142
+
143
+ def missing_relative_file_url(rel_path)
144
+ # the rel_path should already be escaped
145
+ File.join(URI::DEFAULT_PARSER.escape("#{context_path}/file_contents/#{@migration_id_converter.root_folder_name}"), rel_path.gsub(" ", "%20"))
146
+ end
147
+
148
+ def find_file_in_context(rel_path)
149
+ mig_id = nil
150
+ # This is for backward-compatibility: canvas attachment filenames are escaped
151
+ # with '+' for spaces and older exports have files with that instead of %20
152
+ alt_rel_path = rel_path.tr("+", " ")
153
+ if @migration_id_converter.attachment_path_id_lookup
154
+ mig_id ||= @migration_id_converter.attachment_path_id_lookup[rel_path]
155
+ mig_id ||= @migration_id_converter.attachment_path_id_lookup[alt_rel_path]
156
+ end
157
+ if !mig_id && @migration_id_converter.attachment_path_id_lookup_lower
158
+ mig_id ||= @migration_id_converter.attachment_path_id_lookup_lower[rel_path.downcase]
159
+ mig_id ||= @migration_id_converter.attachment_path_id_lookup_lower[alt_rel_path.downcase]
160
+ end
161
+
162
+ # This md5 comparison is here to handle faulty cartridges with the migration_id equivalent of an empty string
163
+ mig_id && mig_id != "gd41d8cd98f00b204e9800998ecf8427e" && @migration_id_converter.lookup_attachment_by_migration_id(mig_id)
164
+ end
165
+
166
+ def resolve_relative_file_url(rel_path)
167
+ split = rel_path.split("?")
168
+ qs = split.pop if split.length > 1
169
+ path = split.join("?")
170
+
171
+ # since we can't be sure whether a ? is part of a filename or query string, try it both ways
172
+ new_url = resolve_relative_file_url_with_qs(path, qs)
173
+ new_url ||= resolve_relative_file_url_with_qs(rel_path, "") if qs.present?
174
+ new_url
175
+ end
176
+
177
+ def resolve_relative_file_url_with_qs(rel_path, qs)
178
+ new_url = nil
179
+ rel_path_parts = Pathname.new(rel_path).each_filename.to_a
180
+
181
+ # e.g. start with "a/b/c.txt" then try "b/c.txt" then try "c.txt"
182
+ while new_url.nil? && !rel_path_parts.empty?
183
+ sub_path = File.join(rel_path_parts)
184
+ if (file = find_file_in_context(sub_path))
185
+ new_url = "#{context_path}/files/#{file.id}"
186
+ # support other params in the query string, that were exported from the
187
+ # original path components and query string. see
188
+ # CCHelper::file_query_string
189
+ params = Rack::Utils.parse_nested_query(qs.presence || "")
190
+ qs = []
191
+ new_action = ""
192
+ params.each do |k, v|
193
+ case k
194
+ when /canvas_qs_(.*)/
195
+ qs << "#{Rack::Utils.escape($1)}=#{Rack::Utils.escape(v)}"
196
+ when /canvas_(.*)/
197
+ new_action += "/#{$1}"
198
+ end
199
+ end
200
+ new_url += new_action.presence || "/preview"
201
+ new_url += "?#{qs.join("&")}" if qs.present?
202
+ end
203
+ rel_path_parts.shift
204
+ end
205
+ new_url
206
+ end
207
+
208
+ def media_iframe_url(media_id, media_type = nil)
209
+ url = "/media_objects_iframe/#{media_id}"
210
+ url += "?type=#{media_type}" if media_type.present?
211
+ url
212
+ end
213
+
214
+ def media_attachment_iframe_url(file_id, media_type = nil)
215
+ url = "/media_attachments_iframe/#{file_id}"
216
+ url += "?type=#{media_type}" if media_type.present?
217
+ url
218
+ end
219
+
220
+ def resolve_media_comment_data(node, rel_path)
221
+ if (file = find_file_in_context(rel_path[/^[^?]+/])) # strip query string for this search
222
+ media_id = (file.media_object&.media_id || file.media_entry_id)
223
+ if media_id && media_id != "maybe"
224
+ if ["iframe", "source"].include?(node.name)
225
+ node["data-media-id"] = media_id
226
+ if node["data-is-media-attachment"]
227
+ node.delete("data-is-media-attachment")
228
+ return media_attachment_iframe_url(file.id, node["data-media-type"])
229
+ else
230
+ return media_iframe_url(media_id, node["data-media-type"])
231
+ end
232
+ else
233
+ node["id"] = "media_comment_#{media_id}"
234
+ return "/media_objects/#{media_id}"
235
+ end
236
+ end
237
+ end
238
+
239
+ if node["id"] && node["id"] =~ /\Amedia_comment_(.+)\z/
240
+ "/media_objects/#{$1}"
241
+ elsif node["data-media-id"].present?
242
+ media_iframe_url(node["data-media-id"], node["data-media-type"])
243
+ else
244
+ node.delete("class")
245
+ node.delete("id")
246
+ node.delete("style")
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end