studio-engine 0.67.0 → 0.67.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ffd4e2260ecf5e4ed81cbe17b45001ac14b3bcbf576b4fd79b506bfa2fff6dc
4
- data.tar.gz: d7f03282009eeb8f914637df864769fb5846808d4ccc270a1285c04564b18418
3
+ metadata.gz: 79895e24dd5ade937ec8c0b4afacd84605ad495530a4c37f94e053a2c1040170
4
+ data.tar.gz: 0f1716ef07d10fc5388ea15853767f6c7e8911e076e280a096a8424a494eba9c
5
5
  SHA512:
6
- metadata.gz: 1ab14b588d1d5382cc1738205472ba01b3f7aeb4442b026a506a824e777060eeae59ab7873d3176bfb6fd5f1ca95633a052e02b69ebe8f7715a6c9a8cdc1d821
7
- data.tar.gz: 9256f176a552cea09a38ec5e56b32749ce4e419f5231b90971e6c14f2099983e6b4be646cdac8cc5efdef2e6d476714875c00ff1beae980f66c31752446c7123
6
+ metadata.gz: 7c6e391302c996753b399019f2baeded267820a69d1f7451fe7ff68f82609fc625cc477cbdebb6127f74d0d5ed3a326ed4357d02c24b595e0fd1c213c3605895
7
+ data.tar.gz: 26bda400a871f3998a7baa64c72f43ed71a2d6d4ff177e02c4c6ae76d9bff65438e0bb33d48564802ea982bc2f628631906fc25e0a24167ec11d38796908310d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Fixed
8
+
9
+ - **Knowledge layer hardening** — the five findings from the 0.67.0 reviews:
10
+ upload keys carry a random suffix (same-named uploads in the same second no
11
+ longer overwrite the first object before the unique index can refuse);
12
+ `normalize_path` drops dot segments (`a/../b` → `a/b` — the S3 key mirrors
13
+ the path, and a `..` reads as traversal to every scanner); `access_for`
14
+ normalizes the queried agent the way writes normalize keys (a capitalized
15
+ `Studio.knowledge_agents` roster entry no longer reads `none`); the inbox
16
+ badge counts before the status filter (filtering to `filed` no longer zeroes
17
+ it); chip styles moved to one shared partial (`_chip_styles`); and reference
18
+ migrations are now written omakase-rubocop-compatible, with the convention
19
+ noted in the migration header (consumer copies are linted by the consumer's
20
+ rubocop — measured on the Industries adoption ship).
21
+
7
22
  ### Added
8
23
 
9
24
  - **Knowledge layer primitive** — `Studio::KnowledgeDoc` + `/admin/knowledge`:
@@ -25,11 +25,14 @@ module Studio
25
25
 
26
26
  scope = Studio::KnowledgeDoc.order(created_at: :desc)
27
27
  scope = scope.for_entity(@entity) if @entity
28
- scope = scope.where(status: @status) if @status
29
28
 
30
- @entities = Studio::KnowledgeDoc.distinct.pluck(:entity).sort
29
+ @entities = Studio::KnowledgeDoc.distinct.pluck(:entity).sort
30
+ # Counted BEFORE the status filter: the badge answers "what waits for
31
+ # triage in this entity", and filtering to status=filed must not zero it.
31
32
  @inbox_size = scope.inbox.count
32
33
 
34
+ scope = scope.where(status: @status) if @status
35
+
33
36
  if @view == "folders"
34
37
  @folders = scope.folders_under(@folder)
35
38
  @docs = scope.in_folder(@folder)
@@ -93,8 +93,13 @@ module Studio
93
93
  end
94
94
 
95
95
  # "/a//b/" -> "a/b". Nil-safe; the empty string is the root folder.
96
+ # Dot segments are dropped ("a/../b" -> "a/b"): harmless server-side, but
97
+ # the S3 key mirrors the path and a ".." reads as traversal to every
98
+ # scanner and human who ever greps the bucket.
96
99
  def normalize_path(value)
