@saooti/octopus-sdk 41.9.0 → 41.9.1

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,28 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 41.9.1 (07/04/2026)
4
+
5
+ **Features**
6
+
7
+ - **12535** - Améliorations pour la traduction
8
+ - La régénration de la transcription invalide les données de
9
+ `PodcastRawTranscript`
10
+ - **14406** - Ajout de `useDayjs` qui permet d'avoir des dates réactives
11
+ - Mise à jour des droits relatifs aux transcriptions/traductions
12
+ - Ajout de nouveaux endpoints à `transcriptionApi`
13
+ - Ajout nouveaux exports:
14
+ - composable `useTranslation`
15
+ - types `TranslationData`, `ModifyPodcastConfig`
16
+ - énumération `TranslationState`
17
+ - Améliorations composants :
18
+ - `ClassicInputText` en mode `textarea` scrolle en haut du contenu par défaut
19
+ - `ClassicAlert` a maintenant un props `text` qui enlève le font et la bordure
20
+
21
+ **Fixes**
22
+
23
+ - **14330** - La page d'émission propose uniquement le dernier épisode
24
+ **valide** à la lecture
25
+
3
26
  ## 41.9.0 (31/03/2026)
4
27
 
5
28
  **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.1",
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
  }
@@ -64,7 +64,7 @@ export const useTranslation = () => {
64
64
  * @param emission *(optional)* If set, will also check in emission settings
65
65
  * @returns Whether the language is available or not
66
66
  */
67
- function translationConfig(language: string, emission?: Emission): CreateTranslation {
67
+ function getTranslationConfig(language: string, emission?: Emission): CreateTranslation {
68
68
  const orgAttributes = authStore.authOrganisation.attributes;
69
69
  const emissionTranslation = parseOrDefault(emission?.annotations['translation-config'] as string|undefined, true);
70
70
  const orgTranslation = parseOrDefault(orgAttributes?.['translation-config']);
@@ -93,8 +93,8 @@ export const useTranslation = () => {
93
93
  const podcast = await podcastApi.get(translationData.podcastId);
94
94
  const emission = podcast.emission;
95
95
 
96
- const baseLangConfig = translationConfig(baseLanguage, emission);
97
- const defaultLangConfig = translationConfig(DEFAULT_LANGUAGE, emission);
96
+ const baseLangConfig = getTranslationConfig(baseLanguage, emission);
97
+ const defaultLangConfig = getTranslationConfig(DEFAULT_LANGUAGE, emission);
98
98
 
99
99
  // 2. If the language of the browser is available, use it
100
100
  if (baseLangConfig === CreateTranslation.ALWAYS) {
@@ -169,6 +169,7 @@ export const useTranslation = () => {
169
169
  return {
170
170
  convertSrtToPlainText,
171
171
  getMostRelevantLanguage,
172
- getMostRelevantTranslation
172
+ getMostRelevantTranslation,
173
+ getTranslationConfig
173
174
  }
174
175
  };
@@ -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
 
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 => {