collavre 0.7.2 β†’ 0.8.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +36 -0
  3. data/app/assets/stylesheets/collavre/creatives.css +27 -1
  4. data/app/controllers/collavre/comments_controller.rb +11 -1
  5. data/app/controllers/collavre/creatives_controller.rb +21 -2
  6. data/app/controllers/collavre/topics_controller.rb +36 -1
  7. data/app/helpers/collavre/application_helper.rb +37 -0
  8. data/app/helpers/collavre/creatives_helper.rb +3 -1
  9. data/app/javascript/components/creative_tree_row.js +1 -0
  10. data/app/javascript/controllers/comments/list_controller.js +13 -2
  11. data/app/javascript/controllers/comments/popup_controller.js +18 -0
  12. data/app/javascript/controllers/comments/topics_controller.js +80 -0
  13. data/app/javascript/controllers/creatives/tree_controller.js +44 -0
  14. data/app/javascript/creatives/tree_renderer.js +1 -0
  15. data/app/javascript/lib/api/creatives.js +14 -0
  16. data/app/javascript/modules/creative_row_editor.js +42 -0
  17. data/app/models/collavre/creative.rb +62 -0
  18. data/app/models/collavre/topic.rb +16 -0
  19. data/app/services/collavre/creatives/index_query.rb +13 -3
  20. data/app/services/collavre/creatives/progress_service.rb +11 -6
  21. data/app/services/collavre/creatives/tree_builder.rb +2 -0
  22. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  23. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +3 -0
  24. data/app/views/collavre/creatives/index.html.erb +4 -1
  25. data/config/locales/comments.en.yml +4 -0
  26. data/config/locales/comments.ko.yml +4 -0
  27. data/config/locales/creatives.en.yml +6 -0
  28. data/config/locales/creatives.ko.yml +6 -0
  29. data/config/routes.rb +4 -0
  30. data/db/migrate/20260313011922_add_archived_at_to_creatives_and_topics.rb +9 -0
  31. data/lib/collavre/version.rb +1 -1
  32. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24812d5fb8a185c36f54a83dd822271f6ce858196fee3217fc5006a96ba03550
4
- data.tar.gz: af46f88162225a5bd5121acb7c2e0f597f944e4b3a556163f0074c2e57684da3
3
+ metadata.gz: 220ee539008a01bf0df3c62d9c815ff16ed8687fb27e2c98d1a62288b3f302e1
4
+ data.tar.gz: 97d41b82062cdc7c2223e4ab707bfc46465d65bf45555e7fb418f50626ed9795
5
5
  SHA512:
6
- metadata.gz: 604b3ec73abf15fa485ddb03dbf3b55313b35d0d9ab736e25172fc229e1903ece4b4e89658868f4823f5c5ebcd4195af35f530d41589af13b1b09d05a659b87f
7
- data.tar.gz: d530ae61ee81e7081214e8cab9bef44c8d1fa52c82c972da4e717e5617f420dc41b37cd1fe5b74a9ae2d09b8271e33b454106cb934bc67f4bf8079ba7cc55420
6
+ metadata.gz: 770cddad2594765fdfea31511f0f5f1479c16345688fcd29102417024a3e6685c627cf43f8b159b2b7fbffbe3a29e4fb3b62d8674d1be0828beedf0666ac3af8
7
+ data.tar.gz: f9efa032715bf3e1493fa29ef98b46d6d1a98460d364595bc8ef2692bb6cd78ce464afeed35f3c456fdc3177e380434db87b8f8d4cea48f25d240f32a35e2e3c
@@ -1275,6 +1275,42 @@ body.chat-fullscreen {
1275
1275
  outline: none;
1276
1276
  }
1277
1277
 
1278
+ /* Archive topic styles */
1279
+ .topic-archived-toggle {
1280
+ display: inline-flex;
1281
+ align-items: center;
1282
+ padding: 0.15em var(--space-2);
1283
+ border-radius: var(--radius-3);
1284
+ font-size: var(--text-0);
1285
+ cursor: pointer;
1286
+ opacity: 0.6;
1287
+ border: 1px dashed var(--border-color);
1288
+ }
1289
+ .topic-archived-toggle:hover {
1290
+ opacity: 1;
1291
+ }
1292
+ .topic-tag.topic-archived {
1293
+ opacity: 0.5;
1294
+ font-style: italic;
1295
+ }
1296
+ .archive-topic-btn,
1297
+ .unarchive-topic-btn {
1298
+ background: none;
1299
+ border: none;
1300
+ cursor: pointer;
1301
+ font-size: var(--text-00);
1302
+ padding: 0 var(--space-1);
1303
+ opacity: 0.6;
1304
+ vertical-align: middle;
1305
+ }
1306
+ .archive-topic-btn:hover,
1307
+ .unarchive-topic-btn:hover {
1308
+ opacity: 1;
1309
+ }
1310
+ .delete-topic-btn {
1311
+ margin-left: var(--space-1);
1312
+ }
1313
+
1278
1314
  /* Selection action bar */
1279
1315
  .selection-action-bar {
1280
1316
  position: sticky;
@@ -678,4 +678,30 @@ creative-tree-row:not([expanded]) .creative-toggle-btn {
678
678
  left: 0.5em;
679
679
  width: auto;
680
680
  }
681
- }
681
+ }
682
+ /* Archive toggle button */
683
+ .archive-toggle-btn {
684
+ background: none;
685
+ border: none;
686
+ cursor: pointer;
687
+ font-size: var(--text-2);
688
+ padding: var(--space-1) var(--space-2);
689
+ opacity: 0.5;
690
+ transition: opacity 0.2s var(--ease-2);
691
+ }
692
+ .archive-toggle-btn:hover {
693
+ opacity: 0.8;
694
+ }
695
+ .archive-toggle-btn.active {
696
+ opacity: 1.0;
697
+ background: var(--surface-input);
698
+ border-radius: var(--radius-2);
699
+ }
700
+
701
+ /* Archived creative row */
702
+ creative-tree-row[archived] .creative-tree {
703
+ opacity: 0.5;
704
+ }
705
+ creative-tree-row[archived] .creative-row {
706
+ background-color: var(--surface-input);
707
+ }
@@ -60,7 +60,13 @@ module Collavre
60
60
  end