97
- value.to_s.strip.squeeze("/").delete_prefix("/").delete_suffix("/")
100
+ value.to_s.strip.squeeze("/").split("/")
101
+ .reject { |segment| segment.empty? || segment == "." || segment == ".." }
102
+ .join("/")
98
103
  end
99
104
 
100
105
  def default_title(file)
@@ -105,8 +110,11 @@ module Studio
105
110
 
106
111
  # --- access ---------------------------------------------------------------
107
112
 
113
+ # The queried agent is normalized the same way written keys are, so a
114
+ # capitalized Studio.knowledge_agents roster entry reads its real level
115
+ # instead of silently defaulting to "none".
108
116
  def access_for(agent)
109
- level = access.is_a?(Hash) ? access[agent.to_s] : nil
117
+ level = access.is_a?(Hash) ? access[agent.to_s.strip.downcase] : nil
110
118
  ACCESS_LEVELS.include?(level) ? level : "none"
111
119
  end
112
120
 
@@ -133,9 +141,13 @@ module Studio
133
141
  content_type = file.respond_to?(:content_type) ? file.content_type : nil
134
142
  body = file.respond_to?(:read) ? file.read : file.to_s
135
143
 
144
+ # The random suffix is load-bearing: the timestamp is second-granularity,
145
+ # so two same-named uploads in one second would otherwise write the same
146
+ # key — the second S3 PUT overwrites the first object BEFORE the unique
147
+ # index rejects the second row (found in PR #251 review, twice over).
136
148
  key = [
137
149
  "knowledge", entity, path.presence,
138
- "#{Time.current.strftime('%Y%m%d%H%M%S')}-#{self.class.sanitize_filename(filename)}"
150
+ "#{Time.current.strftime('%Y%m%d%H%M%S')}-#{SecureRandom.hex(4)}-#{self.class.sanitize_filename(filename)}"
139
151
  ].compact.join("/")
140
152
 
141
153
  Studio::S3.upload(key: key, body: body, content_type: content_type)
@@ -11,12 +11,8 @@
11
11
  Styling note: gem-specific looks live in the scoped <style> below because a
12
12
  host's Tailwind build may not scan this gem's views (same reasoning as the
13
13
  geo grid). Layout leans on utilities every host already ships. %>
14
+ <%= render "studio/knowledge_docs/chip_styles" %>
14
15
  <style>
15
- .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
16
- font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
17
- .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
18
- .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
19
- .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
20
16
  .knowledge-status { font-size: .7rem; padding: 0 .45rem; border-radius: .25rem; border: 1px solid rgb(148 163 184 / .5); }
21
17
  .knowledge-status-inbox { border-color: rgb(245 158 11 / .6); color: rgb(180 83 9); }
22
18
  .knowledge-status-superseded { text-decoration: line-through; opacity: .6; }
@@ -0,0 +1,10 @@
1
+ <%# The access-chip look, shared by the browser and the show page — one
2
+ definition so the two can never drift. Scoped <style> rather than utility
3
+ classes because a host's Tailwind build may not scan this gem's views. %>
4
+ <style>
5
+ .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
6
+ font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
7
+ .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
8
+ .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
9
+ .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
10
+ </style>
@@ -1,12 +1,6 @@
1
1
  <%# /admin/knowledge/:id — one document: metadata, access, download, and the
2
2
  triage form that files it out of the inbox. %>
3
- <style>
4
- .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
5
- font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
6
- .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
7
- .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
8
- .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
9
- </style>
3
+ <%= render "studio/knowledge_docs/chip_styles" %>
10
4
 
11
5
  <div class="mb-4 text-sm">
12
6
  <%= link_to "← Knowledge", admin_knowledge_path(folder: @doc.path.presence) %>
