studio-engine 0.98.0 → 0.99.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,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # What /admin/knowledge/:id shows of a document without a download: a
5
+ # workbook or CSV as tables, a PDF or image inline, text as text.
6
+ #
7
+ # NOT required by lib/studio.rb. Studio::KnowledgeDoc requires it on first
8
+ # use, and the spreadsheet reader (nokogiri, zlib) and `csv` load later
9
+ # still, on the first file of that kind, so an app that never draws the
10
+ # knowledge routes pays nothing for it.
11
+ #
12
+ # Everything here is read-only and answers with a Result, never an
13
+ # exception: a file this cannot show, for any reason, is a Result of kind
14
+ # :fallback carrying one plain sentence, and the page offers the download
15
+ # link beside it.
16
+ #
17
+ # THE CAPS, all in one place:
18
+ #
19
+ # SPREADSHEET_MAX_BYTES an .xlsx larger than this is not opened at all.
20
+ # INFLATE_BUDGET what a whole workbook may inflate to, every part
21
+ # read counted, a part read twice counted twice.
22
+ # SPREADSHEET_DEADLINE_SECONDS
23
+ # wall-clock time one workbook read may take.
24
+ # MAX_PARSED_TEXT_BYTES text the XML parser may hand the reader across a
25
+ # workbook, kept or not.
26
+ # XmlGuard::MAX_TOKEN_BYTES, MAX_OPEN_TAG_BYTES, MAX_ASIDE_BYTES,
27
+ # MAX_NAMESPACE_DECLARATIONS, MAX_DEPTH
28
+ # what the XML parser is ever GIVEN, enforced on the
29
+ # byte stream below it: no DTD, UTF-8 only, and no
30
+ # text node, set of open start tags, or pile of
31
+ # comments large enough for the parser to spend a
32
+ # gigabyte on before a single node comes out.
33
+ # MAX_ROWS, MAX_COLUMNS what one sheet (or CSV) shows; the rest is named
34
+ # in a notice, never silently dropped.
35
+ # MAX_SHEETS tabs rendered; further sheets are counted.
36
+ # MAX_CELLS filled cells across one workbook.
37
+ # MAX_CELL_CHARS text kept for one cell (Excel's own limit); a
38
+ # longer value is cut and marked.
39
+ # MAX_TEXT_BYTES cell text across one workbook, counted once per
40
+ # cell that shows it.
41
+ # MAX_GRID_CELLS cells rendered across one workbook, empty ones
42
+ # included.
43
+ # The sheet that spends the last of any of these
44
+ # three is cut there and says so; the sheets after
45
+ # it are counted, not rendered.
46
+ # NumberFormat::MAX_CODE_LENGTH, MAX_DECIMALS
47
+ # a number format longer than Excel allows is
48
+ # ignored, and no number renders past thirty
49
+ # decimals.
50
+ # DELIMITED_HEAD_BYTES a CSV is read only this far, so one of any size
51
+ # previews its first rows.
52
+ # TEXT_HEAD_BYTES likewise for text.
53
+ # INLINE_MAX_BYTES a PDF or image past this is not embedded.
54
+ module KnowledgePreview
55
+ class Error < StandardError; end
56
+ # The file is not what its name says, or is damaged. The message completes
57
+ # "This file could not be previewed: ...".
58
+ class Unreadable < Error; end
59
+ class TooLarge < Error; end
60
+
61
+ MEGABYTE = 1_048_576
62
+ SPREADSHEET_MAX_BYTES = 20 * MEGABYTE
63
+ INFLATE_BUDGET = 64 * MEGABYTE
64
+ SPREADSHEET_DEADLINE_SECONDS = 10
65
+ MAX_PARSED_TEXT_BYTES = 16 * MEGABYTE
66
+ MAX_ROWS = 500
67
+ MAX_COLUMNS = 50
68
+ MAX_SHEETS = 20
69
+ MAX_CELLS = 50_000
70
+ MAX_CELL_CHARS = 32_767
71
+ MAX_TEXT_BYTES = 2 * MEGABYTE
72
+ MAX_GRID_CELLS = 100_000
73
+ DELIMITED_HEAD_BYTES = 1 * MEGABYTE
74
+ TEXT_HEAD_BYTES = 256 * 1024
75
+ INLINE_MAX_BYTES = 100 * MEGABYTE
76
+
77
+ XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
78
+
79
+ KIND_BY_EXTENSION = {
80
+ ".xlsx" => :spreadsheet, ".xlsm" => :spreadsheet,
81
+ ".csv" => :delimited, ".tsv" => :delimited,
82
+ ".pdf" => :pdf,
83
+ ".png" => :image, ".jpg" => :image, ".jpeg" => :image, ".gif" => :image, ".webp" => :image,
84
+ ".txt" => :text, ".md" => :text, ".markdown" => :text, ".log" => :text,
85
+ ".json" => :text, ".vtt" => :text, ".srt" => :text
86
+ }.freeze
87
+
88
+ KIND_BY_MIME = {
89
+ XLSX_MIME => :spreadsheet,
90
+ "text/csv" => :delimited, "text/tab-separated-values" => :delimited,
91
+ "application/pdf" => :pdf,
92
+ "image/png" => :image, "image/jpeg" => :image, "image/gif" => :image, "image/webp" => :image,
93
+ "application/json" => :text
94
+ }.freeze
95
+
96
+ # The content type an inline object is SERVED as. Chosen here, from the
97
+ # kind, and never copied from the stored mime type: a file uploaded as
98
+ # text/html under a .pdf name must reach the browser as a (broken) PDF,
99
+ # not as a page. SVG is absent on purpose; it is a document that can
100
+ # carry script, and it takes the download link.
101
+ INLINE_TYPE_BY_EXTENSION = {
102
+ ".pdf" => "application/pdf",
103
+ ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
104
+ ".gif" => "image/gif", ".webp" => "image/webp"
105
+ }.freeze
106
+
107
+ Cell = Struct.new(:text, :numeric)
108
+ # rows is a rectangle of Cell-or-nil. row_limit is the row count the
109
+ # truncation notice names.
110
+ Sheet = Struct.new(:name, :rows, :truncated_rows, :truncated_columns, :hidden, :row_limit,
111
+ keyword_init: true)
112
+
113
+ # kind: :table, :pdf, :image, :text or :fallback.
114
+ # error: the unexpected exception behind a fallback, for the caller to
115
+ # log; nil when the fallback is an ordinary refusal.
116
+ Result = Struct.new(:kind, :sheets, :omitted_sheets, :text, :truncated, :url, :reason, :error,
117
+ keyword_init: true) do
118
+ def fallback? = kind == :fallback
119
+ end
120
+
121
+ NUMERIC_TEXT = /\A\(?[-+$€£]?\s?\d[\d,]*(\.\d+)?%?\)?\z/
122
+
123
+ class << self
124
+ # :spreadsheet, :delimited, :pdf, :image, :text, or nil for a file with
125
+ # no inline preview. The extension decides when it is one this knows;
126
+ # the stored mime type is the browser's guess at upload and is consulted
127
+ # only for a name that says nothing.
128
+ def kind_for(filename:, mime_type: nil)
129
+ extension = File.extname(filename.to_s).downcase
130
+ return KIND_BY_EXTENSION[extension] if KIND_BY_EXTENSION.key?(extension)
131
+
132
+ mime = mime_type.to_s.split(";").first.to_s.strip.downcase
133
+ KIND_BY_MIME[mime] || (mime.start_with?("text/") ? :text : nil)
134
+ end
135
+
136
+ def inline_content_type(filename:, mime_type: nil)
137
+ by_name = INLINE_TYPE_BY_EXTENSION[File.extname(filename.to_s).downcase]
138
+ return by_name if by_name
139
+
140
+ mime = mime_type.to_s.split(";").first.to_s.strip.downcase
141
+ INLINE_TYPE_BY_EXTENSION.value?(mime) ? mime : nil
142
+ end
143
+
144
+ # 0 -> "A", 25 -> "Z", 26 -> "AA": the heading a spreadsheet gives a column.
145
+ def column_letter(index)
146
+ letters = +""
147
+ number = index + 1
148
+ while number.positive?
149
+ number, remainder = (number - 1).divmod(26)
150
+ letters.prepend((65 + remainder).chr)
151
+ end
152
+ letters
153
+ end
154
+
155
+ # The preview for a Studio::KnowledgeDoc. Reads the bucket (or, for a
156
+ # PDF or image, only signs a URL), so call it off the page's own render
157
+ # path. `storage` answers download(key:, max_bytes:) and defaults to
158
+ # Studio::S3, named only when a read is about to happen.
159
+ def for(doc, storage: nil)
160
+ return fallback("No file is attached.") unless doc.file?
161
+
162
+ kind = kind_for(filename: doc.filename, mime_type: doc.mime_type)
163
+ case kind
164
+ when :spreadsheet then spreadsheet(doc, storage)
165
+ when :delimited then delimited(doc, storage)
166
+ when :text then text(doc, storage)
167
+ when :pdf, :image then inline(doc, kind)
168
+ else fallback(no_preview_reason(doc))
169
+ end
170
+ rescue TooLarge => e
171
+ fallback("This file is too large to preview: #{e.message}.")
172
+ rescue Unreadable => e
173
+ fallback("This file could not be previewed: #{e.message}.")
174
+ rescue StandardError, LoadError => e
175
+ # Storage down, an object missing, a defect in a reader, or a library
176
+ # the host's bundle lacks. The page must still render; the caller logs
177
+ # what happened.
178
+ fallback("The preview could not be built.", error: e)
179
+ end
180
+
181
+ # --- readers, usable without a document ---------------------------------
182
+
183
+ def read_spreadsheet(bytes, **overrides)
184
+ require_relative "knowledge_preview/xlsx"
185
+ Xlsx.read(bytes, max_rows: MAX_ROWS, max_columns: MAX_COLUMNS, max_sheets: MAX_SHEETS,
186
+ max_cells: MAX_CELLS, max_cell_chars: MAX_CELL_CHARS, max_text_bytes: MAX_TEXT_BYTES,
187
+ max_grid_cells: MAX_GRID_CELLS, max_parsed_text_bytes: MAX_PARSED_TEXT_BYTES,
188
+ inflate_budget: INFLATE_BUDGET, deadline_seconds: SPREADSHEET_DEADLINE_SECONDS,
189
+ **overrides)
190
+ rescue Error
191
+ raise
192
+ rescue StandardError => e
193
+ # A hostile or half-written workbook can trip the reader anywhere;
194
+ # every such trip is the file's fault until shown otherwise.
195
+ raise Unreadable, "its contents are damaged (#{e.class})"
196
+ end
197
+
198
+ # `partial` says the bytes are only the head of a longer file, so the
199
+ # last line may be cut and is dropped.
200
+ def read_delimited(bytes, separator: ",", partial: false)
201
+ require "csv"
202
+ text = decode(bytes, partial: partial)
203
+ raise Unreadable, "it is empty" if text.strip.empty?
204
+
205
+ text = text[0..text.rindex("\n")] if partial && text.include?("\n")
206
+ rows = []
207
+ truncated_rows = partial
208
+ truncated_columns = false
209
+ begin
210
+ CSV.new(text, col_sep: separator, liberal_parsing: true).each do |row|
211
+ if rows.size >= MAX_ROWS
212
+ truncated_rows = true
213
+ break
214
+ end
215
+ truncated_columns = true if row.size > MAX_COLUMNS
216
+ rows << row.first(MAX_COLUMNS)
217
+ end
218
+ rescue CSV::MalformedCSVError
219
+ # Rows read before the bad line still show; with none, it is not CSV.
220
+ raise Unreadable, "it is not valid CSV" if rows.empty?
221
+
222
+ truncated_rows = true
223
+ end
224
+
225
+ width = rows.map(&:size).max.to_i
226
+ cells = rows.map do |row|
227
+ Array.new(width) do |index|
228
+ value = row[index].to_s
229
+ value.empty? ? nil : Cell.new(value, value.match?(NUMERIC_TEXT))
230
+ end
231
+ end
232
+ Sheet.new(name: nil, rows: cells, truncated_rows: truncated_rows,
233
+ truncated_columns: truncated_columns, hidden: false, row_limit: cells.size)
234
+ end
235
+
236
+ # Bytes to UTF-8 text. UTF-8 first; a UTF-16 byte-order mark is honoured;
237
+ # anything else is read as Windows-1252, which is what a CSV exported by
238
+ # desktop accounting software usually is. NUL bytes mean it is not text.
239
+ def decode(bytes, partial: false)
240
+ raw = bytes.to_s.b
241
+ if raw.start_with?("\xFF\xFE".b, "\xFE\xFF".b)
242
+ encoding = raw.start_with?("\xFF\xFE".b) ? Encoding::UTF_16LE : Encoding::UTF_16BE
243
+ body = raw.byteslice(2..)
244
+ body = body.byteslice(0, body.bytesize - 1) if body.bytesize.odd?
245
+ return body.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
246
+ end
247
+ raise Unreadable, "it does not look like text" if raw.include?("\x00".b)
248
+
249
+ raw = raw.byteslice(3..) if raw.start_with?("\xEF\xBB\xBF".b)
250
+ utf8 = raw.dup.force_encoding(Encoding::UTF_8)
251
+ return utf8 if utf8.valid_encoding?
252
+
253
+ if partial
254
+ # A head read can end inside a multi-byte character.
255
+ 1.upto(3) do |trim|
256
+ shorter = raw.byteslice(0, [raw.bytesize - trim, 0].max).force_encoding(Encoding::UTF_8)
257
+ return shorter if shorter.valid_encoding?
258
+ end
259
+ end
260
+ raw.force_encoding(Encoding::Windows_1252).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
261
+ end
262
+
263
+ private
264
+
265
+ def fallback(reason, error: nil)
266
+ Result.new(kind: :fallback, reason: reason, error: error)
267
+ end
268
+
269
+ def no_preview_reason(doc)
270
+ extension = File.extname(doc.filename.to_s).downcase
271
+ extension.empty? ? "There is no inline preview for this kind of file." : "There is no inline preview for #{extension} files."
272
+ end
273
+
274
+ def megabytes(bytes)
275
+ "#{(bytes.to_f / MEGABYTE).round(1).to_s.delete_suffix('.0')} MB"
276
+ end
277
+
278
+ def spreadsheet(doc, storage)
279
+ refuse_over!(doc, SPREADSHEET_MAX_BYTES)
280
+ # One byte past the cap: the recorded byte_size can be absent or
281
+ # stale, and this is the read that cannot be.
282
+ bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: SPREADSHEET_MAX_BYTES + 1)
283
+ raise TooLarge, "spreadsheet previews stop at #{megabytes(SPREADSHEET_MAX_BYTES)}" if bytes.bytesize > SPREADSHEET_MAX_BYTES
284
+
285
+ workbook = read_spreadsheet(bytes)
286
+ Result.new(kind: :table, sheets: workbook.sheets, omitted_sheets: workbook.omitted_sheets)
287
+ end
288
+
289
+ def delimited(doc, storage)
290
+ bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: DELIMITED_HEAD_BYTES + 1)
291
+ partial = bytes.bytesize > DELIMITED_HEAD_BYTES
292
+ tabbed = File.extname(doc.filename.to_s).casecmp?(".tsv") ||
293
+ doc.mime_type.to_s.start_with?("text/tab-separated-values")
294
+ sheet = read_delimited(bytes.byteslice(0, DELIMITED_HEAD_BYTES), separator: tabbed ? "\t" : ",", partial: partial)
295
+ Result.new(kind: :table, sheets: [sheet], omitted_sheets: 0)
296
+ end
297
+
298
+ def text(doc, storage)
299
+ bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: TEXT_HEAD_BYTES + 1)
300
+ partial = bytes.bytesize > TEXT_HEAD_BYTES
301
+ Result.new(kind: :text, text: decode(bytes.byteslice(0, TEXT_HEAD_BYTES), partial: partial), truncated: partial)
302
+ end
303
+
304
+ def inline(doc, kind)
305
+ refuse_over!(doc, INLINE_MAX_BYTES)
306
+ content_type = inline_content_type(filename: doc.filename, mime_type: doc.mime_type)
307
+ # Without a type of our choosing the URL would be signed to serve the
308
+ # STORED type, which is the one thing this path exists to prevent.
309
+ raise Unreadable, "its type cannot be shown inline" if content_type.nil?
310
+
311
+ Result.new(kind: kind, url: doc.signed_url(inline_as: content_type))
312
+ end
313
+
314
+ def refuse_over!(doc, cap)
315
+ size = doc.byte_size.to_i
316
+ return unless size > cap
317
+
318
+ raise TooLarge, "it is #{megabytes(size)} and previews of this kind stop at #{megabytes(cap)}"
319
+ end
320
+ end
321
+ end
322
+ end
data/lib/studio/s3.rb CHANGED
@@ -18,8 +18,32 @@ module Studio
18
18
  public_url? ? url(key: key) : nil