61
61
 
62
62
  # Apply the Topic Filter
63
- scope = scope.where(topic_id: effective_topic_id) if effective_topic_id.present?
63
+ if effective_topic_id.present?
64
+ scope = scope.where(topic_id: effective_topic_id)
65
+ else
66
+ # Main view: exclude comments from archived topics
67
+ archived_topic_ids = @creative.topics.archived.pluck(:id)
68
+ scope = scope.where.not(topic_id: archived_topic_ids) if archived_topic_ids.any?
69
+ end
64
70
 
65
71
  # Default order: Newest first (created_at DESC)
66
72
  # This matches the column-reverse layout where the first item in the list is the visual bottom (Newest).
@@ -150,6 +156,10 @@ module Collavre
150
156
  end
151
157
 
152
158
  def create
159
+ if @creative.archived?
160
+ render json: { error: I18n.t("collavre.comments.archived_creative") }, status: :forbidden and return
161
+ end
162
+
153
163
  unless @creative.has_permission?(Current.user, :feedback)
154
164
  render json: { error: I18n.t("collavre.comments.no_permission") }, status: :forbidden and return
155
165
  end
@@ -9,7 +9,7 @@ module Collavre
9
9
  # Removed unauthenticated access to index and show actions
10
10
  allow_unauthenticated_access only: %i[ index children export_markdown show slide_view ]
11
11
  before_action :enforce_creatives_login_policy, only: %i[ index children export_markdown show slide_view ]
12
- before_action :set_creative, only: %i[ show edit update destroy parent_suggestions slide_view request_permission unconvert contexts update_contexts update_metadata ]
12
+ before_action :set_creative, only: %i[ show edit update destroy parent_suggestions slide_view request_permission unconvert contexts update_contexts update_metadata archive unarchive ]
13
13
 
14
14
  def index
15
15
  respond_to do |format|
@@ -310,6 +310,24 @@ module Collavre
310
310
  end
311
311
  end
312
312
 
313
+ def archive
314
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
315
+ render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
316
+ end
317
+
318
+ @creative.archive!
319
+ head :ok
320
+ end
321
+
322
+ def unarchive
323
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
324
+ render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
325
+ end
326
+
327
+ @creative.unarchive!
328
+ head :ok
329
+ end
330
+
313
331
  def destroy
314
332
  parent = @creative.parent
315
333
  unless @creative.has_permission?(Current.user, :admin)
@@ -355,7 +373,8 @@ module Collavre
355
373
  params[:due_after].present? ||
356
374
  params[:has_due_date].present? ||
357
375
  params[:assignee_id].present? ||
358
- params[:unassigned].present?
376
+ params[:unassigned].present? ||
377
+ params[:show_archived].present?
359
378
  end
360
379
 
361
380
  def serialize_creatives(collection)
@@ -6,8 +6,13 @@ module Collavre
6
6
  is_owner = @creative.user == Current.user
7
7
  can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
8
8
  can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
9
+
10
+ active_topics = @creative.topics.active.order(:created_at)
11
+ archived_topics = @creative.topics.archived.order(:created_at)
12
+
9
13
  render json: {
10
- topics: @creative.topics.order(:created_at),
14
+ topics: active_topics,
15
+ archived_topics: archived_topics,
11
16
  can_manage: can_manage,
12
17
  can_create_topic: can_create_topic
13
18
  }
@@ -100,6 +105,36 @@ module Collavre
100
105
  render json: { success: true, topic: topic.slice(:id, :name), target_creative_id: target_creative.id }
101
106
  end
102
107
 
108
+ def archive
109
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
110
+ render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
111
+ end
112
+
113
+ topic = @creative.topics.find(params[:id])
114
+ topic.archive!
115
+
116
+ TopicsChannel.broadcast_to(
117
+ @creative,
118
+ { action: "archived", topic: topic.slice(:id, :name) }
119
+ )
120
+ render json: { success: true }
121
+ end
122
+
123
+ def unarchive
124
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
125
+ render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
126
+ end
127
+
128
+ topic = @creative.topics.find(params[:id])
129
+ topic.unarchive!
130
+
131
+ TopicsChannel.broadcast_to(
132
+ @creative,
133
+ { action: "unarchived", topic: topic.slice(:id, :name, :archived_at) }
134
+ )
135
+ render json: { success: true }
136
+ end
137
+
103
138
  def reorder
104
139
  unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
105
140
  render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
@@ -1,5 +1,42 @@
1
1
  module Collavre
2
2
  module ApplicationHelper
3
+ # All Collavre engine stylesheets in load order.
4
+ # Host apps should call <%= collavre_stylesheets %> in their layout <head>
5
+ # instead of listing individual stylesheet_link_tags.
6
+ COLLAVRE_STYLESHEETS = %w[
7
+ collavre/design_tokens
8
+ collavre/dark_mode
9
+ collavre/creatives
10
+ collavre/actiontext
11
+ collavre/activity_logs
12
+ collavre/user_menu
13
+ collavre/org_chart
14
+ collavre/popup
15
+ collavre/comments_popup
16
+ collavre/comment_versions
17
+ collavre/mention_menu
18
+ collavre/slide_view
19
+ ].freeze
20
+
21
+ COLLAVRE_PRINT_STYLESHEETS = %w[
22
+ collavre/print
23
+ ].freeze
24
+
25
+ # Renders all Collavre engine stylesheet tags.
26
+ # Call this once in the host app's layout <head> section.
27
+ #
28
+ # <%= collavre_stylesheets %>
29
+ #
30
+ def collavre_stylesheets
31
+ tags = COLLAVRE_STYLESHEETS.map do |sheet|
32
+ stylesheet_link_tag(sheet)
33
+ end
34
+ tags += COLLAVRE_PRINT_STYLESHEETS.map do |sheet|
35
+ stylesheet_link_tag(sheet, media: "print")
36
+ end
37
+ safe_join(tags, "\n ")
38
+ end
39
+
3
40
  # Returns the body CSS class for the current user's theme.