@@ -29,7 +23,7 @@
29
23
  <div><dt class="opacity-60">Document date</dt><dd><%= @doc.document_date&.iso8601 || "—" %></dd></div>
30
24
  <div><dt class="opacity-60">Uploaded</dt><dd><%= @doc.created_at&.to_date&.iso8601 %> by <%= @doc.uploaded_by.presence || "—" %></dd></div>
31
25
  <div><dt class="opacity-60">Source</dt><dd><%= @doc.source_note.presence || "—" %></dd></div>
32
- <div><dt class="opacity-60">File</dt><dd><%= @doc.file? ? "#{@doc.mime_type.presence || 'file'} · #{number_to_human_size(@doc.byte_size) if @doc.byte_size}" : "metadata only" %></dd></div>
26
+ <div><dt class="opacity-60">File</dt><dd><%= @doc.file? ? [ @doc.mime_type.presence || "file", (number_to_human_size(@doc.byte_size) if @doc.byte_size) ].compact.join(" · ") : "metadata only" %></dd></div>
33
27
  <div class="md:col-span-2"><dt class="opacity-60">Access</dt><dd><%= render "studio/knowledge_docs/access_chips", doc: @doc %></dd></div>
34
28
  </dl>
35
29
 
@@ -76,9 +76,35 @@
76
76
  # Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
77
77
  # Solana Wallet also needs the web3 capability, so it defaults OFF on an app
78
78
  # (like McRitchie Studio) that ships web3 off.
79
+ #
80
+ # web3_gem is the THIRD term, and it answers a question neither of the other
81
+ # two asks. auth_methods declares which CREDENTIALS an app accepts; features
82
+ # gates PRODUCT SURFACES — lib/studio.rb spells that split out where it
83
+ # deliberately does NOT gate the /auth/solana routes on :web3. Both are
84
+ # POLICY. Whether the wallet picker can RENDER AT ALL is template resolution,
85
+ # and the Solana button does nothing except swap to that picker. A trigger
86
+ # must carry at least the gate its target's REGISTRATION carries (see
87
+ # web3_gem above, which wraps the wallet-connect registration) or it opens an
88
+ # empty panel — the same failure the three specimen cards had, through a
89
+ # different door.
79
90
  ml_default = Studio.auth_method?(:magic_link)
80
91
  g_default = Studio.auth_method?(:google)
81
- w_default = Studio.auth_method?(:wallet) && web3_on
92
+ w_default = Studio.auth_method?(:wallet) && web3_on && web3_gem
93
+
94
+ # The Sign in card's toggles. Solana Wallet is DROPPED where the gem is
95
+ # absent, because that checkbox is the door this page actually opens:
96
+ # methodOn('wallet') reads props.methods.wallet — the toggle's own boolean —
97
+ # BEFORE it ever falls back to the defaults above, so a ticked box overrides
98
+ # w_default outright. Leaving it offered would keep the empty panel one click
99
+ # away in EVERY base-app configuration, including the stock auth_methods
100
+ # default that declares no :wallet at all. A control that cannot do its one
101
+ # job is worse than an absent one; the section prose says why it is gone.
102
+ auth_toggles = [
103
+ { model: "opts.magicLink", label: "Magic Link" },
104
+ { model: "opts.google", label: "Google" }
105
+ ]
106
+ auth_toggles << { model: "opts.wallet", label: "Solana Wallet" } if web3_gem
107
+ auth_toggles << { model: "opts.terms", label: "Terms" }
82
108
 
83
109
  # Active-card glow-match expression builder: yields an Alpine boolean that is
84
110
  # true when $store.dsModals.current() is THIS specimen's modal. It discriminates
@@ -365,8 +391,12 @@
365
391
  :class="$store.dsModals.cardClasses()">
366
392
 
367
393
  <%# --- Auth suite --- %>
