@saooti/octopus-sdk 41.9.0 → 41.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 41.9.2 (07/04/2026)
4
+
5
+ **Features**
6
+
7
+ - **12535** - Amélioration de la sélection de la langue utilisée
8
+ - Prend maintenant en compte les langues alternatives du navigateur
9
+ - Prend en compte les langue "de base" des variantes (par exemple "fr" sera
10
+ utilisé pour un utilisateur "fr-CH")
11
+
12
+ **Fixes**
13
+
14
+ - Correction tests
15
+
16
+ ## 41.9.1 (07/04/2026)
17
+
18
+ **Features**
19
+
20
+ - **12535** - Améliorations pour la traduction
21
+ - La régénration de la transcription invalide les données de
22
+ `PodcastRawTranscript`
23
+ - **14406** - Ajout de `useDayjs` qui permet d'avoir des dates réactives
24
+ - Mise à jour des droits relatifs aux transcriptions/traductions
25
+ - Ajout de nouveaux endpoints à `transcriptionApi`
26
+ - Ajout nouveaux exports:
27
+ - composable `useTranslation`
28
+ - types `TranslationData`, `ModifyPodcastConfig`
29
+ - énumération `TranslationState`
30
+ - Améliorations composants :
31
+ - `ClassicInputText` en mode `textarea` scrolle en haut du contenu par défaut
32
+ - `ClassicAlert` a maintenant un props `text` qui enlève le font et la bordure
33
+
34
+ **Fixes**
35
+
36
+ - **14330** - La page d'émission propose uniquement le dernier épisode
37
+ **valide** à la lecture
38
+
3
39
  ## 41.9.0 (31/03/2026)
4
40
 
5
41
  **Features**
package/index.ts CHANGED
@@ -135,6 +135,7 @@ export { useSharePath } from "./src/components/composable/share/useSharePath.ts"
135
135
  export { useOrgaComputed } from "./src/components/composable/useOrgaComputed.ts";
136
136
  export { useSeoTitleUrl } from "./src/components/composable/route/useSeoTitleUrl.ts";
137
137
  export { useSeasonsManagement } from "./src/components/composable/useSeasonsManagement.ts";
138
+ export { useTranslation } from "./src/components/composable/useTranslation.ts";
138
139
 
139
140
  //helper
140
141
  import domHelper from "./src/helper/domHelper.ts";
@@ -161,7 +162,7 @@ import classicApi from "./src/api/classicApi.ts";
161
162
 
162
163
  // API
163
164
  export { emissionApi } from "./src/api/emissionApi.ts";
164
- export { transcriptionApi } from "./src/api/transcriptionApi.ts";
165
+ export { transcriptionApi, type TranslationData, TranslationState } from "./src/api/transcriptionApi.ts";
165
166
  export * from "./src/api/groupsApi.ts";
166
167
  export { organisationApi } from "./src/api/organisationApi.ts";
167
168
  export { playlistApi } from "./src/api/playlistApi.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.9.0",
3
+ "version": "41.9.2",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -1,3 +1,4 @@
1
+ import { ModifyPodcastConfig } from "../stores/class/transcript/transcriptParams";
1
2
  import { ModuleApi } from "./apiConnection";
2
3
  import classicApi from "./classicApi";
3
4
 
@@ -100,8 +101,73 @@ async function getRawTranscription(podcastId: number): Promise<string> {
100
101
  });
101
102
  }
102
103
 