4
41
  # Custom themes get "light-mode" to disable OS dark mode overrides,
5
42
  # ensuring the custom theme controls all colors regardless of OS setting.
@@ -34,7 +34,9 @@ module Collavre
34
34
  end
35
35
 
36
36
  content_tag(:div, class: "creative-row-end") do
37
- comment_part = if creative.has_permission?(Current.user, :feedback)
37
+ comment_part = if creative.archived?
38
+ safe_join([])
39
+ elsif creative.has_permission?(Current.user, :feedback)
38
40
  origin = creative.effective_origin
39
41
  comments_count = origin.comments.size
40
42
  pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
@@ -23,6 +23,7 @@ class CreativeTreeRow extends LitElement {
23
23
  editOffIconHtml: { state: true },
24
24
  originLinkHtml: { state: true },
25
25
  isTitle: { type: Boolean, attribute: "is-title", reflect: true },
26
+ archived: { type: Boolean, attribute: "archived", reflect: true },
26
27
  loadingChildren: { type: Boolean, attribute: "loading-children", reflect: true },
27
28
  _loadingDotsState: { state: true }
28
29
  };
@@ -59,6 +59,13 @@ export default class extends Controller {
59
59
  }
60
60
 
61
61
  handleTopicChange(event) {
62
+ // During notifyChildControllers, topic loading fires change events before
63
+ // onPopupOpened sets up highlightAfterLoad. Suppress these to avoid a
64
+ // race where a non-highlight load overwrites the deep-link highlight load.
65
+ if (this.suppressTopicChangeLoad) {
66
+ this.currentTopicId = event.detail.topicId
67
+ return
68
+ }
62
69
  this.currentTopicId = event.detail.topicId
63
70
  this.resetToLatest()
64
71
  }
@@ -148,10 +155,13 @@ export default class extends Controller {
148
155
  }
149
156
 
150
157
  const requestTopicId = this.currentTopicId || ""
158
+ const requestCreativeId = this.creativeId
151
159
 