394
+ <%# web3_gem is passed, not recomputed: this file owns the lookup (see
395
+ above) and the auth modal's Solana button needs the SAME answer,
396
+ because that button's only job is to swap to the wallet-connect
397
+ registration gated on it a few lines down. One lookup, two readers. %>
368
398
  <template x-if="$store.dsModals.current().id === 'auth'">
369
- <div><%= render "style/modals/auth" %></div>
399
+ <div><%= render "style/modals/auth", web3_gem: web3_gem %></div>
370
400
  </template>
371
401
 
372
402
  <%# --- Onboarding step (its card now lives in Profile) --- %>
@@ -629,6 +659,11 @@
629
659
  magic-link-sent &rarr; magic-link-resent</code>. Toggle the methods below a
630
660
  card, then open it to watch them gate the modal. The live card
631
661
  <strong>glows</strong>, and the glow follows the step machine as you advance.
662
+ <% unless web3_gem %>
663
+ <strong>Solana sign-in needs solana-studio, which this app does not
664
+ bundle</strong>, so that button and its toggle are absent here &mdash;
665
+ nothing registers the wallet picker the button opens.
666
+ <% end %>
632
667
  </p>
633
668
  </div>
634
669
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -639,12 +674,7 @@
639
674
  card_data: "opts: { magicLink: #{ml_default}, google: #{g_default}, wallet: #{w_default}, terms: true }",
640
675
  open_expr: "$store.dsModals.open('auth', { step: 'credentials', picksRequired: 6, methods: { magicLink: opts.magicLink, google: opts.google, wallet: opts.wallet }, terms: opts.terms })",
641
676
  glow_when: ds_glow.call("auth", step: "credentials"),
642
- toggles: [
643
- { model: "opts.magicLink", label: "Magic Link" },
644
- { model: "opts.google", label: "Google" },
645
- { model: "opts.wallet", label: "Solana Wallet" },
646
- { model: "opts.terms", label: "Terms" }
647
- ] } do %>
677
+ toggles: auth_toggles } do %>
648
678
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
649
679
  <span class="block h-2 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
650
680
  <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
@@ -17,10 +17,27 @@
17
17
  resets; Solana swaps to the wallet-connect picker (a real ported specimen).
18
18
  No real auth happens — the demo shows the real UI.
19
19
 
20
+ LOCALS:
21
+ web3_gem (Boolean, required) — does solana-studio resolve. style/_modals
22
+ owns the lookup and registers wallet-connect behind the same flag; the
23
+ Solana button below is rendered only when it is true, because a button that
24
+ swaps to an unregistered id opens an empty panel. Fetched without a default
25
+ on purpose: a caller that forgets it should fail loudly here rather than
26
+ silently drop Solana sign-in from an app that has the gem.
27
+
28
+ NOT gated on web3_gem, deliberately: _methodDefaults.wallet below. It is the
29
+ fallback methodOn uses when an opener passes no methods hash, and on a base
30
+ app nothing reaches it — the Sign in card always passes explicit booleans, and
31
+ the only opener that omits them is the wallet picker's back button, which
32
+ needs the gem to exist at all. Adding the term there would read like a third
33
+ gate while changing no rendered outcome and no behaviour, and a gate that
34
+ cannot be shown to bite is decoration. The trigger below is the real gate.
35
+
20
36
  CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
21
37
  SINGLE root, so everything lives inside the outer <div>. The x-data is a
22
38
  double-quoted attribute: keep it free of double-quotes and backticks.
23
39
  %>
40
+ <% web3_gem = local_assigns.fetch(:web3_gem) %>
24
41
  <div x-data="{
25
42
  email: '',
26
43
  ageAttested: false,
@@ -167,21 +184,36 @@
167
184
  <p role="alert" class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
168
185
  </template>
169
186
 