19
19
  end
20
20
 
21
- def download(key:)
22
- client.get_object(bucket: bucket, key: full_key(key)).body.read
21
+ # max_bytes reads only the head of the object (a ranged GET), which is
22
+ # how a preview looks at the first megabyte of a file of any size and
23
+ # how a caller enforces a size cap without trusting a recorded length.
24
+ # A ranged read of an EMPTY object is a 416 (InvalidRange) on S3; that
25
+ # is answered as the empty string it means. Tested against a stubbed
26
+ # client only: no live store, R2 included, has been asked.
27
+ def download(key:, max_bytes: nil)
28
+ # 0 or a negative would build "bytes=0--1", which a store may read as
29
+ # no range at all and answer with the whole object.
30
+ unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes.positive?)
31
+ raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
32
+ end
33
+
34
+ opts = { bucket: bucket, key: full_key(key) }
35
+ opts[:range] = "bytes=0-#{max_bytes - 1}" if max_bytes
36
+ s3 = client
37
+ # The rescue names an SDK constant, so it wraps only what runs after
38
+ # the client (which loads the SDK) exists: an unconfigured app must
39
+ # raise NotConfigured from `bucket` above, not a NameError from here.
40
+ begin
41
+ s3.get_object(**opts).body.read
42
+ rescue Aws::S3::Errors::InvalidRange
43
+ raise unless max_bytes
44
+
45
+ "".b
46
+ end
23
47
  end
