@windward/integrations 0.20.0 → 0.23.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 (151) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/components/Content/Blocks/ActionPanel/TransformActivity.vue +386 -0
  3. package/components/Content/Blocks/ActionPanel/TransformBlock.vue +875 -105
  4. package/components/Content/Blocks/ExternalIntegration/LtiConsumer.vue +22 -2
  5. package/components/ExternalIntegration/Driver/Lti1p1/ManageConsumer.vue +8 -2
  6. package/components/ExternalIntegration/Driver/Lti1p1/ManageProvider.vue +4 -0
  7. package/components/ExternalIntegration/Driver/Lti1p3/ManageConsumer.vue +2 -2
  8. package/components/Integration/JobTable.vue +35 -3
  9. package/components/Integration/TranslateCourse.vue +574 -0
  10. package/components/Settings/ExternalIntegration/LtiConsumerSettings.vue +45 -12
  11. package/helpers/ExternalIntegration/ScormHelper.ts +1 -1
  12. package/i18n/ar-SA/components/ai_agent/chat.ts +20 -0
  13. package/i18n/ar-SA/components/ai_agent/index.ts +5 -0
  14. package/i18n/ar-SA/components/content/blocks/action_panel/index.ts +5 -0
  15. package/i18n/ar-SA/components/content/blocks/action_panel/transform_block.ts +9 -0
  16. package/i18n/ar-SA/components/content/blocks/external_integration/index.ts +5 -0
  17. package/i18n/ar-SA/components/content/blocks/external_integration/lti_consumer.ts +17 -0
  18. package/i18n/ar-SA/components/content/blocks/index.ts +7 -0
  19. package/i18n/ar-SA/components/content/index.ts +5 -0
  20. package/i18n/ar-SA/components/external_integration/driver/lti1p1.ts +17 -0
  21. package/i18n/ar-SA/components/external_integration/driver/lti1p3.ts +25 -0
  22. package/i18n/ar-SA/components/external_integration/driver/scorm.ts +14 -0
  23. package/i18n/ar-SA/components/external_integration/index.ts +36 -0
  24. package/i18n/ar-SA/components/external_integration/provider_target.ts +9 -0
  25. package/i18n/ar-SA/components/file_import/index.ts +5 -0
  26. package/i18n/ar-SA/components/file_import/resourcespace.ts +4 -0
  27. package/i18n/ar-SA/components/index.ts +19 -0
  28. package/i18n/ar-SA/components/integration/driver.ts +67 -0
  29. package/i18n/ar-SA/components/integration/index.ts +9 -0
  30. package/i18n/ar-SA/components/integration/job.ts +25 -0
  31. package/i18n/ar-SA/components/integration/job_log.ts +24 -0
  32. package/i18n/ar-SA/components/llm/blooms.ts +15 -0
  33. package/i18n/ar-SA/components/llm/content_selector.ts +3 -0
  34. package/i18n/ar-SA/components/llm/generate_content/fake_text_stream.ts +62 -0
  35. package/i18n/ar-SA/components/llm/generate_content/generate_questions.ts +92 -0
  36. package/i18n/ar-SA/components/llm/generate_content/index.ts +8 -0
  37. package/i18n/ar-SA/components/llm/index.ts +10 -0
  38. package/i18n/ar-SA/components/navigation/index.ts +5 -0
  39. package/i18n/ar-SA/components/navigation/integrations.ts +9 -0
  40. package/i18n/ar-SA/components/settings/external_integration/index.ts +5 -0
  41. package/i18n/ar-SA/components/settings/external_integration/lti_consumer.ts +10 -0
  42. package/i18n/ar-SA/components/settings/index.ts +5 -0
  43. package/i18n/ar-SA/index.ts +16 -0
  44. package/i18n/ar-SA/modules/index.ts +5 -0
  45. package/i18n/ar-SA/pages/admin/index.ts +5 -0
  46. package/i18n/ar-SA/pages/admin/translateCourse.ts +68 -0
  47. package/i18n/ar-SA/pages/course/external_integration/index.ts +6 -0
  48. package/i18n/ar-SA/pages/course/index.ts +5 -0
  49. package/i18n/ar-SA/pages/importContent.ts +3 -0
  50. package/i18n/ar-SA/pages/importCourse.ts +13 -0
  51. package/i18n/ar-SA/pages/index.ts +15 -0
  52. package/i18n/ar-SA/pages/login/index.ts +9 -0
  53. package/i18n/ar-SA/pages/login/lti.ts +23 -0
  54. package/i18n/ar-SA/pages/login/saml.ts +7 -0
  55. package/i18n/ar-SA/pages/login/scorm.ts +28 -0
  56. package/i18n/ar-SA/pages/vendor.ts +11 -0
  57. package/i18n/ar-SA/shared/content_blocks.ts +9 -0
  58. package/i18n/ar-SA/shared/error.ts +9 -0
  59. package/i18n/ar-SA/shared/file.ts +5 -0
  60. package/i18n/ar-SA/shared/index.ts +17 -0
  61. package/i18n/ar-SA/shared/menu.ts +3 -0
  62. package/i18n/ar-SA/shared/notification.ts +14 -0
  63. package/i18n/ar-SA/shared/permission.ts +52 -0
  64. package/i18n/ar-SA/shared/settings.ts +9 -0
  65. package/i18n/en-US/components/content/blocks/action_panel/index.ts +2 -0
  66. package/i18n/en-US/components/content/blocks/action_panel/transform_activity.ts +8 -0
  67. package/i18n/en-US/components/content/blocks/action_panel/transform_block.ts +3 -1
  68. package/i18n/en-US/components/content/blocks/external_integration/lti_consumer.ts +3 -2
  69. package/i18n/en-US/components/integration/job.ts +2 -0
  70. package/i18n/en-US/components/navigation/integrations.ts +1 -0
  71. package/i18n/en-US/components/settings/external_integration/lti_consumer.ts +2 -0
  72. package/i18n/en-US/pages/admin/index.ts +5 -0
  73. package/i18n/en-US/pages/admin/translateCourse.ts +68 -0
  74. package/i18n/en-US/pages/index.ts +2 -0
  75. package/i18n/es-ES/components/content/blocks/action_panel/index.ts +2 -0
  76. package/i18n/es-ES/components/content/blocks/action_panel/transform_activity.ts +8 -0
  77. package/i18n/es-ES/components/content/blocks/action_panel/transform_block.ts +3 -1
  78. package/i18n/es-ES/components/content/blocks/external_integration/lti_consumer.ts +10 -9
  79. package/i18n/es-ES/components/integration/job.ts +2 -0
  80. package/i18n/es-ES/components/navigation/integrations.ts +1 -0
  81. package/i18n/es-ES/components/settings/external_integration/lti_consumer.ts +3 -0
  82. package/i18n/es-ES/pages/admin/index.ts +5 -0
  83. package/i18n/es-ES/pages/admin/translateCourse.ts +68 -0
  84. package/i18n/es-ES/pages/index.ts +2 -0
  85. package/i18n/fr-FR/components/ai_agent/chat.ts +20 -0
  86. package/i18n/fr-FR/components/ai_agent/index.ts +5 -0
  87. package/i18n/fr-FR/components/content/blocks/action_panel/index.ts +5 -0
  88. package/i18n/fr-FR/components/content/blocks/action_panel/transform_block.ts +9 -0
  89. package/i18n/fr-FR/components/content/blocks/external_integration/index.ts +5 -0
  90. package/i18n/fr-FR/components/content/blocks/external_integration/lti_consumer.ts +17 -0
  91. package/i18n/fr-FR/components/content/blocks/index.ts +7 -0
  92. package/i18n/fr-FR/components/content/index.ts +5 -0
  93. package/i18n/fr-FR/components/external_integration/driver/lti1p1.ts +17 -0
  94. package/i18n/fr-FR/components/external_integration/driver/lti1p3.ts +25 -0
  95. package/i18n/fr-FR/components/external_integration/driver/scorm.ts +14 -0
  96. package/i18n/fr-FR/components/external_integration/index.ts +36 -0
  97. package/i18n/fr-FR/components/external_integration/provider_target.ts +9 -0
  98. package/i18n/fr-FR/components/file_import/index.ts +5 -0
  99. package/i18n/fr-FR/components/file_import/resourcespace.ts +4 -0
  100. package/i18n/fr-FR/components/index.ts +19 -0
  101. package/i18n/fr-FR/components/integration/driver.ts +67 -0
  102. package/i18n/fr-FR/components/integration/index.ts +9 -0
  103. package/i18n/fr-FR/components/integration/job.ts +25 -0
  104. package/i18n/fr-FR/components/integration/job_log.ts +24 -0
  105. package/i18n/fr-FR/components/llm/blooms.ts +15 -0
  106. package/i18n/fr-FR/components/llm/content_selector.ts +3 -0
  107. package/i18n/fr-FR/components/llm/generate_content/fake_text_stream.ts +62 -0
  108. package/i18n/fr-FR/components/llm/generate_content/generate_questions.ts +92 -0
  109. package/i18n/fr-FR/components/llm/generate_content/index.ts +8 -0
  110. package/i18n/fr-FR/components/llm/index.ts +10 -0
  111. package/i18n/fr-FR/components/navigation/index.ts +5 -0
  112. package/i18n/fr-FR/components/navigation/integrations.ts +9 -0
  113. package/i18n/fr-FR/components/settings/external_integration/index.ts +5 -0
  114. package/i18n/fr-FR/components/settings/external_integration/lti_consumer.ts +10 -0
  115. package/i18n/fr-FR/components/settings/index.ts +5 -0
  116. package/i18n/fr-FR/index.ts +16 -0
  117. package/i18n/fr-FR/modules/index.ts +5 -0
  118. package/i18n/fr-FR/pages/admin/index.ts +5 -0
  119. package/i18n/fr-FR/pages/admin/translateCourse.ts +68 -0
  120. package/i18n/fr-FR/pages/course/external_integration/index.ts +6 -0
  121. package/i18n/fr-FR/pages/course/index.ts +5 -0
  122. package/i18n/fr-FR/pages/importContent.ts +3 -0
  123. package/i18n/fr-FR/pages/importCourse.ts +13 -0
  124. package/i18n/fr-FR/pages/index.ts +15 -0
  125. package/i18n/fr-FR/pages/login/index.ts +9 -0
  126. package/i18n/fr-FR/pages/login/lti.ts +23 -0
  127. package/i18n/fr-FR/pages/login/saml.ts +7 -0
  128. package/i18n/fr-FR/pages/login/scorm.ts +28 -0
  129. package/i18n/fr-FR/pages/vendor.ts +11 -0
  130. package/i18n/fr-FR/shared/content_blocks.ts +9 -0
  131. package/i18n/fr-FR/shared/error.ts +9 -0
  132. package/i18n/fr-FR/shared/file.ts +5 -0
  133. package/i18n/fr-FR/shared/index.ts +17 -0
  134. package/i18n/fr-FR/shared/menu.ts +3 -0
  135. package/i18n/fr-FR/shared/notification.ts +14 -0
  136. package/i18n/fr-FR/shared/permission.ts +52 -0
  137. package/i18n/fr-FR/shared/settings.ts +9 -0
  138. package/i18n/index.ts +4 -0
  139. package/i18n/sv-SE/components/content/blocks/action_panel/index.ts +2 -0
  140. package/i18n/sv-SE/components/content/blocks/action_panel/transform_activity.ts +8 -0
  141. package/i18n/sv-SE/components/content/blocks/action_panel/transform_block.ts +3 -1
  142. package/i18n/sv-SE/components/content/blocks/external_integration/lti_consumer.ts +11 -10
  143. package/i18n/sv-SE/components/integration/job.ts +6 -4
  144. package/i18n/sv-SE/components/navigation/integrations.ts +1 -0
  145. package/i18n/sv-SE/components/settings/external_integration/lti_consumer.ts +3 -0
  146. package/i18n/sv-SE/pages/admin/index.ts +5 -0
  147. package/i18n/sv-SE/pages/admin/translateCourse.ts +68 -0
  148. package/i18n/sv-SE/pages/index.ts +2 -0
  149. package/package.json +1 -1
  150. package/pages/admin/translateCourse.vue +81 -0
  151. package/plugin.js +38 -1