170
- <%# 2. Solana — progresses to the Connect Wallet picker modal. Gated on
171
- methodOn('wallet') (default OFF where web3 is off, e.g. McRitchie Studio). %>
172
- <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
173
- class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
174
- <svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
175
- <defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
176
- <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
177
- </linearGradient></defs>
178
- <path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#auth-solana-grad)"/>
179
- <path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#auth-solana-grad)"/>
180
- <path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#auth-solana-grad)"/>
181
- </svg>
182
- <span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
183
- <span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
184
- </button>
187
+ <%# 2. Solana — progresses to the Connect Wallet picker modal. TWO gates,
188
+ asking two different questions, and only one of them is Alpine's:
189
+
190
+ x-show methodOn('wallet') POLICY does this app offer wallet as a
191
+ credential, and has the specimen card's toggle turned it on. Reads
192
+ props.methods.wallet first, so the card's checkbox overrides the
193
+ server default outright.
194
+ Ruby web3_gem CAPABILITY — does solana-studio resolve, so
195
+ that something actually REGISTERED the wallet-connect id this button
196
+ swaps to. style/_modals wraps that registration in the same gate.
197
+
198
+ The policy gate cannot stand in for the capability one: a base app can
199
+ answer yes to the first and no to the second, and then the button opens
200
+ an empty panel. It has to be the RUBY gate because the toggle can flip
201
+ every Alpine term, and a control the operator can tick is not a gate. %>
202
+ <% if web3_gem %>
203
+ <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
204
+ class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
205
+ <svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
206
+ <defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
207
+ <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
208
+ </linearGradient></defs>
209
+ <path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#auth-solana-grad)"/>
210
+ <path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#auth-solana-grad)"/>
211
+ <path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#auth-solana-grad)"/>
212
+ </svg>
213
+ <span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
214
+ <span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
215
+ </button>
216
+ <% end %>
185
217
 
186
218
  <%# "or" divider — only when magic-link AND a social method are both on. %>
187
219
  <div class="relative my-4" x-show="methodOn('magicLink') && (methodOn('google') || methodOn('wallet'))">
@@ -3,6 +3,11 @@
3
3
  # (`bin/rails studio_engine:install:migrations && bin/rails db:migrate`) so the
4
4
  # table is created in the app's database. Do not hand-copy — a hand copy
5
5
  # collides with the installed copy on `class CreateStudioKnowledgeDocs`.
6
+ #
7
+ # STYLE NOTE: reference migrations are written omakase-rubocop-compatible
8
+ # (spaces inside array brackets) because the installed copy is linted by the
9
+ # CONSUMER's rubocop, not this gem's — an engine-styled copy fails the
10
+ # consumer's cert untouched (measured on the Industries adoption ship).
6
11
  class CreateStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
7
12
  def change
8
13
  create_table :studio_knowledge_docs do |t|
@@ -41,8 +46,8 @@ class CreateStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
41
46
  t.timestamps
42
47
  end
43
48
 
44
- add_index :studio_knowledge_docs, [:entity, :status]
45
- add_index :studio_knowledge_docs, [:entity, :path]
49
+ add_index :studio_knowledge_docs, [ :entity, :status ]
50
+ add_index :studio_knowledge_docs, [ :entity, :path ]
46
51
  add_index :studio_knowledge_docs, :s3_key, unique: true
47
52
  add_index :studio_knowledge_docs, :superseded_by_id
48
53
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.67.0"
2
+ VERSION = "0.67.2"
3
3
  end
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.67.0
4
+ version: 0.67.2
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-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -440,6 +440,7 @@ files:
440
440
  - app/views/studio/geo_settings/edit.html.erb
441
441
  - app/views/studio/knowledge_docs/_access_chips.html.erb
442
442
  - app/views/studio/knowledge_docs/_browser.html.erb
443
+ - app/views/studio/knowledge_docs/_chip_styles.html.erb
443
444
  - app/views/studio/knowledge_docs/_doc_row.html.erb
444
445
  - app/views/studio/knowledge_docs/_upload_form.html.erb
445
446
  - app/views/studio/knowledge_docs/index.html.erb