24
48
 
25
49
  # The PUBLIC URL of an object. On AWS (no endpoint) it is the bucket's
@@ -43,9 +67,20 @@ module Studio
43
67
  endpoint.nil? || !Studio.s3_public_url.to_s.empty?
44
68
  end
45
69
 
46
- def signed_url(key:, expires_in: 3600)
70
+ # response_content_disposition and response_content_type are SIGNED into
71
+ # the URL and override the headers the object is served with. That is
72
+ # S3's documented behaviour and part of the S3 compatibility Cloudflare
73
+ # documents for R2. What the tests here prove is the SIGNING, against an
74
+ # R2-style endpoint; that R2 serves the overridden headers has not been
75
+ # verified against a live bucket. "inline" plus a content type the
76
+ # caller chose is how a private PDF or image is shown in the page
77
+ # without the stored content type deciding how the browser treats it.
78
+ def signed_url(key:, expires_in: 3600, response_content_disposition: nil, response_content_type: nil)
47
79
  require "aws-sdk-s3"
48
- Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: full_key(key), expires_in: expires_in)
80
+ params = { bucket: bucket, key: full_key(key), expires_in: expires_in }
81
+ params[:response_content_disposition] = response_content_disposition if response_content_disposition
82
+ params[:response_content_type] = response_content_type if response_content_type
83
+ Aws::S3::Presigner.new(client: client).presigned_url(:get_object, **params)
49
84
  end