@@ -19,7 +19,7 @@
19
19
  </template>
20
20
 
21
21
  <v-list-item
22
- v-for="action in actions"
22
+ v-for="action in availableActions"
23
23
  :key="action.tag"
24
24
  :disabled="isTransforming"
25
25
  @click="performBlockTransform($event, action.tag)"
@@ -38,6 +38,9 @@ import { mapGetters } from 'vuex'
38
38
  import Organization from '~/models/Organization'
39
39
  import Course from '~/models/Course'
40
40
 
41
+ const MIN_SENTENCE_COUNT_FOR_TEXT_TRANSFORM = 4
42
+ const SENTENCES_PER_PARAGRAPH = 2
43
+
41
44
  export default {
42
45
  props: {
43
46
  // The block associated with this action panel
@@ -65,32 +68,13 @@ export default {
65
68
  return {
66
69
  showMenu: false,
67
70
  isTransforming: false,
68
- actions: [
69
- {
70
- tag: 'plugin-core-accordion',
71
- label: this.$t(
72
- 'windward.core.shared.content_blocks.title.accordion'
73
- ),
74
- },
75
- {
76
- tag: 'plugin-core-tab',
77
- label: this.$t(
78
- 'windward.core.shared.content_blocks.title.tab'
79
- ),
80
- },
81
- {
82
- tag: 'plugin-core-clickable-icons',
83
- label: this.$t(
84
- 'windward.core.shared.content_blocks.title.clickable_icons'
85
- ),
86
- },
87
- ],
88
71
  }
89
72
  },
90
73
  computed: {
91
74
  ...mapGetters({
92
75
  organization: 'organization/get',
93
76
  course: 'course/get',
77
+ activeContentBlock: 'contentBlock/get',
94
78
  }),
95
79
  vModel: {
96
80
  get() {
@@ -100,59 +84,812 @@ export default {
100
84
  this.$emit('input', value)
101
85
  },
102
86
  },
103
- canTransformBlock() {
104
- if (!this.value || this.value.tag !== 'content-blocks-text') {
105
- return false
87
+ sourceBlock() {
88
+ if (!this.value) {
89
+ return null
106
90
  }
107
91
 
108
- const body = _.get(this.value, 'body', '')
109
- if (!body || typeof body !== 'string') {
110
- return false
92
+ const active = this.activeContentBlock
93
+ if (
94
+ active &&
95
+ typeof active === 'object' &&
96
+ _.get(active, 'id', null) &&
97
+ _.get(active, 'id', null) === _.get(this.value, 'id', null)
98
+ ) {
99
+ return active
111
100
  }
112
101
 
113
- let paragraphCount = 0
114
- try {
115
- const doc = new DOMParser().parseFromString(body, 'text/html')
116
- const paragraphs = Array.from(doc.querySelectorAll('p, li'))
117
- paragraphCount = paragraphs.filter((el) =>
118
- (el.textContent || '').trim()
119
- ).length
120
- } catch (_e) {
121
- // Fallback: simple split on double line breaks
122
- const normalized = body
123
- .replace(/<[^>]+>/g, '\n')
124
- .split(/\n+/)
125
- .map((p) => p.trim())
126
- .filter((p) => p.length > 0)
127
- paragraphCount = normalized.length
102
+ return this.value
103
+ },
104
+ sourceKind() {
105
+ return this.getBlockKindFromTag(_.get(this.sourceBlock, 'tag', ''))
106
+ },
107
+ textSentenceCount() {
108
+ if (this.sourceKind !== 'text') {
109
+ return 0
110
+ }
111
+ return this.countTextSentences(
112
+ _.get(this.sourceBlock, 'body', ''),
113
+ this.$i18n?.locale
114
+ )
115
+ },
116
+ structuredItemCount() {
117
+ if (
118
+ !['accordion', 'tab', 'clickable_icons'].includes(
119
+ this.sourceKind
120
+ )
121
+ ) {
122
+ return 0
123
+ }
124
+ return this.countStructuredItems(this.sourceBlock)
125
+ },
126
+ availableActions() {
127
+ if (!this.sourceBlock) {
128
+ return []
128
129
  }
129
130
 
130
- return paragraphCount >= 2
131
+ if (this.sourceKind === 'text') {
132
+ if (
133
+ this.textSentenceCount <
134
+ MIN_SENTENCE_COUNT_FOR_TEXT_TRANSFORM
135
+ ) {
136
+ return []
137
+ }
138
+ return [
139
+ {
140
+ tag: 'plugin-core-accordion',
141
+ label: this.$t(
142
+ 'windward.core.shared.content_blocks.title.accordion'
143
+ ),
144
+ },
145
+ {
146
+ tag: 'plugin-core-tab',
147
+ label: this.$t(
148
+ 'windward.core.shared.content_blocks.title.tab'
149
+ ),
150
+ },
151
+ {
152
+ tag: 'plugin-core-clickable-icons',
153
+ label: this.$t(
154
+ 'windward.core.shared.content_blocks.title.clickable_icons'
155
+ ),
156
+ },
157
+ ]
158
+ }
159
+
160
+ if (this.sourceKind === 'accordion') {
161
+ const actions = [
162
+ {
163
+ tag: 'content-blocks-text',
164
+ label: this.$t(
165
+ 'components.content.blocks.text.block_title'
166
+ ),
167
+ },
168
+ ]
169
+ if (this.structuredItemCount >= 2) {
170
+ actions.push(
171
+ {
172
+ tag: 'plugin-core-tab',
173
+ label: this.$t(
174
+ 'windward.core.shared.content_blocks.title.tab'
175
+ ),
176
+ },
177
+ {
178
+ tag: 'plugin-core-clickable-icons',
179
+ label: this.$t(
180
+ 'windward.core.shared.content_blocks.title.clickable_icons'
181
+ ),
182
+ }
183
+ )
184
+ }
185
+ return actions
186
+ }
187
+
188
+ if (this.sourceKind === 'tab') {
189
+ const actions = [
190
+ {
191
+ tag: 'content-blocks-text',
192
+ label: this.$t(
193
+ 'components.content.blocks.text.block_title'
194
+ ),
195
+ },
196
+ ]
197
+ if (this.structuredItemCount >= 2) {
198
+ actions.push(
199
+ {
200
+ tag: 'plugin-core-accordion',
201
+ label: this.$t(
202
+ 'windward.core.shared.content_blocks.title.accordion'
203
+ ),
204
+ },
205
+ {
206
+ tag: 'plugin-core-clickable-icons',
207
+ label: this.$t(
208
+ 'windward.core.shared.content_blocks.title.clickable_icons'
209
+ ),
210
+ }
211
+ )
212
+ }
213
+ return actions
214
+ }
215
+
216
+ if (this.sourceKind === 'clickable_icons') {
217
+ const actions = [
218
+ {
219
+ tag: 'content-blocks-text',
220
+ label: this.$t(
221
+ 'components.content.blocks.text.block_title'
222
+ ),
223
+ },
224
+ ]
225
+ if (this.structuredItemCount >= 2) {
226
+ actions.push(
227
+ {
228
+ tag: 'plugin-core-accordion',
229
+ label: this.$t(
230
+ 'windward.core.shared.content_blocks.title.accordion'
231
+ ),
232
+ },
233
+ {
234
+ tag: 'plugin-core-tab',
235
+ label: this.$t(
236
+ 'windward.core.shared.content_blocks.title.tab'
237
+ ),
238
+ }
239
+ )
240
+ }
241
+ return actions
242
+ }
243
+
244
+ return []
245
+ },
246
+ canTransformBlock() {
247
+ return this.availableActions.length > 0
131
248
  },
132
249
  },
133
250
  methods: {
134
251
  onClickShowTransforms(e) {
135
252
  e.stopPropagation()
136
253
  },
137
- buildTransformedBlock(sourceBlock, data, targetType) {
138
- const baseOrder = _.get(sourceBlock, 'order', 0)
254
+ getBlockKindFromTag(tag) {
255
+ if (!tag || typeof tag !== 'string') {
256
+ return 'unknown'
257
+ }
258
+
259
+ if (tag === 'content-blocks-text') {
260
+ return 'text'
261
+ }
262
+
263
+ const normalized = tag.replace(/^plugin-/, '')
264
+ if (normalized === 'core-accordion') {
265
+ return 'accordion'
266
+ }
267
+ if (normalized === 'core-tab') {
268
+ return 'tab'
269
+ }
270
+ if (normalized === 'core-clickable-icons') {
271
+ return 'clickable_icons'
272
+ }
273
+
274
+ return 'unknown'
275
+ },
276
+ extractTextFromHtml(html) {
277
+ if (!html || typeof html !== 'string') {
278
+ return ''
279
+ }
280
+
281
+ if (typeof DOMParser === 'undefined') {
282
+ return html.replace(/<[^>]+>/g, ' ')
283
+ }
284
+
285
+ try {
286
+ const doc = new DOMParser().parseFromString(html, 'text/html')
287
+ return (doc?.body?.textContent || '').trim()
288
+ } catch (_e) {
289
+ return html.replace(/<[^>]+>/g, ' ')
290
+ }
291
+ },
292
+ segmentSentences(text, locale) {
293
+ const input = typeof text === 'string' ? text : ''
294
+ if (!input.trim()) {
295
+ return []
296
+ }
297
+
298
+ try {
299
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
300
+ const segmenter = new Intl.Segmenter(locale || undefined, {
301
+ granularity: 'sentence',
302
+ })
303
+ const rawSegments = Array.from(segmenter.segment(input))
304
+
305
+ return rawSegments
306
+ .map((seg, idx) => {
307
+ const start = Number(seg.index || 0)
308
+ const end =
309
+ idx + 1 < rawSegments.length
310
+ ? Number(rawSegments[idx + 1].index || 0)
311
+ : input.length
312
+ return {
313
+ start,
314
+ end,
315
+ text: input.slice(start, end),
316
+ }
317
+ })
318
+ .filter((seg) => seg.text.trim().length > 0)
319
+ }
320
+ } catch (_e) {
321
+ // Ignore and fall back to regex
322
+ }
323
+
324
+ // Fallback sentence splitter (best-effort)
325
+ const re = /[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g
326
+ const segments = []
327
+ let match = null
328
+ while ((match = re.exec(input)) !== null) {
329
+ const value = match[0] || ''
330
+ if (!value.trim()) {
331
+ continue
332
+ }
333
+ segments.push({
334
+ start: match.index,
335
+ end: match.index + value.length,
336
+ text: value,
337
+ })
338
+ }
339
+ return segments
340
+ },
341
+ countTextSentences(html, locale) {
342
+ const text = this.extractTextFromHtml(html)
343
+ return this.segmentSentences(text, locale).length
344
+ },
345
+ countTopLevelParagraphs(html) {
346
+ if (!html || typeof html !== 'string') {
347
+ return 0
348
+ }
349
+
350
+ if (typeof DOMParser === 'undefined') {
351
+ return 0
352
+ }
353
+
354
+ try {
355
+ const doc = new DOMParser().parseFromString(html, 'text/html')
356
+ const body = doc?.body
357
+ if (!body) {
358
+ return 0
359
+ }
360
+
361
+ const paragraphs = Array.from(body.children).filter((el) => {
362
+ const tag = (el.tagName || '').toLowerCase()
363
+ if (tag !== 'p' && tag !== 'li') {
364
+ return false
365
+ }
366
+ return (el.textContent || '').trim().length > 0
367
+ })
368
+
369
+ return paragraphs.length
370
+ } catch (_e) {
371
+ return 0
372
+ }
373
+ },
374
+ resolveTextOffset(textNodes, targetOffset) {
375
+ let offset = Math.max(0, Math.trunc(Number(targetOffset) || 0))
376
+
377
+ for (const node of textNodes) {
378
+ const len = (node.nodeValue || '').length
379
+ if (offset <= len) {
380
+ return { node, offset }
381
+ }
382
+ offset -= len
383
+ }
384
+
385
+ const last = textNodes[textNodes.length - 1]
386
+ return { node: last, offset: (last?.nodeValue || '').length }
387
+ },
388
+ splitHtmlIntoParagraphs(html, locale) {
389
+ if (!html || typeof html !== 'string') {
390
+ return null
391
+ }
392
+
393
+ if (typeof DOMParser === 'undefined') {
394
+ return null
395
+ }
396
+
397
+ const doc = new DOMParser().parseFromString(html, 'text/html')
398
+ const body = doc?.body
399
+ if (!body) {
400
+ return null
401
+ }
402
+
403
+ const hasMeaningfulTextOutsideElements = Array.from(
404
+ body.childNodes
405
+ ).some(
406
+ (node) =>
407
+ node.nodeType === Node.TEXT_NODE &&
408
+ (node.textContent || '').trim()
409
+ )
410
+ const elementChildren = Array.from(body.children)
411
+
412
+ let container = null
413
+ if (
414
+ elementChildren.length === 1 &&
415
+ !hasMeaningfulTextOutsideElements
416
+ ) {
417
+ container = elementChildren[0]
418
+ } else if (
419
+ elementChildren.length === 0 &&
420
+ (body.textContent || '').trim()
421
+ ) {
422
+ container = body
423
+ } else {
424
+ return null
425
+ }
426
+
427
+ const containerTag =
428
+ container === body
429
+ ? 'body'
430
+ : (container.tagName || '').toLowerCase()
431
+ const disallowedContainers = new Set([
432
+ 'ul',
433
+ 'ol',
434
+ 'table',
435
+ 'tbody',
436
+ 'thead',
437
+ 'tr',
438
+ 'td',
439
+ 'th',
440
+ ])
441
+ if (disallowedContainers.has(containerTag)) {
442
+ return null
443
+ }
444
+
445
+ // Avoid creating nested paragraphs by splitting inside containers that already have paragraphs
446
+ if (container !== body && containerTag !== 'p') {
447
+ if (
448
+ typeof container.querySelector === 'function' &&
449
+ container.querySelector('p')
450
+ ) {
451
+ return null
452
+ }
453
+ }
454
+
455
+ const text = container.textContent || ''
456
+ const sentences = this.segmentSentences(text, locale)
457
+ if (sentences.length < MIN_SENTENCE_COUNT_FOR_TEXT_TRANSFORM) {
458
+ return null
459
+ }
460
+
461
+ const ranges = []
462
+ for (
463
+ let i = 0;
464
+ i < sentences.length;
465
+ i += SENTENCES_PER_PARAGRAPH
466
+ ) {
467
+ const start = sentences[i].start
468
+ const end =
469
+ sentences[
470
+ Math.min(
471
+ i + SENTENCES_PER_PARAGRAPH - 1,
472
+ sentences.length - 1
473
+ )
474
+ ].end
475
+ ranges.push({ start, end })
476
+ }
477
+
478
+ const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT)
479
+ const textNodes = []
480
+ let current = walker.nextNode()
481
+ while (current) {
482
+ if ((current.nodeValue || '').length > 0) {
483
+ textNodes.push(current)
484
+ }
485
+ current = walker.nextNode()
486
+ }
487
+ if (textNodes.length === 0) {
488
+ return null
489
+ }
490
+
491
+ const paragraphs = []
492
+ for (const { start, end } of ranges) {
493
+ const startPos = this.resolveTextOffset(textNodes, start)
494
+ const endPos = this.resolveTextOffset(textNodes, end)
495
+ if (!startPos?.node || !endPos?.node) {
496
+ continue
497
+ }
498
+
499
+ const range = doc.createRange()
500
+ range.setStart(startPos.node, startPos.offset)
501
+ range.setEnd(endPos.node, endPos.offset)
502
+
503
+ const fragment = range.cloneContents()
504
+ const p = doc.createElement('p')
505
+ p.appendChild(fragment)
506
+
507
+ if ((p.textContent || '').trim().length > 0) {
508
+ paragraphs.push(p)
509
+ }
510
+ }
511
+
512
+ if (paragraphs.length < 2) {
513
+ return null
514
+ }
515
+
516
+ body.innerHTML = ''
517
+ paragraphs.forEach((p) => body.appendChild(p))
518
+ return body.innerHTML
519
+ },
520
+ prepareHtmlForBlockTransform(html, locale) {
521
+ if (!html || typeof html !== 'string') {
522
+ throw new Error('invalid_html')
523
+ }
524
+
525
+ const sentenceCount = this.countTextSentences(html, locale)
526
+ if (sentenceCount < MIN_SENTENCE_COUNT_FOR_TEXT_TRANSFORM) {
527
+ throw new Error('insufficient_content')
528
+ }
529
+
530
+ // If the microservice will see enough top-level paragraphs, don't touch it.
531
+ if (this.countTopLevelParagraphs(html) >= 2) {
532
+ return html
533
+ }
534
+
535
+ // Unwrap a single container (common when editors wrap content)
536
+ if (typeof DOMParser !== 'undefined') {
537
+ try {
538
+ let currentHtml = html
539
+ for (let i = 0; i < 2; i++) {
540
+ if (this.countTopLevelParagraphs(currentHtml) >= 2) {
541
+ return currentHtml
542
+ }
543
+
544
+ const doc = new DOMParser().parseFromString(
545
+ currentHtml,
546
+ 'text/html'
547
+ )
548
+ const body = doc?.body
549
+ if (!body) {
550
+ break
551
+ }
552
+
553
+ const hasMeaningfulTextOutsideElements = Array.from(
554
+ body.childNodes
555
+ ).some(
556
+ (node) =>
557
+ node.nodeType === Node.TEXT_NODE &&
558
+ (node.textContent || '').trim()
559
+ )
560
+ const elementChildren = Array.from(body.children)
561
+
562
+ if (
563
+ elementChildren.length === 1 &&
564
+ !hasMeaningfulTextOutsideElements
565
+ ) {
566
+ const container = elementChildren[0]
567
+ const tag = (container.tagName || '').toLowerCase()
568
+ if (
569
+ tag === 'div' ||
570
+ tag === 'section' ||
571
+ tag === 'article'
572
+ ) {
573
+ currentHtml = container.innerHTML || ''
574
+ continue
575
+ }
576
+ }
577
+
578
+ break
579
+ }
580
+
581
+ const splitHtml = this.splitHtmlIntoParagraphs(
582
+ currentHtml,
583
+ locale
584
+ )
585
+ if (
586
+ splitHtml &&
587
+ this.countTopLevelParagraphs(splitHtml) >= 2
588
+ ) {
589
+ return splitHtml
590
+ }
591
+ } catch (_e) {
592
+ // Ignore and fall through to error
593
+ }
594
+ }
595
+
596
+ throw new Error('insufficient_content')
597
+ },
598
+ countStructuredItems(block) {
599
+ const items = _.get(block, 'metadata.config.items', [])
600
+ return Array.isArray(items) ? items.length : 0
601
+ },
602
+ escapeHtml(text) {
603
+ return String(text == null ? '' : text)
604
+ .replace(/&/g, '&amp;')
605
+ .replace(/</g, '&lt;')
606
+ .replace(/>/g, '&gt;')
607
+ .replace(/"/g, '&quot;')
608
+ .replace(/'/g, '&#39;')
609
+ },
610
+ normalizeAssetReference(asset) {
611
+ if (!asset) {
612
+ return null
613
+ }
614
+
615
+ if (typeof asset === 'string') {
616
+ return { file_asset_id: asset }
617
+ }
618
+
619
+ if (typeof asset === 'object') {
620
+ return asset
621
+ }
622
+
623
+ return null
624
+ },
625
+ normalizeImageConfig(rawConfig) {
626
+ if (!rawConfig || typeof rawConfig !== 'object') {
627
+ return null
628
+ }
629
+
630
+ const config = _.cloneDeep(rawConfig)
631
+ if (typeof config.asset === 'string') {
632
+ config.asset = this.normalizeAssetReference(config.asset)
633
+ } else if (config.asset && typeof config.asset === 'object') {
634
+ config.asset = this.normalizeAssetReference(config.asset)
635
+ }
636
+
637
+ return config
638
+ },
639
+ hasAssetInImageConfig(imageConfig) {
640
+ return !!_.get(imageConfig, 'asset.file_asset_id', null)
641
+ },
642
+ buildNewBlockBase(sourceBlock) {
643
+ const baseOrderRaw = _.get(sourceBlock, 'order', 0)
644
+ const baseOrder = Number(baseOrderRaw)
645
+ const normalizedOrder = Number.isFinite(baseOrder)
646
+ ? Math.trunc(baseOrder)
647
+ : 0
648
+
139
649
  const newBlock = {
140
650
  id: _.uniqueId('create_content_block_'),
141
651
  content_id: null,
142
652
  tag: '',
143
653
  body: '',
144
654
  status: 'draft',
145
- order: baseOrder + 0.5,
655
+ order: normalizedOrder,
146
656
  type: {
147
657
  save_state: false,
148
658
  trackable: false,
149
659
  completable: false,
150
660
  },
661
+ assets: _.cloneDeep(_.get(sourceBlock, 'assets', [])),
151
662
  metadata: {
152
663
  config: {},
153
664
  },
154
665
  }
155
666
 
667
+ const display = _.cloneDeep(
668
+ _.get(sourceBlock, 'metadata.display', null)
669
+ )
670
+ if (display) {
671
+ newBlock.metadata.display = display
672
+ }
673
+
674
+ return newBlock
675
+ },
676
+ extractStructuredBlockData(sourceBlock) {
677
+ const kind = this.getBlockKindFromTag(_.get(sourceBlock, 'tag', ''))
678
+ const config = _.get(sourceBlock, 'metadata.config', {}) || {}
679
+
680
+ const title = typeof config.title === 'string' ? config.title : ''
681
+ const instructions =
682
+ typeof config.instructions === 'string'
683
+ ? config.instructions
684
+ : ''
685
+ const displayTitle = _.isBoolean(config.display_title)
686
+ ? config.display_title
687
+ : true
688
+
689
+ const rawItems = Array.isArray(config.items) ? config.items : []
690
+ const items = rawItems.map((rawItem) => {
691
+ if (!rawItem || typeof rawItem !== 'object') {
692
+ return { title: '', bodyHtml: '', imageConfig: null }
693
+ }
694
+
695
+ if (kind === 'accordion') {
696
+ return {
697
+ title:
698
+ typeof rawItem.header === 'string'
699
+ ? rawItem.header
700
+ : '',
701
+ bodyHtml:
702
+ typeof rawItem.content === 'string'
703
+ ? rawItem.content
704
+ : '',
705
+ imageConfig: this.normalizeImageConfig(
706
+ rawItem.fileConfig
707
+ ),
708
+ }
709
+ }
710
+
711
+ if (kind === 'tab') {
712
+ return {
713
+ title:
714
+ typeof rawItem.tabHeader === 'string'
715
+ ? rawItem.tabHeader
716
+ : '',
717
+ bodyHtml:
718
+ typeof rawItem.content === 'string'
719
+ ? rawItem.content
720
+ : '',
721
+ imageConfig: this.normalizeImageConfig(
722
+ rawItem.imageAsset
723
+ ),
724
+ }
725
+ }
726
+
727
+ if (kind === 'clickable_icons') {
728
+ const fileConfig = this.normalizeImageConfig(
729
+ rawItem.fileConfig
730
+ )
731
+ const iconImage = rawItem.iconImage === true
732
+
733
+ return {
734
+ title:
735
+ typeof rawItem.title === 'string'
736
+ ? rawItem.title
737
+ : '',
738
+ bodyHtml:
739
+ typeof rawItem.body === 'string'
740
+ ? rawItem.body
741
+ : '',
742
+ imageConfig:
743
+ iconImage || this.hasAssetInImageConfig(fileConfig)
744
+ ? fileConfig
745
+ : null,
746
+ }
747
+ }
748
+
749
+ return { title: '', bodyHtml: '', imageConfig: null }
750
+ })
751
+
752
+ return {
753
+ kind,
754
+ title,
755
+ instructions,
756
+ displayTitle,
757
+ items,
758
+ }
759
+ },
760
+ buildTextBlockFromStructured(sourceBlock) {
761
+ const structured = this.extractStructuredBlockData(sourceBlock)
762
+ const parts = []
763
+
764
+ if (structured.displayTitle && structured.title) {
765
+ parts.push(`<h2>${this.escapeHtml(structured.title)}</h2>`)
766
+ }
767
+ if (structured.instructions) {
768
+ parts.push(`<p>${this.escapeHtml(structured.instructions)}</p>`)
769
+ }
770
+
771
+ structured.items.forEach((item) => {
772
+ const itemTitle =
773
+ typeof item.title === 'string' ? item.title : ''
774
+ const bodyHtml =
775
+ typeof item.bodyHtml === 'string' ? item.bodyHtml : ''
776
+
777
+ if (itemTitle) {
778
+ parts.push(`<h3>${this.escapeHtml(itemTitle)}</h3>`)
779
+ }
780
+ if (bodyHtml) {
781
+ parts.push(bodyHtml)
782
+ }
783
+ })
784
+
785
+ const newBlock = this.buildNewBlockBase(sourceBlock)
786
+ newBlock.tag = 'content-blocks-text'
787
+ newBlock.body = parts.join('')
788
+ newBlock.metadata.config = { expand: false }
789
+ return newBlock
790
+ },
791
+ buildStructuredBlockFromStructured(sourceBlock, targetType) {
792
+ const structured = this.extractStructuredBlockData(sourceBlock)
793
+ const newBlock = this.buildNewBlockBase(sourceBlock)
794
+
795
+ if (targetType === 'plugin-core-accordion') {
796
+ newBlock.tag = 'plugin-core-accordion'
797
+ newBlock.body = this.$t(
798
+ 'windward.core.shared.content_blocks.title.accordion'
799
+ )
800
+ newBlock.metadata.config = {
801
+ title: structured.title,
802
+ display_title: structured.displayTitle,
803
+ instructions: structured.instructions,
804
+ items: structured.items.map((item) => ({
805
+ header: item.title || '',
806
+ expand: false,
807
+ content: item.bodyHtml || '',
808
+ fileConfig: item.imageConfig || {
809
+ display: {
810
+ width: 100,
811
+ margin: '',
812
+ padding: '',
813
+ },
814
+ hideBackground: true,
815
+ },
816
+ })),
817
+ }
818
+ return newBlock
819
+ }
820
+
821
+ if (targetType === 'plugin-core-tab') {
822
+ newBlock.tag = 'plugin-core-tab'
823
+ newBlock.body = this.$t(
824
+ 'windward.core.shared.content_blocks.title.tab'
825
+ )
826
+ newBlock.metadata.config = {
827
+ title: structured.title,
828
+ display_title: structured.displayTitle,
829
+ instructions: structured.instructions,
830
+ currentTab: 0,
831
+ items: structured.items.map((item) => ({
832
+ tabHeader: item.title || '',
833
+ expand: false,
834
+ content: item.bodyHtml || '',
835
+ imageAsset: item.imageConfig || {
836
+ display: {
837
+ width: 100,
838
+ margin: '',
839
+ padding: '',
840
+ },
841
+ hideBackground: true,
842
+ },
843
+ })),
844
+ }
845
+ return newBlock
846
+ }
847
+
848
+ if (targetType === 'plugin-core-clickable-icons') {
849
+ newBlock.tag = 'plugin-core-clickable-icons'
850
+ newBlock.body = this.$t(
851
+ 'windward.core.shared.content_blocks.title.clickable_icons'
852
+ )
853
+ newBlock.metadata.config = {
854
+ title: structured.title,
855
+ display_title: structured.displayTitle,
856
+ instructions: structured.instructions,
857
+ description: this.$t(
858
+ 'windward.core.components.settings.clickable_icon.information'
859
+ ),
860
+ display: {
861
+ show_title: false,
862
+ show_background: false,
863
+ round_icon: false,
864
+ italic_icon: false,
865
+ large_icon: false,
866
+ autocolor: true,
867
+ },
868
+ items: structured.items.map((item) => {
869
+ const imageConfig = item.imageConfig
870
+ const hasAsset = this.hasAssetInImageConfig(imageConfig)
871
+
872
+ return {
873
+ icon: '',
874
+ fileConfig: hasAsset ? imageConfig : {},
875
+ iconImage: hasAsset,
876
+ title: item.title || '',
877
+ body: item.bodyHtml || '',
878
+ color: {
879
+ class: '',
880
+ },
881
+ active: false,
882
+ }
883
+ }),
884
+ }
885
+ return newBlock
886
+ }
887
+
888
+ throw new Error(`Unsupported targetType: ${targetType}`)
889
+ },
890
+ buildTransformedBlock(sourceBlock, data, targetType) {
891
+ const newBlock = this.buildNewBlockBase(sourceBlock)
892
+
156
893
  const blockTitle = data.block_title || ''
157
894
  const items = Array.isArray(data.items) ? data.items : []
158
895
 
@@ -219,15 +956,15 @@ export default {
219
956
  'windward.core.components.settings.clickable_icon.information'
220
957
  ),
221
958
  display: {
222
- show_title: false,
223
- show_background: false,
959
+ show_title: true,
960
+ show_background: true,
224
961
  round_icon: false,
225
962
  italic_icon: false,
226
963
  large_icon: false,
227
964
  autocolor: true,
228
965
  },
229
966
  items: items.map((item) => ({
230
- icon: '',
967
+ icon: 'mdi-star',
231
968
  fileConfig: {},
232
969
  iconImage: false,
233
970
  title: item.title || '',
@@ -242,30 +979,18 @@ export default {
242
979
 
243
980
  return newBlock
244
981
  },
245
- async performBlockTransform(e, targetType) {
246
- e.stopPropagation()
247
-
248
- const html = _.get(this.value, 'body', '')
249
- if (!html || typeof html !== 'string') {
250
- return
251
- }
982
+ async generateMultiItemBlockFromText(targetType) {
983
+ const sourceBlock = this.sourceBlock
984
+ const html = _.get(sourceBlock, 'body', '')
985
+ const locale = this.$i18n?.locale
252
986
 
253
- if (this.isTransforming) {
254
- return
255
- }
987
+ const preparedHtml = this.prepareHtmlForBlockTransform(html, locale)
256
988
 
257
989
  const organizationId = _.get(this.organization, 'id', null)
258
990
  const courseId = _.get(this.course, 'id', null)
259
991
 
260
992
  if (!organizationId || !courseId) {
261
- if (this.$toast) {
262
- this.$toast.error(
263
- this.$t(
264
- 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_error'
265
- )
266
- )
267
- }
268
- return
993
+ throw new Error('missing_context')
269
994
  }
270
995
 
271
996
  const request = new Course()
@@ -277,55 +1002,76 @@ export default {
277
1002
 
278
1003
  const resourcePath = request._customResource
279
1004
  if (!resourcePath) {
280
- if (this.$toast) {
281
- this.$toast.error(
282
- this.$t(
283
- 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_error'
284
- )
285
- )
286
- }
287
- return
1005
+ throw new Error('missing_resource')
288
1006
  }
289
1007
 
290
1008
  const payload = {
291
- html,
1009
+ html: preparedHtml,
292
1010
  target_tag: targetType,
293
- language: this.$i18n.locale,
1011
+ language: locale,
1012
+ }
1013
+
1014
+ const requestConfig = request._reqConfig(
1015
+ {
1016
+ method: 'POST',
1017
+ url: `${request.baseURL()}/${resourcePath}`,
1018
+ data: payload,
1019
+ },
1020
+ { forceMethod: true }
1021
+ )
1022
+
1023
+ const response = await request.request(requestConfig)
1024
+ const data = response?.data || response
1025
+
1026
+ if (!data || !Array.isArray(data.items) || data.items.length < 2) {
1027
+ throw new Error('invalid_response')
1028
+ }
1029
+
1030
+ return this.buildTransformedBlock(sourceBlock, data, targetType)
1031
+ },
1032
+ async performBlockTransform(e, targetType) {
1033
+ e.stopPropagation()
1034
+
1035
+ if (this.isTransforming) {
1036
+ return
1037
+ }
1038
+
1039
+ const sourceBlock = this.sourceBlock
1040
+
1041
+ if (!sourceBlock || !sourceBlock.tag) {
1042
+ return
294
1043
  }
295
1044
 
296
1045
  this.isTransforming = true
297
1046
 
298
1047
  try {
299
- const requestConfig = request._reqConfig(
300
- {
301
- method: 'POST',
302
- url: `${request.baseURL()}/${resourcePath}`,
303
- data: payload,
304
- },
305
- { forceMethod: true }
306
- )
307
-
308
- const response = await request.request(requestConfig)
309
- const data = response?.data || response
1048
+ let newBlock = null
310
1049
 
311
- if (
312
- !data ||
313
- !Array.isArray(data.items) ||
314
- data.items.length === 0
315
- ) {
316
- throw new Error('invalid_response')
1050
+ if (this.sourceKind === 'text') {
1051
+ if (targetType === 'content-blocks-text') {
1052
+ return
1053
+ }
1054
+ newBlock = await this.generateMultiItemBlockFromText(
1055
+ targetType
1056
+ )
1057
+ } else if (targetType === 'content-blocks-text') {
1058
+ newBlock = this.buildTextBlockFromStructured(sourceBlock)
1059
+ } else {
1060
+ if (this.countStructuredItems(sourceBlock) < 2) {
1061
+ throw new Error('insufficient_items')
1062
+ }
1063
+ newBlock = this.buildStructuredBlockFromStructured(
1064
+ sourceBlock,
1065
+ targetType
1066
+ )
317
1067
  }
318
1068
 
319
- const newBlock = this.buildTransformedBlock(
320
- this.value,
321
- data,
322
- targetType
323
- )
1069
+ if (!newBlock) {
1070
+ throw new Error('invalid_new_block')
1071
+ }
324
1072
 
325
- // Let the host content page own block insertion via the global event
326
1073
  this.$eb.$emit('create:content-block', newBlock)
327
1074
 
328
- // Immediately focus the new block for the user
329
1075
  const activeBlock = _.cloneDeep(newBlock)
330
1076
  this.$ContentService.setContentBlock(activeBlock)
331
1077
  this.$eb.$emit('block:focus', activeBlock)
@@ -333,12 +1079,36 @@ export default {
333
1079
  // Surface errors instead of failing silently
334
1080
  // eslint-disable-next-line no-console
335
1081
  console.error('Block transform failed', e)
1082
+
1083
+ const reason = _.get(
1084
+ e,
1085
+ 'response.data.error.details.reason',
1086
+ ''
1087
+ )
1088
+
336
1089
  if (this.$toast) {
337
- this.$toast.error(
338
- this.$t(
339
- 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_error'
1090
+ if (
1091
+ String(e?.message) === 'insufficient_content' ||
1092
+ reason === 'NOT_ENOUGH_PARAGRAPHS'
1093
+ ) {
1094
+ this.$toast.error(
1095
+ this.$t(
1096
+ 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_insufficient_paragraphs'
1097
+ )
340
1098
  )
341
- )
1099
+ } else if (String(e?.message) === 'insufficient_items') {
1100
+ this.$toast.error(
1101
+ this.$t(
1102
+ 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_insufficient_items'
1103
+ )
1104
+ )
1105
+ } else {
1106
+ this.$toast.error(
1107
+ this.$t(
1108
+ 'windward.integrations.components.content.blocks.action_panel.transform_block.change_block_type_error'
1109
+ )
1110
+ )
1111
+ }
342
1112
  }
343
1113
  } finally {
344
1114
  this.isTransforming = false