104
+ /**
105
+ * Generate the transcription on the given podcast
106
+ * @param podcastId ID of the podcast on which to do the transcription
107
+ * @param langauge Target language. Should be the native language of the podcast
108
+ * @param params Transcription parameters
109
+ */
110
+ async function generateTranscription(podcastId: number, language: string, params: ModifyPodcastConfig): Promise<void> {
111
+ await classicApi.putData({
112
+ api: ModuleApi.SPEECHTOTEXT,
113
+ path: `convert/${language}/${podcastId}`,
114
+ dataToSend: params
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Regenerate the transcription on the given podcast
120
+ * Currently does the same as `generateTranscription`, but should be used when
121
+ * relevant in case of future evolutions.
122
+ * @param podcastId ID of the podcast on which to do the transcription
123
+ * @param langauge Target language. Should be the native language of the podcast
124
+ * @param params Transcription parameters
125
+ */
126
+ async function regenerateTranscription(podcastId: number, language: string, params: ModifyPodcastConfig): Promise<void> {
127
+ await classicApi.putData({
128
+ api: ModuleApi.SPEECHTOTEXT,
129
+ path: `regenerate/${language}/${podcastId}`,
130
+ dataToSend: params
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Change the visibility of the podcast
136
+ * @param podcastId ID of the podcast for which to change the visibility
137
+ * @param visibility New visibility state
138
+ */
139
+ async function changeTranscriptionVisibility(podcastId: number, visibility: boolean): Promise<void> {
140
+ await classicApi.putData({
141
+ api: 11,
142
+ path: "visibility/" + podcastId,
143
+ parameters: {
144
+ visibility
145
+ }
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Update the transcription of a podcast
151
+ * @param podcastId ID of the podcast
152
+ * @param transcript The new transcript
153
+ */
154
+ async function updateTranscription(podcastId: number, transcript: string): Promise<void> {
155
+ await classicApi.postData({
156
+ api: ModuleApi.SPEECHTOTEXT,
157
+ path: "update/" + podcastId,
158
+ dataToSend: {
159
+ file: new File([transcript], "file")
160
+ },
161
+ contentType: "formData"
162
+ });
163
+ }
164
+
103
165
  export const transcriptionApi = {
166
+ changeTranscriptionVisibility,
104
167
  getTranslations,
105
168
  getTranslation,
106
- getRawTranscription
169
+ getRawTranscription,
170
+ generateTranscription,
171
+ regenerateTranscription,
172
+ updateTranscription
107
173
  };
@@ -1,18 +1,17 @@
1
1
  import { Conference } from '@/stores/class/conference/conference';
2
2
  import { Podcast } from '@/stores/class/general/podcast';
3
- import dayjs from 'dayjs';
4
- import duration from "dayjs/plugin/duration";
5
- dayjs.extend(duration);
6
3
  // @ts-expect-error Bibliothèque non typée
7
4
  import humanizeDuration from "humanize-duration";
8
5
  import {computed, Ref} from 'vue';
9
6
  import { useI18n } from 'vue-i18n';
10
7
  import {useOrgaComputed} from "../useOrgaComputed"
11
8
  import { state } from '../../../stores/ParamSdkStore';
9
+ import { useDayjs } from '../useDayjs';
12
10
 
13
11
  export const usePodcastView = (podcast: Ref<Podcast|undefined>, podcastConference: Ref<Conference|undefined>)=>{
14
12
 
15
13
  const {locale} = useI18n();
14
+ const { dayjs } = useDayjs();
16
15
 
17
16
  const {isEditRights, isPodcastmaker} = useOrgaComputed();
18
17
 
@@ -0,0 +1,24 @@
1
+ import dayjs, { Dayjs } from "dayjs";
2
+ import { useI18n } from "vue-i18n"
3
+
4
+ import "dayjs/locale/de";
5
+ import "dayjs/locale/es";
6
+ import "dayjs/locale/fr";
7
+ import "dayjs/locale/it";
8
+ import "dayjs/locale/sl";
9
+
10
+ import duration from "dayjs/plugin/duration";
11
+ dayjs.extend(duration);
12
+
13
+ export const useDayjs = () => {
14
+ const { locale } = useI18n();
15
+
16
+ function composableDayjs(param?: string|number|Date|Dayjs): Dayjs {
17
+ return dayjs(param).locale(locale.value);
18
+ }
19
+ composableDayjs.duration = dayjs.duration;
20
+
21
+ return {
22
+ dayjs: composableDayjs
23
+ }
24
+ }
@@ -154,13 +154,21 @@ export const useRights = () => {
154
154
  return true;
155
155
  }
156
156
 
157
- if (roleContainsAny('RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION')) {
157
+ if (roleContainsAny('RESTRICTED_PRODUCTION', 'PODCAST_CRUD')) {
158
158
  return podcast.createdByUserId === authStore.authProfile?.userId;
159
159
  }
160
160
 
161
161
  return false;
162
162
  }
163
163
 
164
+ function canEditTranscriptVisibility(podcast: Podcast): boolean {
165
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
166
+ }
167
+
168
+ function canEditTranslation(podcast: Podcast): boolean {
169
+ return canEditTranscript(podcast);
170
+ }
171
+
164
172
  function canSeeHistory(): boolean {
165
173
  return roleContainsAny('ADMIN', 'ORGANISATION');
166
174
  }
@@ -198,6 +206,8 @@ export const useRights = () => {
198
206
  // Other
199
207
  canEditCodeInsertPlayer,
200
208
  canEditTranscript,
209
+ canEditTranslation,
210
+ canEditTranscriptVisibility,
201
211
  canSeeHistory,
202
212
  isRestrictedProduction
203
213
  }
@@ -14,6 +14,12 @@ interface RelevantLanguages {
14
14
  available?: string;
15
15
  }
16
16
 
17
+ enum Availability {
18
+ Available,
19
+ ToGenerate,
20
+ Unavailable
21
+ }
22
+
17
23
  export const useTranslation = () => {
18
24
 
19
25
  const authStore = useAuthStore();
@@ -64,7 +70,7 @@ export const useTranslation = () => {
64
70
  * @param emission *(optional)* If set, will also check in emission settings
65
71
  * @returns Whether the language is available or not
66
72
  */
67
- function translationConfig(language: string, emission?: Emission): CreateTranslation {
73
+ function getTranslationConfig(language: string, emission?: Emission): CreateTranslation {
68
74
  const orgAttributes = authStore.authOrganisation.attributes;
69
75
  const emissionTranslation = parseOrDefault(emission?.annotations['translation-config'] as string|undefined, true);
70
76
  const orgTranslation = parseOrDefault(orgAttributes?.['translation-config']);
@@ -72,6 +78,32 @@ export const useTranslation = () => {
72
78
  return getConfigurationFor(language, emissionTranslation, orgTranslation);
73
79
  }
74
80
 
81
+ function getLanguageAvailability(language: string, emission: Emission, translationData: PodcastTranslationData): Availability {
82
+
83
+ // 1. If language of podcast == language of browser, use that language
84
+ if (language === translationData.nativeLanguage) {
85
+ return Availability.Available;
86
+ } else {
87
+ const langConfig = getTranslationConfig(language, emission);
88
+
89
+ // 2. If the language of the browser is available, use it
90
+ if (langConfig === CreateTranslation.ALWAYS) {
91
+ return Availability.Available;
92
+ }
93
+ // 2b. If the language is on demand, check if generated
94
+ else if (langConfig === CreateTranslation.ON_DEMAND) {
95
+ // Check if translation is already generated
96
+ if (translationData.translations.find(t => t.language === language && t.state === TranslationState.FINISHED)) {
97
+ return Availability.Available;
98
+ } else {
99
+ return Availability.ToGenerate;
100
+ }
101
+ }
102
+ }
103
+
104
+ return Availability.Unavailable;
105
+ }
106
+
75
107
  /**
76
108
  * Get the relevant language for the current user
77
109
  * @param translationData The translation data for the podcast
@@ -81,56 +113,49 @@ export const useTranslation = () => {
81
113
  * undefined if *ready* is better)
82
114
  */
83
115
  async function getMostRelevantLanguage(translationData: PodcastTranslationData): Promise<RelevantLanguages> {
84
- const languages: RelevantLanguages = {
85
- ready: translationData.nativeLanguage
86
- };
87
- const baseLanguage = getLanguage();
88
-
89
- // 1. If language of podcast == language of browser, use that language
90
- if (baseLanguage === translationData.nativeLanguage) {
91
- languages.ready = baseLanguage;
92
- } else {
93
- const podcast = await podcastApi.get(translationData.podcastId);
94
- const emission = podcast.emission;
116
+ const userLanguage = getLanguage(false);
95
117
 
96
- const baseLangConfig = translationConfig(baseLanguage, emission);
97
- const defaultLangConfig = translationConfig(DEFAULT_LANGUAGE, emission);
118
+ // If user language is native, do not go further
119
+ if (userLanguage === translationData.nativeLanguage) {
120
+ return { ready: userLanguage };
121
+ }
98
122
 
99
- // 2. If the language of the browser is available, use it
100
- if (baseLangConfig === CreateTranslation.ALWAYS) {
101
- languages.ready = baseLanguage;
123
+ // Compute all available languages. Use language set on site, then
124
+ // languages defined on browser, then default, and finally native language
125
+ const languages: string[] = [];
126
+ [userLanguage, ...navigator.languages, DEFAULT_LANGUAGE, translationData.nativeLanguage].forEach(l => {
127
+ if (!languages.includes(l)) {
128
+ languages.push(l);
102
129
  }
103
- // 2b. If the language is on demand, check if generated
104
- else if (baseLangConfig === CreateTranslation.ON_DEMAND && !languages.available) {
105
- // Check if translation is already generated
106
- if (translationData.translations.find(t => t.language === baseLanguage && t.state === TranslationState.FINISHED)) {
107
- languages.ready = baseLanguage;
108
- } else {
109
- languages.available = baseLanguage;
130
+ // For each language, if it is a variant (for example fr-CH), also
131
+ // add the base language
132
+ if (l.includes('-')) {
133
+ const base = l.split('-')[0];
134
+ if (!languages.includes(base)) {
135
+ languages.push(base);
110
136
  }
111
137
  }
138
+ });
112
139
 
113
- // 3. If default language is available, use it
114
- else if (defaultLangConfig === CreateTranslation.ALWAYS) {
115
- languages.ready = DEFAULT_LANGUAGE;
116
- }
117
- // 3b. If the language is on demand, it will be created
118
- else if (defaultLangConfig === CreateTranslation.ON_DEMAND && !languages.available) {
119
- // Check if translation is already generated
120
- if (translationData.translations.find(t => t.language === DEFAULT_LANGUAGE && t.state === TranslationState.FINISHED)) {
121
- languages.ready = DEFAULT_LANGUAGE;
122
- } else {
123
- languages.available = DEFAULT_LANGUAGE;
124
- }
140
+ const podcast = await podcastApi.get(translationData.podcastId);
141
+ const emission = podcast.emission;
142
+
143
+ let bestReady: string|null = null;
144
+ let bestAvailable: string|null = null;
145
+
146
+ for (const language of languages) {
147
+ const avaibility = getLanguageAvailability(language, emission, translationData);
148
+ if (avaibility === Availability.Available && bestReady === null) {
149
+ bestReady = language;
150
+ break;
125
151
  }
126
152
 
127
- // 4. Otherwise, use default language of podcast
128
- else {
129
- languages.ready = translationData.nativeLanguage;
153
+ if (avaibility === Availability.ToGenerate && bestAvailable === null) {
154
+ bestAvailable = language;
130
155
  }
131
156
  }
132
157
 
133
- return languages;
158
+ return { ready: bestReady, available: bestAvailable ?? undefined };
134
159
  }
135
160
 
136
161
  /**
@@ -169,6 +194,7 @@ export const useTranslation = () => {
169
194
  return {
170
195
  convertSrtToPlainText,
171
196
  getMostRelevantLanguage,
172
- getMostRelevantTranslation
197
+ getMostRelevantTranslation,
198
+ getTranslationConfig
173
199
  }
174
200
  };
@@ -60,7 +60,6 @@
60
60
  <script setup lang="ts">
61
61
  import AnimatorsItem from "./AnimatorsItem.vue";
62
62
  import {useOrgaComputed} from "../../composable/useOrgaComputed";
63
- import dayjs from "dayjs";
64
63
  import { computed, defineAsyncComponent } from "vue";
65
64
  import { Podcast, PodcastType } from "../../../stores/class/general/podcast";
66
65
  import { state } from "../../../stores/ParamSdkStore";
@@ -68,6 +67,7 @@ import { useI18n } from "vue-i18n";
68
67
  import { useSeasonsManagement } from "../../composable/useSeasonsManagement";
69
68
  import BullhornIcon from 'vue-material-design-icons/Bullhorn.vue';
70
69
  import GiftIcon from 'vue-material-design-icons/Gift.vue';
70
+ import { useDayjs } from "../../composable/useDayjs";
71
71
  const PodcastPlayBar = defineAsyncComponent(
72
72
  () => import("./PodcastPlayBar.vue"),
73
73
  );
@@ -81,6 +81,7 @@ const props = defineProps({
81
81
  const { t } = useI18n();
82
82
  const { isPodcastmaker } = useOrgaComputed();
83
83
  const { formatSeason } = useSeasonsManagement();
84
+ const { dayjs } = useDayjs();
84
85
 
85
86
  //Computed
86
87
  const date = computed(() => {
@@ -90,6 +91,7 @@ const date = computed(() => {
90
91
  }
91
92
  return dayjs(props.podcast.pubDate).format(format);
92
93
  });
94
+
93
95
  const orgaNameDisplay = computed(() =>{
94
96
  if (props.podcast.organisation.name.length > 30) {
95
97
  return props.podcast.organisation.name.substring(0, 30) + "...";
@@ -12,6 +12,7 @@
12
12
  v-else-if="editRight && isEditBox"
13
13
  :podcast="podcast"
14
14
  :display-studio-access="isDebriefing"
15
+ @update-transcription="resetTranscription"
15
16
  @validate-podcast="emit('updatePodcast', $event)"
16
17
  />
17
18
  <div class="mb-2 w-100">
@@ -158,7 +159,10 @@
158
159
  :orga-id="podcast.organisation.id"
159
160
  :rubrique-ids="podcastRubriques"
160
161
  />
161
- <PodcastRawTranscript :podcast-id="podcast.podcastId" />
162
+ <PodcastRawTranscript
163
+ ref="podcastRawTranscript"
164
+ :podcast-id="podcast.podcastId"
165
+ />
162
166
  <SubscribeButtons
163
167
  v-if="isPodcastmaker"
164
168
  class="mt-4"
@@ -185,7 +189,7 @@ import { useRouter } from "vue-router";
185
189
  import { useSeasonsManagement } from "../../composable/useSeasonsManagement";
186
190
  import { SeasonMode } from "../../../stores/class/general/emission";
187
191
 
188
- import { defineAsyncComponent, toRefs, computed } from "vue";
192
+ import { defineAsyncComponent, toRefs, computed, useTemplateRef } from "vue";
189
193
  const ErrorMessage = defineAsyncComponent(
190
194
  () => import("../../misc/ErrorMessage.vue"),
191
195
  );
@@ -243,6 +247,9 @@ const authStore = useAuthStore();
243
247
  const router = useRouter();
244
248
  const { areSeasonsEnabled } = useSeasonsManagement();
245
249
 
250
+ /** Reference to the PodcastRqwTranscript */
251
+ const podcastRawTranscript = useTemplateRef('podcastRawTranscript');
252
+
246
253
  //Computed
247
254
  const podcastRubriques = computed(() => {
248
255
  let rubriques = props.podcast?.rubriqueIds ?? [];
@@ -342,6 +349,10 @@ function removeDeleted(): void {
342
349
  }
343
350
  }
344
351
 
352
+ function resetTranscription(): void {
353
+ podcastRawTranscript.value.reset();
354
+ }
355
+
345
356
  const showTags = computed((): boolean => {
346
357
  if (state.podcastPage.hideTags === true) {
347
358
  return false;
@@ -137,12 +137,14 @@ function saveAccessibility(accessibility: {fontSize: number,background: string,c
137
137
  isAccessibilityModal.value = false;
138
138
  }
139
139
 
140
- async function fetchTranscripts(): Promise<void> {
140
+ async function fetchTranscripts(language?: string): Promise<void> {
141
141
  try {
142
142
  const translation = await transcriptionApi.getTranslations(props.podcastId);
143
- const { ready, available } = await getMostRelevantLanguage(translation);
144
- // If available language is set, it is better than ready, so use it
145
- const language = available ?? ready;
143
+ if (language === undefined) {
144
+ const { ready, available } = await getMostRelevantLanguage(translation);
145
+ // If available language is set, it is better than ready, so use it
146
+ language = available ?? ready;
147
+ }
146
148
  const srt = await transcriptionApi.getTranslation(props.podcastId, language, true);
147
149
  transcript.value = convertSrtToPlainText(srt);
148
150
 
@@ -165,7 +167,7 @@ async function fetchTranscripts(): Promise<void> {
165
167
  async function changeLanguage(language: string): Promise<void> {
166
168
  loadingDone.value = false;
167
169
  currentLanguage.value = language;
168
- transcript.value = '';
170
+ transcript.value = undefined;
169
171
  try {
170
172
  const srt = await transcriptionApi.getTranslation(props.podcastId, language);
171
173
  transcript.value = convertSrtToPlainText(srt);
@@ -174,6 +176,15 @@ async function changeLanguage(language: string): Promise<void> {
174
176
  }
175
177
  loadingDone.value = true;
176
178
  }
179
+
180
+ /** Force reloading */
181
+ function reset(): void {
182
+ if (isOpen.value || loadingDone.value) {
183
+ fetchTranscripts(currentLanguage.value);
184
+ }
185
+ }
186
+
187
+ defineExpose({ reset });
177
188
  </script>
178
189
 
179
190
  <style scoped lang="scss">
@@ -259,7 +259,17 @@ onMounted(()=>{
259
259
  if (props.errorVariable !== isError.value) {
260
260
  emit("update:errorVariable", isError.value);
261
261
  }
262
- })
262
+
263
+ if (props.isTextarea) {
264
+ // Delay a scroll back to the top of the text area
265
+ setTimeout(() => {
266
+ const textArea = document.getElementById(computedInputId.value);
267
+ if (textArea) {
268
+ textArea.scrollTop = 0;
269
+ }
270
+ }, 100);
271
+ }
272
+ });
263
273
 
264
274
  //Methods
265
275
  function addEmojiSelected(emoji: string) {
@@ -8,7 +8,7 @@
8
8
  -->
9
9
  <template>
10
10
  <div
11
- class="p-2 pe-4 my-2 rounded d-flex alert"
11
+ class="p-2 pe-4 rounded d-flex alert"
12
12
  :class="cardClass"
13
13
  >
14
14
  <!-- The icon -->
@@ -16,7 +16,7 @@
16
16
  :is="iconComponent"
17
17
  v-if="!noIcon"
18
18
  class="icon"
19
- :size="30"
19
+ :size="text ? 22 : 30"
20
20
  />
21
21
 
22
22
  <!-- Main content -->
@@ -39,18 +39,26 @@ import CheckCircle from 'vue-material-design-icons/CheckCircle.vue';
39
39
  import CloseCircle from 'vue-material-design-icons/CloseCircle.vue';
40
40
  import Information from 'vue-material-design-icons/Information.vue';
41
41
 
42
- const { type } = defineProps<{
42
+ const { type, text } = defineProps<{
43
43
  /** Disables the icon when true */
44
44
  noIcon?: boolean;
45
45
  /** An optional title for the alert */
46
46
  title?: string;
47
47
  /** The type of message */
48
48
  type: 'info'|'success'|'warning'|'error';
49
+ /** Use a simpler display */
50
+ text?: boolean;
49
51
  }>();
50
52
 
51
53
  /** The class applied to the alert */
52
- const cardClass = computed(() => {
53
- return 'alert-' + type;
54
+ const cardClass = computed((): Array<string> => {
55
+ const classes = ['alert-' + type];
56
+ if (text === true) {
57
+ classes.push('text-alert');
58
+ } else {
59
+ classes.push('my-2');
60
+ }
61
+ return classes;
54
62
  });
55
63
 
56
64
  const iconComponent = computed(() => {
@@ -70,9 +78,10 @@ const iconComponent = computed(() => {
70
78
 
71
79
  <style lang="scss" scoped>
72
80
  .alert {
73
- //color: white;
74
- border: 1px solid;
75
- border-left: 8px solid;
81
+ &:not(.text-alert) {
82
+ border: 1px solid;
83
+ border-left: 8px solid;
84
+ }
76
85
 
77
86
  .icon {
78
87
  align-items: start !important;
@@ -98,14 +107,17 @@ const iconComponent = computed(() => {
98
107
  'error': var(--octopus-danger),
99
108
  );
100
109
 
101
- &.alert {
102
- @each $type, $color in $types {
103
- &-#{$type} {
110
+ @each $type, $color in $types {
111
+ &-#{$type} {
112
+ &:not(.text-alert) {
104
113
  background-color: hsl(from #{$color} h s 95) !important;
105
114
  border-color: #{$color} !important;
106
- .icon {
107
- color: #{$color};
108
- }
115
+ }
116
+ &.text-alert {
117
+ color: #{$color} !important;
118
+ }
119
+ .icon {
120
+ color: #{$color};
109
121
  }
110
122
  }
111
123
  }
@@ -269,8 +269,11 @@ async function getEmissionDetails(): Promise<void> {
269
269
  }
270
270
  }
271
271
 
272
+ function isReadyAndVisibleAndValid(p: Podcast): boolean {
273
+ return "READY" === p.processingStatus && p.availability.visibility && p.valid === true;
274
+ }
275
+
272
276
  function podcastsFetched(podcasts: Array<Podcast>, season: number|undefined) {
273
- const isReadyAndVisible = (p: Podcast) => "READY" === p.processingStatus && p.availability.visibility;
274
277
 
275
278
  if (areSeasonsEnabled(emission.value)) {
276
279
  // Ignore results that are not from the last season
@@ -279,11 +282,11 @@ function podcastsFetched(podcasts: Array<Podcast>, season: number|undefined) {
279
282
  return;
280
283
  }
281
284
  // If seasons are enabled, take last element
282
- lastPodcast.value = podcasts.findLast(isReadyAndVisible);
285
+ lastPodcast.value = podcasts.findLast(isReadyAndVisibleAndValid);
283
286
  } else {
284
287
  // If seasons are disabled, we have a standard date desc sort, so we take
285
288
  // first element
286
- lastPodcast.value = podcasts.find(isReadyAndVisible);
289
+ lastPodcast.value = podcasts.find(isReadyAndVisibleAndValid);
287
290
  }
288
291
  }
289
292
 
@@ -1,38 +1,57 @@
1
1
  /**
2
2
  * Utility function to retrieve most relevant language for the user
3
+ * @params forceToKnown When set to true (default), will limit language to one
4
+ * one of 6 known languages. When set to false, allow all
5
+ * languages.
3
6
  * @returns The most relevant language
4
7
  */
5
- export function getLanguage(): string {
6
- const nameEQ = "octopus-language=";
7
- const ca = document.cookie.split(";");
8
+ export function getLanguage(forceToKnown = true): string {
9
+ const nameEQ = "octopus-language=";
10
+ const ca = document.cookie.split(";");
8
11
 
9
- let language = "";
10
- for (const valueCookie of ca) {
11
- let c = valueCookie;
12
- while (c.startsWith(" ")) {
13
- c = c.substring(1, c.length);
12
+ let language = "";
13
+ for (const valueCookie of ca) {
14
+ let c = valueCookie;
15
+ while (c.startsWith(" ")) {
16
+ c = c.substring(1, c.length);
17
+ }
18
+ if (0 === c.indexOf(nameEQ)) {
19
+ language = c.substring(nameEQ.length, c.length);
20
+ break;
21
+ }
14
22
  }
15
- if (0 === c.indexOf(nameEQ)) {
16
- language = c.substring(nameEQ.length, c.length);
17
- break;
18
- }
19
- }
20
23
 
21
- if (0 === language.length) {
22
- const navigatorLang = navigator.language;
23
- language = "fr";
24
- if (navigatorLang.includes("en")) {
25
- language = "en";
26
- } else if (navigatorLang.includes("it")) {
27
- language = "it";
28
- } else if (navigatorLang.includes("sl")) {
29
- language = "sl";
30
- } else if (navigatorLang.includes("es")) {
31
- language = "es";
32
- } else if (navigatorLang.includes("de")) {
33
- language = "de";
24
+ if (0 === language.length) {
25
+ if (forceToKnown === true) {
26
+ // Choose best avilable language
27
+ for (const navigatorLang of navigator.languages) {
28
+ if (navigatorLang.includes("fr")) {
29
+ language = "fr";
30
+ break;
31
+ } else if (navigatorLang.includes("en")) {
32
+ language = "en";
33
+ break;
34
+ } else if (navigatorLang.includes("it")) {
35
+ language = "it";
36
+ break;
37
+ } else if (navigatorLang.includes("sl")) {
38
+ language = "sl";
39
+ break;
40
+ } else if (navigatorLang.includes("es")) {
41
+ language = "es";
42
+ break;
43
+ } else if (navigatorLang.includes("de")) {
44
+ language = "de";
45
+ break;
46
+ }
47
+ }
48
+ if (!language) {
49
+ language = "fr";
50
+ }
51
+ } else {
52
+ language = navigator.language;
53
+ }
34
54
  }
35
- }
36
55
 
37
- return language;
56
+ return language;
38
57
  }
package/src/i18n.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import { nextTick } from "vue";
2
2
  import { createI18n } from "vue-i18n";
3
- import dayjs from "dayjs";
4
- import "dayjs/locale/de";
5
- import "dayjs/locale/es";
6
- import "dayjs/locale/fr";
7
- import "dayjs/locale/it";
8
- import "dayjs/locale/sl";
9
3
 
10
4
  export function setupI18n(options: { [key:string]: string|boolean }, isAuthenticated: boolean, isEducation: boolean) {
11
5
  const i18n = createI18n(options);
@@ -15,7 +9,6 @@ export function setupI18n(options: { [key:string]: string|boolean }, isAuthentic
15
9
 
16
10
  export function setI18nLanguage(i18n: any, locale: string) {
17
11
  i18n.locale.value= locale;
18
- dayjs.locale(locale);
19
12
  const html = document.querySelector("html");
20
13
  if (html) {
21
14
  html.setAttribute("lang", locale);
@@ -63,7 +63,7 @@ export interface ParamStore {
63
63
  /** If true, do not display subtitles on podcast pages */
64
64
  hideSubtitle?: boolean;
65
65
  /** Select whether to display description, summary, or both */
66
- descriptionOrSummary: 'description'|'summary'|'both';
66
+ descriptionOrSummary?: 'description'|'summary'|'both';
67
67
  };
68
68
  emissionPage: {
69
69
  ShareButtons?: boolean;
@@ -256,7 +256,7 @@ describe('useRights', () => {
256
256
  });
257
257
  });
258
258
 
259
- ['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
259
+ ['RESTRICTED_PRODUCTION', 'PODCAST_CRUD'].forEach(role => {
260
260
  it(`${role} can only edit transcript of own podcast`, async () => {
261
261
  await setup([role]);
262
262
  expect(useRights().canEditTranscript(ownPodcast)).toBe(true);
@@ -265,11 +265,53 @@ describe('useRights', () => {
265
265
  });
266
266
 
267
267
  it('denies unrelated roles', async () => {
268
- await setup(['PODCAST_CRUD']);
268
+ await setup(['RESTRICTED_ANIMATION']);
269
269
  expect(useRights().canEditTranscript(ownPodcast)).toBe(false);
270
270
  });
271
271
  });
272
272
 
273
+ describe('canEditTranslation', () => {
274
+ const ownPodcast = { podcastId: 1, createdByUserId: 'test-user-123' } as Podcast;
275
+ const otherPodcast = { podcastId: 2, createdByUserId: 'other-user' } as Podcast;
276
+
277
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION'].forEach(role => {
278
+ it(`allows ${role} to edit transcript of any podcast`, async () => {
279
+ await setup([role]);
280
+ expect(useRights().canEditTranslation(otherPodcast)).toBe(true);
281
+ });
282
+ });
283
+
284
+ ['RESTRICTED_PRODUCTION', 'PODCAST_CRUD'].forEach(role => {
285
+ it(`${role} can only edit transcript of own podcast`, async () => {
286
+ await setup([role]);
287
+ expect(useRights().canEditTranslation(ownPodcast)).toBe(true);
288
+ expect(useRights().canEditTranslation(otherPodcast)).toBe(false);
289
+ });
290
+ });
291
+
292
+ it('denies unrelated roles', async () => {
293
+ await setup(['RESTRICTED_ANIMATION']);
294
+ expect(useRights().canEditTranslation(ownPodcast)).toBe(false);
295
+ });
296
+ });
297
+
298
+ describe('canEditTranscriptVisibility', () => {
299
+ const ownPodcast = { podcastId: 1, createdByUserId: 'test-user-123' } as Podcast;
300
+ const otherPodcast = { podcastId: 2, createdByUserId: 'other-user' } as Podcast;
301
+
302
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION'].forEach(role => {
303
+ it(`allows ${role} to edit transcript of any podcast`, async () => {
304
+ await setup([role]);
305
+ expect(useRights().canEditTranscriptVisibility(otherPodcast)).toBe(true);
306
+ });
307
+ });
308
+
309
+ it('denies unrelated roles', async () => {
310
+ await setup(['PODCAST_CRUD']);
311
+ expect(useRights().canEditTranscriptVisibility(ownPodcast)).toBe(false);
312
+ });
313
+ });
314
+
273
315
  describe('Other permissions', () => {
274
316
  describe('canEditCodeInsertPlayer', () => {
275
317
  ['ADMIN', 'ORGANISATION'].forEach(role => {
@@ -76,6 +76,36 @@ describe('useTranslation', () => {
76
76
  });
77
77
  });
78
78
 
79
+ describe('getMostRelevantLanguage', () => {
80
+ it('bases its results on the language alternatives in the browser', async () => {
81
+ vi.mocked(getLanguage).mockReturnValue('de');
82
+
83
+ Object.defineProperty(navigator, 'languages', {
84
+ value: ['fr', 'it', 'de'],
85
+ configurable: true
86
+ });
87
+
88
+ const translationData = { nativeLanguage: 'it', podcastId: 0, translations: [] };
89
+
90
+ const result = await composable.getMostRelevantLanguage(translationData)
91
+ expect(result.ready).toBe('it');
92
+ });
93
+
94
+ it('takes into consideration the "base" languages of variants', async () => {
95
+ vi.mocked(getLanguage).mockReturnValue('fr-CH');
96
+
97
+ Object.defineProperty(navigator, 'languages', {
98
+ value: ['fr-CH', 'it', 'de'],
99
+ configurable: true
100
+ });
101
+
102
+ const translationData = { nativeLanguage: 'fr', podcastId: 0, translations: [] };
103
+
104
+ const result = await composable.getMostRelevantLanguage(translationData)
105
+ expect(result.ready).toBe('fr');
106
+ });
107
+ });
108
+
79
109
  describe('getMostRelevantTranslation', () => {
80
110
  it('uses the native language when it matches the browser language', async () => {
81
111
  await composable.getMostRelevantTranslation(makeTranslationData({ nativeLanguage: 'fr' }));
@@ -21,14 +21,14 @@ describe('PodcastModuleBox', () => {
21
21
 
22
22
  it('shows the date without time by default', async() => {
23
23
  const wrapper = await mount(podcast);
24
- expect(wrapper.text()).toContain('1 December 2025');
24
+ expect(wrapper.text()).toContain('1 décembre 2025');
25
25
  expect(wrapper.text()).not.toContain('11:21');
26
26
  });
27
27
 
28
28
  it('shows the date with time when enabled in SdkParams', async() => {
29
29
  initialize({ generalParameters: { showTimeWithDates: true } });
30
30
  const wrapper = await mount(podcast);
31
- expect(wrapper.text()).toContain('1 December 2025');
31
+ expect(wrapper.text()).toContain('1 décembre 2025');
32
32
  expect(wrapper.text()).toContain('11:21');
33
33
  });
34
34
  });
@@ -33,6 +33,7 @@ function makeReadyPodcast(seasonMode: SeasonMode = SeasonMode.NO_SEASON) {
33
33
  podcast.seasonNumber = 1;
34
34
  podcast.seasonEpisodeNumber = 3;
35
35
  podcast.emission.seasonMode = seasonMode;
36
+ podcast.valid = true;
36
37
  return podcast;
37
38
  }
38
39
 
@@ -2,8 +2,8 @@ import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { getLanguage } from '../../src/helper/language';
3
3
 
4
4
  function setNavigatorLanguage(lang: string) {
5
- Object.defineProperty(navigator, 'language', {
6
- value: lang,
5
+ Object.defineProperty(navigator, 'languages', {
6
+ value: [lang],
7
7
  configurable: true,
8
8
  });
9
9
  }
@@ -29,15 +29,15 @@ describe('language', () => {
29
29
 
30
30
  describe('navigator-based detection', () => {
31
31
  it.each([
32
- ['en-US', 'en'],
33
- ['it-IT', 'it'],
34
- ['sl-SI', 'sl'],
35
- ['de-DE', 'de'],
36
- ['es-ES', 'es'],
37
- ['fr-FR', 'fr'],
38
- ['ja-JP', 'fr'],
39
- ])('returns "%s" for navigator language %s', (navigatorLang, expected) => {
40
- setNavigatorLanguage(navigatorLang);
32
+ { input: 'en-US', expected: 'en' },
33
+ { input: 'it-IT', expected: 'it' },
34
+ { input: 'sl-SI', expected: 'sl' },
35
+ { input: 'de-DE', expected: 'de' },
36
+ { input: 'es-ES', expected: 'es' },
37
+ { input: 'fr-FR', expected: 'fr' },
38
+ { input: 'ja-JP', expected: 'fr' }
39
+ ])('returns "$expected" for navigator language $input', ({ input , expected }) => {
40
+ setNavigatorLanguage(input);
41
41
  expect(getLanguage()).toBe(expected);
42
42
  });
43
43
  });
package/vite.config.js CHANGED
@@ -73,4 +73,4 @@ export default defineConfig(({ mode }) => {
73
73
  }
74
74
  });
75
75
 
76
- /* eslint-enable */
76
+ /* eslint-enable */