50
85
 
51
86
  def exists?(key:)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.98.0"
2
+ VERSION = "0.99.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1453,8 +1453,8 @@ module Studio
1453
1453
  end
1454
1454
 
1455
1455
  # Knowledge layer — /admin/knowledge (Studio::KnowledgeDoc): folder/flat
1456
- # document browser, upload-to-inbox intake, per-agent access map, and
1457
- # 15-minute presigned downloads. Opt-in (default off) like every route
1456
+ # document browser, upload-to-inbox intake, per-agent access map,
1457
+ # 15-minute presigned downloads, and the inline document preview. Opt-in (default off) like every route
1458
1458
  # surface, so no app grows an admin page it never asked for.
1459
1459
  if Studio.draw_knowledge_routes
1460
1460
  get "admin/knowledge", to: "studio/knowledge_docs#index", as: :admin_knowledge
@@ -1471,6 +1471,9 @@ module Studio
1471
1471
  patch "admin/knowledge/:id", to: "studio/knowledge_docs#update"
1472
1472
  get "admin/knowledge/:id/download", to: "studio/knowledge_docs#download",
1473
1473
  as: :admin_knowledge_doc_download
1474
+ # The inline preview the show page lazy-loads (Studio::KnowledgePreview).
1475
+ get "admin/knowledge/:id/preview", to: "studio/knowledge_docs#preview",
1476
+ as: :admin_knowledge_doc_preview
1474
1477
  end
