@windward/core 0.27.0 → 0.29.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.
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :class="blockDirectionClasses" :dir="blockTextDirection">
3
3
  <h2
4
4
  v-if="
5
5
  block.metadata.config.title &&
@@ -13,7 +13,7 @@
13
13
  {{ block.metadata.config.instructions }}
14
14
  </p>
15
15
 
16
- <div v-if="hasSource" class="video-wrapper">
16
+ <div v-if="hasSource" class="video-wrapper" dir="ltr">
17
17
  <VuetifyPlayer
18
18
  class="no-border-player"
19
19
  :language="$i18n && $i18n.locale ? $i18n.locale : 'en-US'"
@@ -146,6 +146,124 @@ export default {
146
146
  }
147
147
  },
148
148
  computed: {
149
+ courseSourceLocale() {
150
+ // Get the source language from translation metadata
151
+ // This is the original language the course was translated FROM
152
+ // For translated courses: metadata.translation.source_language (e.g., "EN")
153
+ // For non-translated courses: falls back to locale code
154
+
155
+ // First check translation metadata for source_language (e.g., "EN", "ES")
156
+ const translationSourceLang = _.get(this.block, 'course.metadata.translation.source_language') ||
157
+ this.$store?.getters?.['course/get']?.metadata?.translation?.source_language
158
+ if (translationSourceLang) {
159
+ return translationSourceLang.toLowerCase()
160
+ }
161
+
162
+ // Try source_locale_id lookup
163
+ const translationSourceLocale = _.get(this.block, 'course.metadata.translation.source_locale_id')
164
+ if (translationSourceLocale) {
165
+ const sourceLocale = this.$store?.getters?.['locales/getById']?.(translationSourceLocale)
166
+ if (sourceLocale?.code) {
167
+ return sourceLocale.code.toLowerCase()
168
+ }
169
+ }
170
+
171
+ return (
172
+ _.get(this.block, 'course.locale.code')?.toLowerCase() ||
173
+ this.$store?.getters?.['course/sourceLocale']?.code?.toLowerCase() ||
174
+ 'en-us'
175
+ )
176
+ },
177
+ courseTargetLocale() {
178
+ // Get the target language from translation metadata
179
+ // This is the language the course was translated INTO
180
+ // For translated courses: metadata.translation.target_language (e.g., "PT-BR", "ES")
181
+
182
+ const translationTargetLang = _.get(this.block, 'course.metadata.translation.target_language') ||
183
+ this.$store?.getters?.['course/get']?.metadata?.translation?.target_language
184
+ if (translationTargetLang) {
185
+ return translationTargetLang.toLowerCase()
186
+ }
187
+
188
+ // Try target_locale_id lookup
189
+ const translationTargetLocale = _.get(this.block, 'course.metadata.translation.target_locale_id')
190
+ if (translationTargetLocale) {
191
+ const targetLocale = this.$store?.getters?.['locales/getById']?.(translationTargetLocale)
192
+ if (targetLocale?.code) {
193
+ return targetLocale.code.toLowerCase()
194
+ }
195
+ }
196
+
197
+ // No target language - course is not translated
198
+ return null
199
+ },
200
+ courseCurrentLocale() {
201
+ // Get the current course's locale code (the language this course is in)
202
+ // For a translated course, this is the target language
203
+ // Priority: translation target_language > block.course.locale > store getters > fallback
204
+
205
+ // For translated courses, use target_language directly
206
+ if (this.courseTargetLocale) {
207
+ return this.courseTargetLocale
208
+ }
209
+
210
+ const localeCode =
211
+ _.get(this.block, 'course.locale.code')?.toLowerCase() ||
212
+ _.get(this.block, 'course.locale.name')?.toLowerCase() ||
213
+ this.$store?.getters?.['course/localeCode']?.toLowerCase() ||
214
+ this.$store?.getters?.['course/locale']?.code?.toLowerCase() ||
215
+ this.$store?.getters?.['course/locale']?.name?.toLowerCase() ||
216
+ this.$store?.state?.course?.course?.locale?.code?.toLowerCase() ||
217
+ this.$store?.state?.course?.course?.locale?.name?.toLowerCase()
218
+
219
+ return localeCode || this.courseSourceLocale
220
+ },
221
+ isTranslatedCourse() {
222
+ // Check if this course is a translated course (has translation metadata)
223
+ return !!(
224
+ _.get(this.block, 'course.metadata.translation') ||
225
+ this.$store?.getters?.['course/get']?.metadata?.translation
226
+ )
227
+ },
228
+ courseTargetLocaleId() {
229
+ // Get the target_locale_id from translation metadata (UUID)
230
+ // This is used to match VTT files which are linked by locale_id
231
+ return _.get(this.block, 'course.metadata.translation.target_locale_id') ||
232
+ this.$store?.getters?.['course/get']?.metadata?.translation?.target_locale_id ||
233
+ _.get(this.block, 'course.locale_id') ||
234
+ this.$store?.getters?.['course/get']?.locale_id ||
235
+ null
236
+ },
237
+ allowedCaptionLocales() {
238
+ // Only show captions for the source language and the current course's language
239
+ // This prevents showing all translated captions (FR, ES, DE, etc.) when viewing a specific course
240
+ const locales = new Set()
241
+
242
+ // Helper to add both full locale and base language code
243
+ const addLocale = (locale) => {
244
+ if (!locale) return
245
+ locales.add(locale)
246
+ locales.add(locale.toLowerCase())
247
+ // Also add base language code (e.g., "en" from "en-US", "fr" from "fr-FR")
248
+ const base = locale.split('-')[0]?.toLowerCase()
249
+ if (base) {
250
+ locales.add(base)
251
+ }
252
+ }
253
+
254
+ // Always include the source language (original English captions)
255
+ addLocale(this.courseSourceLocale)
256
+
257
+ // Include the current course's language
258
+ addLocale(this.courseCurrentLocale)
259
+
260
+ // Also include tracks with no locale (legacy/default tracks)
261
+ locales.add(null)
262
+ locales.add(undefined)
263
+ locales.add('')
264
+
265
+ return locales
266
+ },
149
267
  hasSource() {
150
268
  // __isDirty is used to communicate from the settings panel that a source / caption
151
269
  // has changed and to reload the video player
@@ -175,12 +293,38 @@ export default {
175
293
  let file = this.resolveAsset(
176
294
  playlist[index].sources[sourceIndex]
177
295
  )
178
- if (
179
- !_.isEmpty(_.get(file, 'asset.metadata.notes')) &&
180
- //check the file notes are actually text and not an empty div
181
- this.hasWords(_.get(file, 'asset.metadata.notes'))
182
- ) {
183
- result.push(_.get(file, 'asset.metadata.notes'))
296
+
297
+ // Try to get translated notes from VTT file (for translated courses)
298
+ let notes = null
299
+ if (this.isTranslatedCourse) {
300
+ const linkedCaptions = this.getAllLinkedCaptions(file)
301
+ // Find VTT matching by locale_id (UUID) or locale.code (language code)
302
+ const currentVtt = _.find(linkedCaptions, vtt => {
303
+ // First try matching by locale_id (most reliable)
304
+ if (this.courseTargetLocaleId && vtt.locale_id === this.courseTargetLocaleId) {
305
+ return true
306
+ }
307
+ // Also try matching by locale.code
308
+ const vttLocale = _.get(vtt, 'locale.code', '').toLowerCase()
309
+ return vttLocale && this.isAllowedCaptionLanguage(vttLocale) &&
310
+ vttLocale === this.courseCurrentLocale
311
+ })
312
+ // Notes can be in different locations:
313
+ // - vtt.metadata.notes (FileAssetMap metadata)
314
+ // - vtt.notes (FileAssetMapResource exposes this)
315
+ // - vtt.asset.metadata.notes (FileAsset metadata - where translation stores them)
316
+ notes = _.get(currentVtt, 'metadata.notes') ||
317
+ _.get(currentVtt, 'notes') ||
318
+ _.get(currentVtt, 'asset.metadata.notes')
319
+ }
320
+
321
+ // Fallback to video asset notes
322
+ if (!notes || !this.hasWords(notes)) {
323
+ notes = _.get(file, 'asset.metadata.notes')
324
+ }
325
+
326
+ if (notes && this.hasWords(notes)) {
327
+ result.push(notes)
184
328
  }
185
329
  }
186
330
  }
@@ -192,36 +336,124 @@ export default {
192
336
  linkedPlaylist() {
193
337
  const playlist = _.cloneDeep(this.block.metadata.config.playlist)
194
338
  for (const index in playlist) {
339
+ // Build comprehensive track list from multiple sources
340
+ const allTracks = []
341
+ const addedSrcs = new Set() // Track added URLs to avoid duplicates
342
+ const addedLangs = new Set() // Track added languages to avoid duplicate srclang keys
343
+
344
+ // 1. FIRST: Resolve video files and get linked captions BEFORE transforming sources
345
+ const resolvedVideoFiles = []
195
346
  for (const sourceIndex in playlist[index].sources) {
196
- let file = this.resolveAsset(
347
+ let videoFile = this.resolveAsset(
197
348
  playlist[index].sources[sourceIndex]
198
349
  )
350
+ resolvedVideoFiles.push(videoFile)
199
351
 
200
- playlist[index].sources[sourceIndex] = {
201
- src: _.get(file, 'asset.public_url', ''),
202
- type: _.get(file, 'asset.metadata.mime', ''),
203
- }
352
+ // Get ALL linked captions from this video file (adds to block.assets)
353
+ const linkedCaptions = this.getAllLinkedCaptions(videoFile)
204
354
 
205
- // If there's linked captions and there's no hard-set captions
206
- // Fallback to the linked captions
207
- const linkedCaptions = this.getLinkedCaptions(file)
208
- if (playlist[index].tracks.length === 0 && linkedCaptions) {
209
- playlist[index].tracks.push(linkedCaptions)
355
+ for (const vtt of linkedCaptions) {
356
+ // Get the src - may be directly available or need to extract from asset
357
+ const src = _.get(vtt, 'asset.public_url', '')
358
+
359
+ // Skip if no src or already added
360
+ if (!src || addedSrcs.has(src)) {
361
+ continue
362
+ }
363
+
364
+ const srclang = _.get(vtt, 'locale.code') || this.courseSourceLocale
365
+
366
+ // Skip if this language was already added (avoid duplicate keys)
367
+ if (addedLangs.has(srclang)) {
368
+ continue
369
+ }
370
+
371
+ // FILTER: Only include tracks that match the course's language(s)
372
+ // - Original courses: show only the course's own language
373
+ // - Translated courses: show source language + target language
374
+ if (!this.isAllowedCaptionLanguage(srclang)) {
375
+ continue
376
+ }
377
+
378
+ const label = this.getLabelForLanguage(srclang) || _.get(vtt, 'locale.name') || srclang
379
+
380
+ allTracks.push({
381
+ src: src,
382
+ kind: 'captions',
383
+ srclang: srclang,
384
+ label: label,
385
+ default: false,
386
+ })
387
+ addedSrcs.add(src)
388
+ addedLangs.add(srclang)
210
389
  }
211
390
  }
212
391
 
392
+ // 2. Process manually-added tracks from playlist[x].tracks[]
213
393
  for (const trackIndex in playlist[index].tracks) {
214
394
  let file = this.resolveAsset(
215
395
  playlist[index].tracks[trackIndex]
216
396
  )
217
397
 
218
- playlist[index].tracks[trackIndex] = {
219
- src: _.get(file, 'asset.public_url', ''),
398
+ const trackObj = playlist[index].tracks[trackIndex]
399
+ const src = _.get(file, 'asset.public_url', '')
400
+
401
+ // Skip if no src or already added
402
+ if (!src || addedSrcs.has(src)) {
403
+ continue
404
+ }
405
+
406
+ const srclang = trackObj.srclang || this.courseSourceLocale
407
+
408
+ // Skip if this language was already added (avoid duplicate keys)
409
+ if (addedLangs.has(srclang)) {
410
+ continue
411
+ }
412
+
413
+ // FILTER: Only include tracks that match source or target language
414
+ // For translated courses, only show source language and target language tracks
415
+ // For non-translated courses, this filter is skipped and all tracks are shown
416
+ if (this.isTranslatedCourse && !this.isAllowedCaptionLanguage(srclang)) {
417
+ continue
418
+ }
419
+
420
+ const label = trackObj.label || this.getLabelForLanguage(srclang)
421
+
422
+ allTracks.push({
423
+ src: src,
220
424
  kind: 'captions',
221
- srclang: 'en-US',
222
- default: true,
425
+ srclang: srclang,
426
+ label: label,
427
+ default: false,
428
+ })
429
+ addedSrcs.add(src)
430
+ addedLangs.add(srclang)
431
+ }
432
+
433
+ // 3. NOW transform video sources to player format
434
+ for (const sourceIndex in playlist[index].sources) {
435
+ const file = resolvedVideoFiles[sourceIndex]
436
+ playlist[index].sources[sourceIndex] = {
437
+ src: _.get(file, 'asset.public_url', ''),
438
+ type: _.get(file, 'asset.metadata.mime', ''),
223
439
  }
224
440
  }
441
+
442
+ // 4. Sort tracks: current course locale first (target language on translated
443
+ // courses, source language on non-translated courses).
444
+ allTracks.sort((a, b) => {
445
+ if (this.srclangMatchesLocale(a.srclang, this.courseCurrentLocale)) return -1
446
+ if (this.srclangMatchesLocale(b.srclang, this.courseCurrentLocale)) return 1
447
+ return 0
448
+ })
449
+
450
+ // 5. Set default on first track (target language for translated courses)
451
+ if (allTracks.length > 0) {
452
+ allTracks[0].default = true
453
+ }
454
+
455
+ // 6. Replace tracks array with processed tracks
456
+ playlist[index].tracks = allTracks
225
457
  for (const adIndex in playlist[index].ads) {
226
458
  for (const adSourceIndex in playlist[index].ads[adIndex]
227
459
  .sources) {
@@ -354,6 +586,9 @@ export default {
354
586
  },
355
587
  },
356
588
  },
589
+ mounted() {
590
+ // Video block mounted - locale filtering applied via isTranslatedCourse check
591
+ },
357
592
  beforeMount() {
358
593
  // Apply the default config
359
594
  if (_.isEmpty(this.block.metadata.config)) {
@@ -400,6 +635,48 @@ export default {
400
635
  async onBeforeSave() {
401
636
  this.block.body = 'video'
402
637
  },
638
+ /**
639
+ * Get ALL linked VTT caption files (supports multiple languages)
640
+ * @param {Object} file - The video file object with linked_assets
641
+ * @returns {Array} Array of VTT files with locale information
642
+ */
643
+ getAllLinkedCaptions(file) {
644
+ // Get ALL VTT files from linked_assets (not just first one)
645
+ const linkedCaptions = _.filter(
646
+ _.get(file, 'asset.linked_assets', []),
647
+ (f) => _.get(f, 'asset.metadata.extension', '') === 'vtt'
648
+ )
649
+
650
+ // Add locale information to each VTT AND add to block.assets
651
+ return linkedCaptions.map(vtt => {
652
+ // CRITICAL: Add to block.assets so resolveAsset can find it
653
+ const foundAsset = this.block.assets.find((a) => {
654
+ return a.file_asset_id === vtt.file_asset_id
655
+ })
656
+ if (!foundAsset) {
657
+ this.block.assets.push(_.cloneDeep(vtt))
658
+ }
659
+
660
+ // Try to get locale - priority: API response > store lookup > null
661
+ let locale = vtt.locale || null
662
+
663
+ // If no locale from API but we have locale_id, try store lookup
664
+ if (!locale && vtt.locale_id && this.$store?.getters?.['locales/getById']) {
665
+ locale = this.$store.getters['locales/getById'](vtt.locale_id)
666
+ }
667
+
668
+ return {
669
+ ...vtt,
670
+ locale: locale
671
+ }
672
+ })
673
+ },
674
+
675
+ /**
676
+ * Get single linked caption (backward compatibility)
677
+ * @param {Object} file - The video file object
678
+ * @returns {Object|null} First VTT file found
679
+ */
403
680
  getLinkedCaptions(file) {
404
681
  // Check to see if the video source has a linked asset and it's a vtt file
405
682
  // Prefer the most recently linked VTT (last match), not the oldest.
@@ -431,6 +708,84 @@ export default {
431
708
  return linkedCaption || null
432
709
  },
433
710
 
711
+ /**
712
+ * TODO:find a better place for this to live
713
+ * Get human-readable language label for a language code
714
+ * @param {string} langCode - ISO language code (e.g., 'en-US')
715
+ * @returns {string} Language name
716
+ */
717
+ getLabelForLanguage(langCode) {
718
+ // Map common language codes to display names
719
+ const labels = {
720
+ 'en': 'English',
721
+ 'en-US': 'English',
722
+ 'en-GB': 'English (UK)',
723
+ 'es': 'Spanish',
724
+ 'es-ES': 'Spanish',
725
+ 'es-MX': 'Spanish (Mexico)',
726
+ 'fr': 'French',
727
+ 'fr-FR': 'French',
728
+ 'fr-CA': 'French (Canada)',
729
+ 'de': 'German',
730
+ 'de-DE': 'German',
731
+ 'it': 'Italian',
732
+ 'it-IT': 'Italian',
733
+ 'pt': 'Portuguese',
734
+ 'pt-BR': 'Portuguese (Brazil)',
735
+ 'pt-PT': 'Portuguese (Portugal)',
736
+ 'zh': 'Chinese',
737
+ 'zh-CN': 'Chinese (Simplified)',
738
+ 'zh-TW': 'Chinese (Traditional)',
739
+ 'ja': 'Japanese',
740
+ 'ja-JP': 'Japanese',
741
+ 'ko': 'Korean',
742
+ 'ko-KR': 'Korean',
743
+ 'ru': 'Russian',
744
+ 'ru-RU': 'Russian',
745
+ 'ar': 'Arabic',
746
+ 'ar-SA': 'Arabic',
747
+ 'hi': 'Hindi',
748
+ 'hi-IN': 'Hindi',
749
+ }
750
+ return labels[langCode] || langCode
751
+ },
752
+
753
+ /**
754
+ * Check if a caption language is allowed based on course source/target languages
755
+ * Since allowedCaptionLocales already contains both full codes (en-us) and base codes (en),
756
+ * we just need to check if the language or its base is in the Set.
757
+ * @param {string} lang - The language code to check (e.g., 'en-US', 'PT-BR')
758
+ * @returns {boolean} - True if the language is allowed
759
+ */
760
+ isAllowedCaptionLanguage(lang) {
761
+ if (!lang) {
762
+ // Allow tracks with no language set (legacy/default tracks)
763
+ return true
764
+ }
765
+
766
+ const langLower = lang.toLowerCase()
767
+ const langBase = langLower.split('-')[0]
768
+
769
+ // Check if the full language code or its base is in allowedCaptionLocales
770
+ return this.allowedCaptionLocales.has(langLower) ||
771
+ this.allowedCaptionLocales.has(langBase)
772
+ },
773
+
774
+ /**
775
+ * Check whether a srclang code refers to the same language as a locale code.
776
+ * Handles case differences ("PT-BR" vs "pt-br") and DeepL short codes
777
+ * ("ES" matching "es-ES").
778
+ * @param {string} srclang - Track srclang (e.g. "PT-BR", "ES")
779
+ * @param {string} locale - Locale code to match against (e.g. "pt-br", "es-es")
780
+ * @returns {boolean}
781
+ */
782
+ srclangMatchesLocale(srclang, locale) {
783
+ if (!srclang || !locale) return false
784
+ const a = srclang.toLowerCase()
785
+ const b = locale.toLowerCase()
786
+ return a === b || a.split('-')[0] === b.split('-')[0]
787
+ },
788
+
434
789
  /**
435
790
  * Check if the given text has words, omitting HTML tags and HTML entities
436
791
  * @param {string} text - The text to check
@@ -300,18 +300,23 @@ export default {
300
300
  'windward.core.components.settings.clickable_icon.clickable_icon_title'
301
301
  )
302
302
  }
303
- if (_.isEmpty(this.block.metadata.config.instructions)) {
303
+ if (
304
+ _.isEmpty(this.block.metadata.config.instructions) &&
305
+ !this.block.metadata.config.__isInitialized
306
+ ) {
304
307
  this.block.metadata.config.instructions = this.$t(
305
308
  'windward.core.components.settings.clickable_icon.instructions'
306
309
  )
310
+ // save state of initialization so we can allow user to set inputs to empty
311
+ this.$set(this.block.metadata.config, '__isInitialized', true)
307
312
  }
308
313
  if (!_.isBoolean(this.block.metadata.config.display_title)) {
309
314
  this.$set(this.block.metadata.config, 'display_title', true)
310
315
  }
311
316
  if (_.isEmpty(this.block.metadata.config.display)) {
312
317
  this.block.metadata.config.display = {
313
- show_title: false,
314
- show_background: false,
318
+ show_title: true,
319
+ show_background: true,
315
320
  round_icon: false,
316
321
  italic_icon: false,
317
322
  large_icon: false,
@@ -326,7 +331,7 @@ export default {
326
331
  methods: {
327
332
  onAddElement() {
328
333
  const defaultObject = {
329
- icon: '',
334
+ icon: 'mdi-star',
330
335
  fileConfig: {},
331
336
  iconImage: false,
332
337
  title: '',
@@ -1,5 +1,10 @@
1
1
  <template>
2
2
  <v-container>
3
+ <BaseContentBlockSettings
4
+ v-model="block.metadata.config"
5
+ :disabled="render"
6
+ ></BaseContentBlockSettings>
7
+ <v-divider class="my-4 primary"></v-divider>
3
8
  <ImageAssetSettings
4
9
  v-model="block.metadata.config"
5
10
  :assets.sync="block.assets"
@@ -13,11 +18,14 @@
13
18
  import _ from 'lodash'
14
19
  import BaseContentSettings from '~/components/Content/Settings/BaseContentSettings.js'
15
20
  import ImageAssetSettings from '~/components/Content/Settings/ImageAssetSettings.vue'
21
+ import BaseContentBlockSettings from '~/components/Content/Settings/BaseContentBlockSettings.vue'
22
+ import Uuid from '~/helpers/Uuid'
16
23
 
17
24
  export default {
18
25
  name: 'ImageSettings',
19
26
  components: {
20
27
  ImageAssetSettings,
28
+ BaseContentBlockSettings,
21
29
  },
22
30
  extends: BaseContentSettings,
23
31
  props: {
@@ -40,6 +48,24 @@ export default {
40
48
  if (_.isEmpty(this.block.metadata.config)) {
41
49
  this.block.metadata.config = {}
42
50
  }
51
+ // If the block is brand new and the title is missing then pre-set the word 'Image' to it
52
+ if (
53
+ !Uuid.test(this.block.id) &&
54
+ _.isEmpty(this.block.metadata.config.title)
55
+ ) {
56
+ this.block.metadata.config.title = this.$t(
57
+ 'windward.core.shared.content_blocks.title.image'
58
+ )
59
+ } else if (_.isEmpty(this.block.metadata.config.title)) {
60
+ // Otherwise make sure the title key at least exists
61
+ this.block.metadata.config.title = ''
62
+ }
63
+ if (!_.isBoolean(this.block.metadata.config.display_title)) {
64
+ this.$set(this.block.metadata.config, 'display_title', true)
65
+ }
66
+ if (_.isEmpty(this.block.metadata.config.instructions)) {
67
+ this.block.metadata.config.instructions = ''
68
+ }
43
69
  if (_.isEmpty(this.block.metadata.config.asset)) {
44
70
  this.block.metadata.config.asset = null
45
71
  }
@@ -24,10 +24,25 @@
24
24
  </p>
25
25
  <TextEditor
26
26
  id="block-settings-sample-response"
27
+ ref="sampleResponseEditor"
27
28
  v-model="block.metadata.config.sample_response"
28
29
  :height="200"
30
+ :rules="sampleResponseRules"
29
31
  :disabled="render"
30
32
  ></TextEditor>
33
+ <v-row class="pt-4">
34
+ <v-col cols="12">
35
+ <v-switch
36
+ v-model="block.metadata.config.ai_mode_for_student"
37
+ :label="
38
+ $t(
39
+ 'windward.core.components.settings.open_response.ai_mode_for_student'
40
+ )
41
+ "
42
+ :disabled="render"
43
+ ></v-switch>
44
+ </v-col>
45
+ </v-row>
31
46
  <p class="pt-4">
32
47
  {{
33
48
  $t(
@@ -84,6 +99,34 @@ export default {
84
99
  course: 'course/get',
85
100
  currentContent: 'content/get',
86
101
  }),
102
+ sampleResponseRules() {
103
+ return [
104
+ (value) => {
105
+ if (!this.block?.metadata?.config?.ai_mode_for_student) {
106
+ return true
107
+ }
108
+
109
+ const text = this.stripHtmlTags(value)
110
+ return (
111
+ !_.isEmpty(text) ||
112
+ this.$t(
113
+ 'windward.core.components.settings.open_response.validation.sample_response_required_ai_mode'
114
+ )
115
+ )
116
+ },
117
+ ]
118
+ },
119
+ },
120
+ watch: {
121
+ 'block.metadata.config.ai_mode_for_student'(newValue, oldValue) {
122
+ if (newValue === oldValue) {
123
+ return
124
+ }
125
+
126
+ this.$nextTick(() => {
127
+ this.$refs.sampleResponseEditor?.validate?.()
128
+ })
129
+ },
87
130
  },
88
131
  beforeMount() {
89
132
  if (_.isEmpty(this.block)) {
@@ -117,9 +160,21 @@ export default {
117
160
  if (_.isEmpty(this.block.metadata.config.starting_text)) {
118
161
  this.block.metadata.config.starting_text = ''
119
162
  }
163
+ if (!_.isBoolean(this.block.metadata.config.ai_mode_for_student)) {
164
+ this.$set(this.block.metadata.config, 'ai_mode_for_student', false)
165
+ }
120
166
  },
121
167
  mounted() {},
122
168
  methods: {
169
+ stripHtmlTags(body) {
170
+ if (typeof body !== 'string') {
171
+ return ''
172
+ }
173
+
174
+ let text = body.replace(/&nbsp;/gi, ' ')
175
+ text = text.replace(/\u00A0/g, ' ')
176
+ return text.replace(/(<([^>]+)>)/gi, '').trim()
177
+ },
123
178
  onApplyGeneratedBlock(payload = {}) {
124
179
  if (_.isEmpty(payload)) {
125
180
  return
@@ -136,6 +191,10 @@ export default {
136
191
  _.get(config, 'sample_response', '') ?? ''
137
192
  this.block.metadata.config.starting_text =
138
193
  _.get(config, 'starting_text', '') ?? ''
194
+ if (_.has(config, 'ai_mode_for_student')) {
195
+ this.block.metadata.config.ai_mode_for_student =
196
+ config.ai_mode_for_student
197
+ }
139
198
 
140
199
  if (_.has(config, 'display_title')) {
141
200
  this.block.metadata.config.display_title = config.display_title