152
160
  this.fetchComments(params).then((html) => {
153
- // If topic changed while fetching (e.g. deep link detection), discard this stale response.
154
- // The topic change event will have triggered a new load.
161
+ // Discard stale responses if creative or topic changed while fetching.
162
+ // This prevents a race condition where switching creatives causes
163
+ // the old creative's comments to overwrite the new creative's list.
164
+ if (this.creativeId !== requestCreativeId) return
155
165
  if (String(this.currentTopicId || "") !== String(requestTopicId)) return
156
166
 
157
167
  this.listTarget.innerHTML = html
@@ -307,6 +317,7 @@ export default class extends Controller {
307
317
  if (!comment) return
308
318
  comment.scrollIntoView({ behavior: 'auto', block: 'center' })
309
319
  comment.classList.add('highlight-flash')
320
+ comment.dataset.highlighted = 'true'
310
321
  window.setTimeout(() => comment.classList.remove('highlight-flash'), 2000)
311
322
  }
312
323
 
@@ -199,11 +199,29 @@ export default class extends Controller {
199
199
  }
200
200
 
201
201
  async notifyChildControllers({ creativeId, canComment, highlightId }) {
202
+ // Pre-set creativeId on list controller BEFORE loading topics.
203
+ // Topics loading triggers a change event that list controller handles.
204
+ // Without this, list controller still holds the previous creative's ID
205
+ // and would fetch comments for the wrong creative (race condition).
206
+ //
207
+ // Also suppress topic-change-triggered loads during topic initialization.
208
+ // Without this, the topic change event fires loadInitialComments() before
209
+ // onPopupOpened sets highlightAfterLoad, causing a race where the non-highlight
210
+ // load can overwrite the deep-link highlight load.
211
+ if (this.listController) {
212
+ this.listController.creativeId = creativeId
213
+ this.listController.suppressTopicChangeLoad = true
214
+ }
215
+
202
216
  // Load topics first to establish context
203
217
  if (this.topicsController) {
204
218
  await this.topicsController.onPopupOpened({ creativeId })
205
219
  }
206
220
 
221
+ if (this.listController) {
222
+ this.listController.suppressTopicChangeLoad = false
223
+ }
224
+
207
225
  if (this.formController) {
208
226
  this.formController.onPopupOpened({ creativeId, canComment })
209
227
  }
@@ -67,6 +67,8 @@ export default class extends Controller {
67
67
  this.topics = topics
68
68
  this.canManageTopics = canManage
69
69
  this.canCreateTopic = canCreateTopic
70
+ this.archivedTopics = data.archived_topics || []
71
+
70
72
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
71
73
  this.restoreSelection()
72
74
  }
@@ -115,12 +117,28 @@ export default class extends Controller {
115
117
  #${topic.name}`
116
118
 
117
119
  if (canManage) {
120
+ html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="πŸ“¦">πŸ“¦</button>`
118
121
  html += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">&times;</button>`
119
122
  }
120
123
 
121
124
  html += `</span>`
122
125
  })
123
126
 
127
+ // Archived topics section
128
+ if (this.archivedTopics && this.archivedTopics.length > 0) {
129
+ html += `<span class="topic-archived-toggle" data-action="click->comments--topics#toggleArchivedTopics">
130
+ πŸ“¦ ${this.archivedTopics.length}
131
+ </span>`
132
+ if (this.showingArchived) {
133
+ this.archivedTopics.forEach(topic => {
134
+ html += `<span class="topic-tag topic-archived" data-id="${topic.id}">
135
+ #${topic.name}
136
+ ${canManage ? `<button class="unarchive-topic-btn" data-action="click->comments--topics#unarchiveTopic" data-id="${topic.id}" title="Restore">↩</button>` : ''}
137
+ </span>`
138
+ })
139
+ }
140
+ }
141
+
124
142
  // Add create button container (write permission is sufficient for topic creation)
125
143
  if (canCreateTopic) {
126
144
  html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer">
@@ -323,6 +341,63 @@ export default class extends Controller {
323
341
  }
324
342
  }
325
343
 
344
+ async archiveTopic(event) {
345
+ event.stopPropagation()
346
+ const topicId = event.target.dataset.id
347
+ if (!topicId) return
348
+
349
+ try {
350
+ const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}/archive`, {
351
+ method: 'PATCH',
352
+ headers: {
353
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
354
+ }
355
+ })
356
+
357
+ if (response.ok) {
358
+ if (String(this.currentTopicId) === String(topicId)) {
359
+ this.currentTopicId = ""
360
+ this.dispatch("change", { detail: { topicId: "" } })
361
+ }
362
+ this.loadTopics()
363
+ } else {
364
+ alert("Failed to archive topic")
365
+ }
366
+ } catch (e) {
367
+ console.error("Error archiving topic", e)
368
+ }
369
+ }
370
+
371
+ async unarchiveTopic(event) {
372
+ event.stopPropagation()
373
+ const topicId = event.target.dataset.id
374
+ if (!topicId) return
375
+
376
+ try {
377
+ const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}/unarchive`, {
378
+ method: 'PATCH',
379
+ headers: {
380
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
381
+ }
382
+ })
383
+
384
+ if (response.ok) {
385
+ this.loadTopics()
386
+ } else {
387
+ alert("Failed to restore topic")
388
+ }
389
+ } catch (e) {
390
+ console.error("Error restoring topic", e)
391
+ }
392
+ }
393
+
394
+ toggleArchivedTopics(event) {
395
+ event.stopPropagation()
396
+ this.showingArchived = !this.showingArchived
397
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
398
+ this.restoreSelection()
399
+ }
400
+
326
401
  showInput(event) {
327
402
  event.preventDefault()
328
403
  const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
@@ -614,6 +689,11 @@ export default class extends Controller {
614
689
  return
615
690
  }
616
691
 
692
+ if (action === "archived" || action === "unarchived") {
693
+ this.loadTopics()
694
+ return
695
+ }
696
+
617
697
  if (action === "reordered" && data.topic_ids) {
618
698
  this.reorderTopicsFromServer(data.topic_ids)
619
699
  return
@@ -20,6 +20,7 @@ export default class extends Controller {
20
20
  this.queueAlignmentUpdate()
21
21
  window.addEventListener('resize', this.handleResize)
22
22
  this.element.addEventListener('creative-tree:updated', this.handleTreeUpdated)
23
+ this._setupArchiveToggle()
23
24
  }
24
25
 
25
26
  disconnect() {
@@ -29,6 +30,49 @@ export default class extends Controller {
29
30
  }
30
31
  window.removeEventListener('resize', this.handleResize)
31
32
  this.element.removeEventListener('creative-tree:updated', this.handleTreeUpdated)
33
+ if (this._archiveToggleHandler) {
34
+ document.getElementById('toggle-archived-btn')?.removeEventListener('click', this._archiveToggleHandler)
35
+ document.getElementById('toggle-archived-btn-mobile')?.removeEventListener('click', this._archiveToggleHandler)
36
+ }
37
+ }
38
+
39
+ _setupArchiveToggle() {
40
+ const btn = document.getElementById('toggle-archived-btn')
41
+ const mobileBtn = document.getElementById('toggle-archived-btn-mobile')
42
+ if (!btn && !mobileBtn) return
43
+
44
+ this._showingArchived = false
45
+
46
+ const toggle = () => {
47
+ this._showingArchived = !this._showingArchived
48
+
49
+ // Update desktop button
50
+ if (btn) {
51
+ btn.classList.toggle('active', this._showingArchived)
52
+ btn.title = this._showingArchived ? (btn.dataset.hideText || '') : (btn.dataset.showText || '')
53
+ }
54
+
55
+ // Update mobile button text
56
+ if (mobileBtn) {
57
+ const label = this._showingArchived ? (mobileBtn.dataset.hideText || '') : (mobileBtn.dataset.showText || '')
58
+ mobileBtn.textContent = 'πŸ“¦ ' + label
59
+ }
60
+
61
+ const url = new URL(this.urlValue, window.location.origin)
62
+ if (this._showingArchived) {
63
+ url.searchParams.set('show_archived', 'true')
64
+ } else {
65
+ url.searchParams.delete('show_archived')
66
+ }
67
+ this.urlValue = url.pathname + url.search
68
+ this.element.innerHTML = ''
69
+ delete this.element.dataset.loaded
70
+ this.load()
71
+ }
72
+
73
+ this._archiveToggleHandler = toggle
74
+ if (btn) btn.addEventListener('click', toggle)
75
+ if (mobileBtn) mobileBtn.addEventListener('click', toggle)
32
76
  }
33
77
 
34
78
  load() {
@@ -64,6 +64,7 @@ function applyRowProperties(row, node) {
64
64
  updateBooleanAttr('hasChildren', 'has-children', node.has_children)
65
65
  updateBooleanAttr('expanded', 'expanded', node.expanded)
66
66
  updateBooleanAttr('isRoot', 'is-root', node.is_root)
67
+ updateBooleanAttr('archived', 'archived', node.archived)
67
68
 
68
69
  if (node.link_url) {
69
70
  if (row.linkUrl !== node.link_url) {
@@ -58,6 +58,18 @@ export function destroy(id, withChildren = false) {
58
58
  })
59
59
  }
60
60
 
61
+ export function archive(id) {
62
+ return csrfFetch(`/creatives/${id}/archive`, {
63
+ method: 'PATCH',
64
+ })
65
+ }
66
+
67
+ export function unarchive(id) {
68
+ return csrfFetch(`/creatives/${id}/unarchive`, {
69
+ method: 'PATCH',
70
+ })
71
+ }
72
+
61
73
  export function unconvert(id) {
62
74
  return csrfFetch(`/creatives/${id}/unconvert`, {
63
75
  method: 'POST',
@@ -84,6 +96,8 @@ const creativesApi = {
84
96
  save,
85
97
  linkExisting,
86
98
  destroy,
99
+ archive,
100
+ unarchive,
87
101
  unconvert,
88
102
  updateMetadata,
89
103
  }
@@ -86,6 +86,7 @@ export function initializeCreativeRowEditor() {
86
86
  const levelDownBtn = document.getElementById('inline-level-down');
87
87
  const levelUpBtn = document.getElementById('inline-level-up');
88
88
  const deletePopupToggle = document.getElementById('inline-delete-popup-toggle');
89
+ const archiveBtn = document.getElementById('inline-archive');
89
90
  const deleteBtn = document.getElementById('inline-delete');
90
91
  const deleteWithChildrenBtn = document.getElementById('inline-delete-with-children');
91
92
  const linkBtn = document.getElementById('inline-link');
@@ -677,6 +678,13 @@ export function initializeCreativeRowEditor() {
677
678
  if (levelUpBtn) levelUpBtn.disabled = !canLevelUp;
678
679
 
679
680
  if (deletePopupToggle) deletePopupToggle.disabled = !hasCreativeId;
681
+ if (archiveBtn) {
682
+ archiveBtn.disabled = !hasCreativeId;
683
+ // Toggle label between Archive / Restore based on creative state
684
+ const targetRow = hasCreativeId ? document.querySelector(`creative-tree-row[creative-id="${form.dataset.creativeId}"]`) : null;
685
+ const isArchived = targetRow?.hasAttribute('archived');
686
+ archiveBtn.textContent = isArchived ? (archiveBtn.dataset.restoreLabel || 'Restore') : (archiveBtn.dataset.archiveLabel || 'Archive');
687
+ }
680
688
  if (deleteBtn) deleteBtn.disabled = !hasCreativeId;
681
689
  if (deleteWithChildrenBtn) deleteWithChildrenBtn.disabled = !hasCreativeId;
682
690
  if (linkBtn) linkBtn.disabled = !hasCreativeId || linkBtn.style.display === 'none';
@@ -1848,6 +1856,40 @@ export function initializeCreativeRowEditor() {
1848
1856
  levelUpBtn.addEventListener('click', levelUp);
1849
1857
  }
1850
1858
 
1859
+ if (archiveBtn) {
1860
+ archiveBtn.addEventListener('click', function () {
1861
+ const creativeId = form.dataset.creativeId;
1862
+ if (!creativeId) return;
1863
+ const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`);
1864
+ const isArchived = row?.hasAttribute('archived');
1865
+ const confirmMsg = isArchived ? archiveBtn.dataset.restoreConfirm : archiveBtn.dataset.confirm;
1866
+
1867
+ if (confirm(confirmMsg)) {
1868
+ const apiCall = isArchived ? creativesApi.unarchive(creativeId) : creativesApi.archive(creativeId);
1869
+ apiCall.then(res => {
1870
+ if (res.ok) {
1871
+ if (!isArchived) {
1872
+ // Archiving: remove from view
1873
+ const childrenContainer = document.getElementById(`creative-children-${creativeId}`);
1874
+ if (childrenContainer) childrenContainer.remove();
1875
+ if (row) row.remove();
1876
+ } else {
1877
+ // Restoring: reload tree to show updated state
1878
+ const treeEl = document.querySelector('[data-controller="creatives--tree"]');
1879
+ if (treeEl) {
1880
+ treeEl.innerHTML = '';
1881
+ delete treeEl.dataset.loaded;
1882
+ const ctrl = window.Stimulus?.getControllerForElementAndIdentifier(treeEl, 'creatives--tree');
1883
+ if (ctrl) ctrl.load();
1884
+ }
1885
+ }
1886
+ closeEditor();
1887
+ }
1888
+ });
1889
+ }
1890
+ });
1891
+ }
1892
+
1851
1893
  if (deleteBtn) {
1852
1894
  deleteBtn.addEventListener('click', function () {
1853
1895
  if (confirm(deleteBtn.dataset.confirm)) deleteCurrent(false);
@@ -22,6 +22,10 @@ module Collavre
22
22
 
23
23
  has_closure_tree order: :sequence, name_column: :description, hierarchy_table_name: "creative_hierarchies"
24
24
 
25
+ # --- Archive scopes ---
26
+ scope :active, -> { where(archived_at: nil) }
27
+ scope :archived, -> { where.not(archived_at: nil) }
28
+
25
29
  attr_accessor :filtered_progress
26
30
 
27
31
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
@@ -143,6 +147,64 @@ module Collavre
143
147
  end
144
148
  end
145
149
 
150
+ # --- Archive ---
151
+ def archived?
152
+ archived_at.present?
153
+ end
154
+
155
+ def archive!
156
+ now = Time.current
157
+ self.class.transaction do
158
+ # Archive self and descendants
159
+ self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
160
+
161
+ # If this is a linked creative, also archive the origin and its descendants
162
+ origin = effective_origin(Set.new)
163
+ if origin != self
164
+ origin.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
165
+ # Archive all other linked creatives of the origin
166
+ origin.linked_creatives.where.not(id: id).find_each do |linked|
167
+ linked.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
168
+ end
169
+ end
170
+
171
+ # Also archive any linked creatives that point to this one
172
+ linked_creatives.find_each do |linked|
173
+ linked.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
174
+ end
175
+
176
+ reload
177
+ parent&.reload
178
+ Collavre::Creatives::ProgressService.new(parent).update_progress_from_children! if parent
179
+ end
180
+ end
181
+
182
+ def unarchive!
183
+ self.class.transaction do
184
+ # Unarchive self and descendants
185
+ self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
186
+
187
+ # If this is a linked creative, also unarchive the origin and its descendants
188
+ origin = effective_origin(Set.new)
189
+ if origin != self
190
+ origin.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
191
+ # Unarchive all other linked creatives of the origin
192
+ origin.linked_creatives.where.not(id: id).find_each do |linked|
193
+ linked.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
194
+ end
195
+ end
196
+
197
+ # Also unarchive any linked creatives that point to this one
198
+ linked_creatives.find_each do |linked|
199
+ linked.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
200
+ end
201
+
202
+ reload
203
+ parent&.reload
204
+ Collavre::Creatives::ProgressService.new(parent).update_progress_from_children! if parent
205
+ end
206
+ end
207
+
146
208
 
147
209
  private
148
210
 
@@ -7,12 +7,28 @@ module Collavre
7
7
 
8
8
  has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
9
9
 
10
+ # --- Archive scopes ---
11
+ scope :active, -> { where(archived_at: nil) }
12
+ scope :archived, -> { where.not(archived_at: nil) }
13
+
10
14
  validates :name, presence: true, uniqueness: { scope: :creative_id }
11
15
 
12
16
  before_create :set_default_position
13
17
 
14
18
  default_scope { order(:position) }
15
19
 
20
+ def archived?
21
+ archived_at.present?
22
+ end
23
+
24
+ def archive!
25
+ update!(archived_at: Time.current)
26
+ end
27
+
28
+ def unarchive!
29
+ update!(archived_at: nil)
30
+ end
31
+
16
32
  private
17
33
 
18
34
  def set_default_position
@@ -112,8 +112,11 @@ module Creatives
112
112
  creative = Creative.find_by(id: params[:id])
113
113
  return empty_result unless creative && readable?(creative)
114
114
 
115
+ children = creative.children_with_permission(user, :read)
116
+ children = children.select { |c| !c.archived? } unless params[:show_archived]
117
+
115
118
  {
116
- creatives: creative.children_with_permission(user, :read),
119
+ creatives: children,
117
120
  parent: creative,
118
121
  allowed_ids: nil,
119
122
  overall_progress: nil,
@@ -122,8 +125,11 @@ module Creatives
122
125
  end
123
126
 
124
127
  def handle_root_query
128
+ roots = Creative.where(user: user).roots
129
+ roots = roots.where(archived_at: nil) unless params[:show_archived]
130
+
125
131
  {
126
- creatives: Creative.where(user: user).roots,
132
+ creatives: roots,
127
133
  parent: nil,
128
134
  allowed_ids: nil,
129
135
  overall_progress: nil,
@@ -132,7 +138,7 @@ module Creatives
132
138
  end
133
139
 
134
140
  def determine_scope
135
- if params[:id]
141
+ base_scope = if params[:id]
136
142
  base = Creative.find_by(id: params[:id])&.effective_origin
137
143
  return Creative.none unless base
138
144
 
@@ -163,6 +169,10 @@ module Creatives
163
169
  else
164
170
  Creative.where(origin_id: nil) # Only real creatives (not shells)
165
171
  end
172
+
173
+ # Exclude archived creatives from all filters/search unless explicitly requested
174
+ base_scope = base_scope.where(archived_at: nil) unless params[:show_archived]
175
+ base_scope
166
176
  end
167
177
 
168
178
  def determine_start_nodes(allowed_ids)
@@ -8,10 +8,11 @@ module Collavre
8
8
  end
9
9
 
10
10
  def update_progress_from_children!
11
- if creative.children.any?
11
+ active_children = creative.children.active
12
+ if active_children.any?
12
13
  # Use Ruby calculation to get effective progress (handling delegation for linked creatives)
13
14
  # instead of SQL average which reads potentially stale DB columns.
14
- new_progress = creative.children.map(&:progress).sum.to_f / creative.children.size
15
+ new_progress = active_children.map(&:progress).sum.to_f / active_children.size
15
16
  creative.update(progress: new_progress)
16
17
  else
17
18
  creative.update(progress: 0)
@@ -37,8 +38,9 @@ module Collavre
37
38
  rescue ActiveRecord::RecordNotFound
38
39
  return
39
40
  end
40
- new_progress = if parent.children.any?
41
- parent.children.map(&:progress).sum.to_f / parent.children.size
41
+ active_children = parent.children.active
42
+ new_progress = if active_children.any?
43
+ active_children.map(&:progress).sum.to_f / active_children.size
42
44
  else
43
45
  0
44
46
  end
@@ -51,9 +53,10 @@ module Collavre
51
53
 
52
54
  def progress_for_tags(tag_ids, user)
53
55
  return creative.progress if tag_ids.blank?
56
+ return nil if creative.archived?
54
57
 
55
58
  tag_ids = Array(tag_ids).map(&:to_s)
56
- visible_children = creative.children_with_permission(user)
59
+ visible_children = creative.children_with_permission(user).select { |c| !c.archived? }
57
60
  child_values = visible_children.map do |child|
58
61
  self.class.new(child).progress_for_tags(tag_ids, user)
59
62
  end.compact
@@ -70,7 +73,9 @@ module Collavre
70
73
 
71
74
  # `tagged_ids` should be a Set of creative IDs tagged with the plan.
72
75
  def progress_for_plan(tagged_ids)
73
- child_values = creative.children.map do |child|
76
+ return nil if creative.archived?
77
+
78
+ child_values = creative.children.active.map do |child|
74
79
  self.class.new(child).progress_for_plan(tagged_ids)
75
80
  end.compact
76
81
 
@@ -121,6 +121,7 @@ module Creatives
121
121
  has_children: filtered_children.any?,
122
122
  expanded: expanded,
123
123
  is_root: creative.parent.nil?,
124
+ archived: creative.archived?,
124
125
  link_url: view_context.collavre.creative_path(creative),
125
126
  templates: template_payload_for(creative),
126
127
  inline_editor_payload: inline_editor_payload_for(creative),
@@ -148,6 +149,7 @@ module Creatives
148
149
  return [] if raw_params["comment"] == "true" || raw_params["search"].present?
149
150
 
150
151
  children = creative.children_with_permission(user)
152
+ children = children.select { |c| !c.archived? } unless raw_params["show_archived"]
151
153
  if allowed_creative_ids
152
154
  children.select { |c| allowed_creative_ids.include?(c.id.to_s) }
153
155
  else
@@ -58,6 +58,16 @@
58
58
  button_attributes: { id: 'inline-delete-popup-toggle' },
59
59
  menu_id: 'inline-delete-popup-menu'
60
60
  ) do %>
61
+ <button type="button"
62
+ id="inline-archive"
63
+ class="popup-menu-item"
64
+ title="<%= t('collavre.creatives.index.archive') %>"
65
+ data-confirm="<%= t('collavre.creatives.index.archive_confirm') %>"
66
+ data-archive-label="<%= t('collavre.creatives.index.archive') %>"
67
+ data-restore-label="<%= t('collavre.creatives.index.unarchive') %>"
68
+ data-restore-confirm="<%= t('collavre.creatives.index.unarchive_confirm', default: 'Restore this creative?') %>">
69
+ <%= t('collavre.creatives.index.archive') %>
70
+ </button>
61
71
  <button type="button"
62
72
  id="inline-delete"
63
73
  class="popup-menu-item danger-link"
@@ -25,6 +25,9 @@
25
25
  <% if @parent_creative.present? %>
26
26
  <%= button_to t('collavre.creatives.index.slide_view'), slide_view_creative_path(@parent_creative), method: :get, class: 'popup-menu-item' %>
27
27
  <% end %>
28
+ <% if authenticated? %>
29
+ <button type="button" id="toggle-archived-btn-mobile" class="popup-menu-item" data-show-text="<%= t('collavre.creatives.index.show_archived') %>" data-hide-text="<%= t('collavre.creatives.index.hide_archived') %>">πŸ“¦ <%= t('collavre.creatives.index.show_archived') %></button>
30
+ <% end %>
28
31
  <% if authenticated? && (!params[:id] || (current_creative && current_creative.has_permission?(Current.user, :write))) %>
29
32
  <button type="button" class="popup-menu-item" data-controller="click-target" data-click-target-id-value="import-markdown-btn" data-action="click->click-target#trigger"><%= t('collavre.creatives.index.import_markdown') %></button>
30
33
  <% end %>
@@ -54,6 +54,9 @@
54
54
  <button id="expand-all-btn" class="expand-btn" style="width: 36px;" data-expand-text="<%= t('app.expand_all') %>" data-collapse-text="<%= t('app.collapse_all') %>" data-action="click->creatives--expansion#toggleAll" data-creatives--expansion-target="expand"><span aria-hidden="true">β–Ό</span></button>
55
55
  <button id="toggle-edit-btn" class="edit-toggle-btn mobile-only" aria-label="<%= t('.edit') %>" title="<%= t('.edit') %>">
56
56
  <span aria-hidden="true"><%= svg_tag 'edit.svg', class: 'icon-edit', width: 16, height: 16 %></span></button>
57
+ <% if authenticated? %>
58
+ <button id="toggle-archived-btn" class="archive-toggle-btn desktop-only" title="<%= t('collavre.creatives.index.show_archived') %>" data-show-text="<%= t('collavre.creatives.index.show_archived') %>" data-hide-text="<%= t('collavre.creatives.index.hide_archived') %>"><span aria-hidden="true">πŸ“¦</span></button>
59
+ <% end %>
57
60
  <% if authenticated? && (!params[:id] || (@parent_creative && @parent_creative.has_permission?(Current.user, :write))) %>
58
61
  <button id="import-markdown-btn" class="import-btn desktop-only" data-action="click->creatives--import#toggle" data-creatives--import-target="toggle"><%= t('.import_markdown') %></button>
59
62
  <% end %>
@@ -179,7 +182,7 @@
179
182
  tree_params = params.to_unsafe_h.slice(
180
183
  "id", "tags", "min_progress", "max_progress", "search", "comment",
181
184
  "has_comments", "due_before", "due_after", "has_due_date",
182
- "assignee_id", "unassigned"
185
+ "assignee_id", "unassigned", "show_archived"
183
186
  ).merge(format: :json)
184
187
  tree_url = creatives_path(tree_params)
185
188
  %>
@@ -5,6 +5,9 @@ en:
5
5
  delete_confirm: This will delete all messages in this topic. Are you sure?
6
6
  new_placeholder: New Topic
7
7
  no_permission: You don't have permission to perform this action.
8
+ archive: Archive
9
+ unarchive: Restore
10
+ archived_topics: "Archived topics (%{count})"
8
11
  move:
9
12
  no_target_permission: You don't have write permission on the target creative.
10
13
  duplicate_name: A topic named '%{name}' already exists in the target creative.
@@ -25,6 +28,7 @@ en:
25
28
  delete_button: Delete
26
29
  not_owner: Only the comment owner can modify this.
27
30
  no_permission: You do not have permission to comment.
31
+ archived_creative: This creative is archived. Comments are disabled.
28
32
  convert_not_allowed: Only the comment owner or a creative admin can convert
29
33
  this message.
30
34
  convert_button: Convert
@@ -5,6 +5,9 @@ ko:
5
5
  delete_confirm: 이 ν† ν”½μ˜ λͺ¨λ“  λ©”μ‹œμ§€κ°€ μ‚­μ œλ©λ‹ˆλ‹€. ν™•μ‹€ν•©λ‹ˆκΉŒ?
6
6
  new_placeholder: μƒˆ ν† ν”½
7
7
  no_permission: 이 μž‘μ—…μ„ μˆ˜ν–‰ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.
8
+ archive: μ•„μΉ΄μ΄λΈŒ
9
+ unarchive: 볡원
10
+ archived_topics: "μ•„μΉ΄μ΄λΈŒλœ ν† ν”½ (%{count}개)"
8
11
  move:
9
12
  no_target_permission: λŒ€μƒ ν¬λ¦¬μ—μ΄ν‹°λΈŒμ— λŒ€ν•œ μ“°κΈ° κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.
10
13
  duplicate_name: "'%{name}' 토픽이 λŒ€μƒ ν¬λ¦¬μ—μ΄ν‹°λΈŒμ— 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€."
@@ -25,6 +28,7 @@ ko:
25
28
  delete_button: μ‚­μ œ
26
29
  not_owner: λŒ“κΈ€ μ†Œμœ μžλ§Œ μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
27
30
  no_permission: λŒ“κΈ€μ„ μΆ”κ°€ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.
31
+ archived_creative: μ•„μΉ΄μ΄λΈŒλœ ν¬λ¦¬μ—μ΄ν‹°λΈŒμž…λ‹ˆλ‹€. μ±„νŒ…μ΄ λΉ„ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.
28
32
  convert_not_allowed: λŒ“κΈ€ μ†Œμœ μž λ˜λŠ” ν¬λ¦¬μ—μ΄ν‹°λΈŒ κ΄€λ¦¬μžλ§Œ 이 λ©”μ‹œμ§€λ₯Ό μ „ν™˜ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
29
33
  convert_button: μ „ν™˜
30
34
  convert_to_creative: ν•˜μœ„ ν¬λ¦¬μ—μ΄ν‹°λΈŒλ‘œ μ „ν™˜
@@ -11,6 +11,12 @@ en:
11
11
  append_as_parent_creative: New upper-creative
12
12
  append_below_creative: Add below
13
13
  delete: Delete
14
+ archive: Archive
15
+ archive_confirm: Archive this item and all children?
16
+ unarchive_confirm: Restore this creative from archive?
17
+ unarchive: Restore
18
+ show_archived: Show archived
19
+ hide_archived: Hide archived
14
20
  delete_only_this: Delete only this Creative
15
21
  delete_with_children: Delete this Creative and all its children
16
22
  are_you_sure: Are you sure?
@@ -11,6 +11,12 @@ ko:
11
11
  append_as_parent_creative: μƒμœ„μ— μΆ”κ°€
12
12
  append_below_creative: μ•„λž˜μ— μΆ”κ°€
13
13
  delete: μ‚­μ œ
14
+ archive: μ•„μΉ΄μ΄λΈŒ
15
+ archive_confirm: 이 ν•­λͺ©κ³Ό λͺ¨λ“  ν•˜μœ„ ν•­λͺ©μ„ μ•„μΉ΄μ΄λΈŒν• κΉŒμš”?
16
+ unarchive_confirm: 이 ν¬λ¦¬μ—μ΄ν‹°λΈŒλ₯Ό μ•„μΉ΄μ΄λΈŒμ—μ„œ λ³΅μ›ν• κΉŒμš”?
17
+ unarchive: 볡원
18
+ show_archived: μ•„μΉ΄μ΄λΈŒ ν‘œμ‹œ
19
+ hide_archived: μ•„μΉ΄μ΄λΈŒ 숨기기
14
20
  delete_only_this: 이 ν•­λͺ©λ§Œ μ‚­μ œ
15
21
  delete_with_children: 이 ν•­λͺ©κ³Ό λͺ¨λ“  ν•˜μœ„ ν•­λͺ© μ‚­μ œ
16
22
  are_you_sure: 정말 μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?
data/config/routes.rb CHANGED
@@ -57,6 +57,8 @@ Collavre::Engine.routes.draw do
57
57
  end
58
58
  member do
59
59
  patch :move
60
+ patch :archive
61
+ patch :unarchive
60
62
  end
61
63
  end
62
64
  resources :comments, only: [ :index, :create, :destroy, :show, :update ] do
@@ -95,6 +97,8 @@ Collavre::Engine.routes.draw do
95
97
  post :share, to: "creatives#share", as: :share_creative
96
98
  post :request_permission, to: "creatives#request_permission"
97
99
  post :unconvert
100
+ patch :archive
101
+ patch :unarchive
98
102
  get :parent_suggestions
99
103
  get :slide_view
100
104
  get :contexts
@@ -0,0 +1,9 @@
1
+ class AddArchivedAtToCreativesAndTopics < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :creatives, :archived_at, :datetime
4
+ add_column :topics, :archived_at, :datetime
5
+
6
+ add_index :creatives, :archived_at, where: "archived_at IS NOT NULL", name: "index_creatives_on_archived_at"
7
+ add_index :topics, :archived_at, where: "archived_at IS NOT NULL", name: "index_topics_on_archived_at"
8
+ end
9
+ end
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -646,6 +646,7 @@ files:
646
646
  - db/migrate/20260223173533_add_review_type_to_comments.rb
647
647
  - db/migrate/20260225065200_create_comment_versions.rb
648
648
  - db/migrate/20260225074416_add_selected_version_id_to_comments.rb
649
+ - db/migrate/20260313011922_add_archived_at_to_creatives_and_topics.rb
649
650
  - lib/collavre.rb
650
651
  - lib/collavre/configuration.rb
651
652
  - lib/collavre/engine.rb