1475
1478
 
1476
1479
  # Solana / Phantom wallet sign-in (nonce challenge + signature verify),
@@ -40,6 +40,10 @@ Gem::Specification.new do |spec|
40
40
  spec.add_dependency "solid_queue", ">= 1.0", "< 2.0"
41
41
  spec.add_dependency "aws-sdk-s3", "~> 1.218"
42
42
  spec.add_dependency "mini_magick", "~> 5.0"
43
+ # The knowledge layer's CSV preview (Studio::KnowledgePreview) requires it,
44
+ # lazily. csv left Ruby's default gems in 3.4, and a bundled app can then
45
+ # load it only if its lockfile names it; not every consumer's does.
46
+ spec.add_dependency "csv", ">= 3.0"
43
47
  spec.add_dependency "resend", "~> 1.1"
44
48
  # Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
45
49
  # broadcasting (Studio::Cable). `redis` is the dependency whose ABSENCE
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.98.0
4
+ version: 0.99.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-09 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -112,6 +112,20 @@ dependencies:
112
112
  - - "~>"
113
113
  - !ruby/object:Gem::Version
114
114
  version: '5.0'
115
+ - !ruby/object:Gem::Dependency
116
+ name: csv
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '3.0'
122
+ type: :runtime
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '3.0'
115
129
  - !ruby/object:Gem::Dependency
116
130
  name: resend
117
131
  requirement: !ruby/object:Gem::Requirement
@@ -564,9 +578,14 @@ files:
564
578
  - app/views/studio/knowledge_docs/_chip_styles.html.erb
565
579
  - app/views/studio/knowledge_docs/_coverage_table.html.erb
566
580
  - app/views/studio/knowledge_docs/_doc_row.html.erb
581
+ - app/views/studio/knowledge_docs/_preview.html.erb
582
+ - app/views/studio/knowledge_docs/_preview_fallback.html.erb
583
+ - app/views/studio/knowledge_docs/_preview_sheet.html.erb
584
+ - app/views/studio/knowledge_docs/_preview_styles.html.erb
567
585
  - app/views/studio/knowledge_docs/_upload_form.html.erb
568
586
  - app/views/studio/knowledge_docs/coverage.html.erb
569
587
  - app/views/studio/knowledge_docs/index.html.erb
588
+ - app/views/studio/knowledge_docs/preview.html.erb
570
589
  - app/views/studio/knowledge_docs/show.html.erb
571
590
  - app/views/studio/links/confirm.html.erb
572
591
  - app/views/studio/local_emails/index.html.erb
@@ -715,6 +734,11 @@ files:
715
734
  - lib/studio/ip_locations.rb
716
735
  - lib/studio/js_identifier.rb
717
736
  - lib/studio/js_literal.rb
737
+ - lib/studio/knowledge_preview.rb
738
+ - lib/studio/knowledge_preview/number_format.rb
739
+ - lib/studio/knowledge_preview/xlsx.rb
740
+ - lib/studio/knowledge_preview/xml_guard.rb
741
+ - lib/studio/knowledge_preview/zip.rb
718
742
  - lib/studio/knowledge_recording.rb
719
743
  - lib/studio/knowledge_transcript.rb
720
744
  - lib/studio/link_preview.rb