diff --git a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx index 7b74e676d..10977d55f 100644 --- a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx +++ b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx @@ -77,7 +77,11 @@ beforeEach(() => { vi.setSystemTime(NOW); client.queryCalendarEvents.mockReset(); calendarHolds([]); - createEvent.mockReset().mockImplementation(async (_client, event: Partial) => ({ ...event, id: `new-${event.uid}` })); + createEvent.mockReset().mockImplementation(async (_client, event: Partial) => { + const created = { ...event, id: `new-${event.uid}` } as CalendarEvent; + useCalendarStore.setState((s) => ({ events: [...s.events, created] })); + return created; + }); updateEvent.mockReset().mockResolvedValue(true); deleteEvent.mockReset().mockResolvedValue(true); setCalendarGate(true); @@ -148,6 +152,9 @@ describe('useBookingCalendar', () => { const [, id, patch, scheduling] = updateEvent.mock.calls[0]; expect(id).toBe('e1'); expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' }); + // The stored description isn't the generated one any more: title and notes stay the user's. + expect(patch).not.toHaveProperty('title'); + expect(patch).not.toHaveProperty('description'); expect(scheduling).toBeUndefined(); expect(result.current.status).toBe('in-calendar'); }); @@ -241,12 +248,60 @@ describe('useBookingCalendar', () => { }); it('says why an add failed', async () => { - createEvent.mockResolvedValue(null); + createEvent.mockImplementation(async () => { + useCalendarStore.setState({ error: 'Failed to create event' }); + return null; + }); const { result } = await settled(await bookingFrom([OUT])); await act(() => result.current.add()); expect(result.current.error).toBe('action_failed'); expect(result.current.status).toBe('add'); }); + + it('says so when a plugin stops an add, an update or a removal', async () => { + createEvent.mockResolvedValue(null); + const fresh = await settled(await bookingFrom([OUT])); + await act(() => fresh.result.current.add()); + expect(fresh.result.current).toMatchObject({ notice: 'blocked', error: null, status: 'add' }); + + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + updateEvent.mockResolvedValue(false); + const moved = await settled(await bookingFrom([OUT_MOVED])); + await act(() => moved.result.current.update()); + expect(moved.result.current).toMatchObject({ notice: 'blocked', status: 'update' }); + + deleteEvent.mockResolvedValue(false); + const cancelled = await settled(await bookingFrom([OUT_CANCELLED])); + await act(() => cancelled.result.current.remove()); + expect(cancelled.result.current).toMatchObject({ notice: 'blocked', status: 'remove' }); + }); + + it('shows a booking added from another card for the same booking as in the calendar', async () => { + const booking = await bookingFrom([OUT]); + const first = await settled(booking); + const second = await settled(await bookingFrom([OUT])); + expect(first.result.current.status).toBe('add'); + expect(second.result.current.status).toBe('add'); + await act(() => second.result.current.add()); + expect(second.result.current.status).toBe('in-calendar'); + await waitFor(() => expect(first.result.current.status).toBe('in-calendar')); + expect(createEvent).toHaveBeenCalledTimes(1); + }); + + it('shows a booking removed from another card as gone', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + deleteEvent.mockImplementation(async (_client, id: string) => { + useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) })); + return true; + }); + const first = await settled(await bookingFrom([OUT_CANCELLED])); + const second = await settled(await bookingFrom([OUT_CANCELLED])); + expect(first.result.current.status).toBe('remove'); + await act(() => second.result.current.remove()); + await waitFor(() => expect(first.result.current.status).toBe('cancelled')); + }); }); describe('BookingCalendarActions', () => { @@ -272,6 +327,53 @@ describe('BookingCalendarActions', () => { expect(await screen.findByText('added')).toBeTruthy(); }); + async function openPicker() { + useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work'), cal('Travel')] }); + render(); + const trigger = await screen.findByRole('button', { name: /add_to_calendar/ }); + fireEvent.click(trigger); + return { trigger, menu: screen.getByRole('menu') }; + } + + it('moves focus into the picker and through it with the arrow keys, Home and End', async () => { + const { menu } = await openPicker(); + const items = screen.getAllByRole('menuitem'); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: 'End' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(document.activeElement).toBe(items[0]); + expect(menu.querySelector('div')?.getAttribute('role')).toBe('presentation'); + }); + + it('closes the picker on Escape and gives focus back to its button', async () => { + const { trigger, menu } = await openPicker(); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('stays open while its own list scrolls, and closes when the page scrolls', async () => { + const { menu } = await openPicker(); + fireEvent.scroll(menu); + expect(screen.queryByRole('menu')).not.toBeNull(); + fireEvent.scroll(window); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('puts focus on the outcome after a pick', async () => { + await openPicker(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Work' })); + await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('status')); + expect(document.activeElement?.textContent).toBe('added'); + }); + it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => { useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] }); const width = window.innerWidth; diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx index 657229845..217031697 100644 --- a/components/email/structured-mail/booking-calendar-actions.tsx +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'next-intl'; import { @@ -53,35 +53,77 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured const [picker, setPicker] = useState(null); const triggerRef = useRef(null); const pickerRef = useRef(null); + const rowRef = useRef(null); + // An action was started from here: once it is done, focus goes to its + // outcome instead of being lost with the button or picker that vanished. + const focusAfterAction = useRef(false); const closePicker = useCallback(() => setPicker(null), []); + const pickerOpen = picker !== null; useEffect(() => { - if (!picker) return; + if (!pickerOpen) return; + pickerRef.current?.querySelector('[role="menuitem"]')?.focus(); const onPointer = (event: MouseEvent | TouchEvent) => { const target = event.target as Node; if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return; closePicker(); }; + // Scrolling the list itself must not close it; scrolling the page moves the trigger away. + const onScroll = (event: Event) => { + if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return; + closePicker(); + }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { closePicker(); triggerRef.current?.focus(); } }; - window.addEventListener('scroll', closePicker, true); + window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', closePicker); document.addEventListener('mousedown', onPointer); document.addEventListener('touchstart', onPointer); document.addEventListener('keydown', onKey); return () => { - window.removeEventListener('scroll', closePicker, true); + window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', closePicker); document.removeEventListener('mousedown', onPointer); document.removeEventListener('touchstart', onPointer); document.removeEventListener('keydown', onKey); }; - }, [picker, closePicker]); + }, [pickerOpen, closePicker]); + + useEffect(() => { + if (busy || !focusAfterAction.current) return; + focusAfterAction.current = false; + const row = rowRef.current; + if (!row) return; + const target = row.querySelector('[role="alert"]') + ?? row.querySelector('[role="status"]') + ?? row.querySelector('button'); + target?.focus(); + }, [busy, status, notice, error]); + + const onMenuKey = (event: ReactKeyboardEvent) => { + const items = Array.from(pickerRef.current?.querySelectorAll('[role="menuitem"]') ?? []); + if (items.length === 0) return; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number | null = null; + if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length; + else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'Tab') closePicker(); + if (next === null) return; + event.preventDefault(); + items[next].focus(); + }; + + const act = (action: () => Promise) => { + focusAfterAction.current = true; + void action(); + }; if (status === 'hidden' || status === 'loading') return null; // A cancelled booking with nothing in the calendar has no action; only the @@ -90,7 +132,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured const onAdd = () => { if (calendars.length <= 1) { - void calendar.add(calendars[0]?.id); + act(() => calendar.add(calendars[0]?.id)); return; } if (picker) { @@ -101,10 +143,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured }; const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null; + const blocked = notice === 'blocked'; const showView = status === 'in-calendar' || status === 'update'; return ( -
+
{status === 'add' && ( <> @@ -160,7 +205,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured {status === 'remove' && (
); diff --git a/components/email/structured-mail/use-booking-calendar.ts b/components/email/structured-mail/use-booking-calendar.ts index 5c30ea378..1d0c2800e 100644 --- a/components/email/structured-mail/use-booking-calendar.ts +++ b/components/email/structured-mail/use-booking-calendar.ts @@ -41,8 +41,8 @@ export interface BookingCalendar { busy: boolean; /** What went wrong with the last action, in the user's language. */ error: string | null; - /** What the last action did, for a short confirmation. */ - notice: 'added' | 'updated' | 'removed' | null; + /** What the last action did, for a short confirmation; `blocked` when a plugin stopped it. */ + notice: 'added' | 'updated' | 'removed' | 'blocked' | null; /** Add every live event, into `calendarId` or the default calendar. */ add: (calendarId?: string) => Promise; /** Add the missing live events, update the changed ones, remove the cancelled ones. */ @@ -52,6 +52,13 @@ export interface BookingCalendar { viewInCalendar: () => void; } +/** + * Uids whose events a card removed in this session. Another card for the + * same booking (a conversation showing two of its mails) may still hold the + * event from its own lookup; this tells it the event is gone. + */ +const removedUids = new Set(); + type Lookup = { key: string; failed: false; events: Record } | { key: string; failed: true }; interface Plan { @@ -174,6 +181,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e const events: Record = {}; uids.forEach((uid, i) => { events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null; + if (events[uid]) removedUids.delete(uid); }); setLookup({ key: uidKey, failed: false, events }); const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null); @@ -200,11 +208,14 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null; const existingOf = useCallback((uid: string): CalendarEvent | null => { + if (removedUids.has(uid)) return null; const stored = known?.[uid]; - if (!stored) return null; // The store's copy of the same event is fresher after a change elsewhere. - return storeEvents.find((e) => e.id === stored.id) ?? stored; - }, [known, storeEvents]); + if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored; + // Added since the lookup, maybe by another card for the same booking. + return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared + && (!e.localAccountId || e.localAccountId === activeAccountId)) ?? null; + }, [known, storeEvents, activeAccountId]); const plan = useMemo(() => { if (!active) return { status: 'hidden', live: [], stale: [] }; @@ -219,14 +230,17 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev)); }, [uidKey]); - /** Run one action; on failure say why and look the events up again. */ + /** + * Run one action. `work` resolves to false when a plugin stopped a step, + * which is said as such; on failure say why and look the events up again. + */ const run = useCallback(async (done: NonNullable, work: () => Promise) => { if (!client || busy) return; setBusy(true); setError(null); setNotice(null); try { - if (await work()) setNotice(done); + setNotice((await work()) ? done : 'blocked'); } catch (err) { setError(describeError(err, t('action_failed'))); setRefresh((n) => n + 1); @@ -239,9 +253,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!client) return false; for (const { slot, existing } of plan.live) { if (existing) continue; + // createEvent answers null both when a plugin stops it and when the + // server refuses (the uid exists after all, say); only the latter sets + // the store's error. + useCalendarStore.setState({ error: null }); const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } }); - // null: refused (a plugin, or the server, e.g. the uid exists after all). - if (!created) throw new Error('create failed'); + if (!created) { + if (useCalendarStore.getState().error) throw new Error('create failed'); + return false; + } + removedUids.delete(slot.uid); remember(slot.uid, created); } return true; @@ -250,7 +271,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e const removeStale = useCallback(async (): Promise => { if (!client) return false; for (const { slot, existing } of plan.stale) { - if (!(await deleteEvent(client, existing.id))) return false; + // Marked before the store drops the event, so other cards that + // re-render on that change already see it gone. + removedUids.add(slot.uid); + let removed = false; + try { + removed = await deleteEvent(client, existing.id); + } finally { + if (!removed) removedUids.delete(slot.uid); + } + if (!removed) return false; remember(slot.uid, null); } return true; @@ -266,7 +296,9 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!client) return false; for (const { slot, existing } of plan.live) { if (!existing || !eventNeedsUpdate(existing, slot.event as Partial)) continue; - const patch = bookingEventPatch(existing, slot.event as Partial); + // Only what moved; title and notes the user changed stay. + const patch = bookingEventPatch(existing, slot.event as Partial, senderDomain); + if (Object.keys(patch).length === 0) continue; if (!(await updateEvent(client, existing.id, patch))) return false; const updated = { ...existing, ...patch } as CalendarEvent; remember(slot.uid, updated); @@ -281,7 +313,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!(await createMissing(target))) return false; } return removeStale(); - }), [run, client, plan.live, targets, updateEvent, remember, createMissing, removeStale]); + }), [run, client, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]); const remove = useCallback(() => run('removed', removeStale), [run, removeStale]); diff --git a/locales/ar/common.json b/locales/ar/common.json index f8a91e8b7..34d13ac31 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5963,6 +5963,7 @@ "updated": "تم تحديث التقويم", "removed": "تمت الإزالة من تقويمك", "action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.", + "blocked": "أوقفت إضافةٌ هذا التغيير في التقويم.", "title": { "rental_car": "سيارة مستأجرة", "rental_car_company": "سيارة مستأجرة: {company}" diff --git a/locales/ca/common.json b/locales/ca/common.json index 06f73178c..260b3d728 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5963,6 +5963,7 @@ "updated": "S’ha actualitzat el calendari", "removed": "S’ha tret del vostre calendari", "action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.", + "blocked": "Un connector ha impedit aquest canvi al calendari.", "title": { "rental_car": "Cotxe de lloguer", "rental_car_company": "Cotxe de lloguer: {company}" diff --git a/locales/cs/common.json b/locales/cs/common.json index d64744cf2..1b5f5f140 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendář aktualizován", "removed": "Odebráno z vašeho kalendáře", "action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.", + "blocked": "Tuto změnu kalendáře zastavil plugin.", "title": { "rental_car": "Pronájem auta", "rental_car_company": "Pronájem auta: {company}" diff --git a/locales/da/common.json b/locales/da/common.json index 5abf5b6f5..9422d3b7d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender opdateret", "removed": "Fjernet fra din kalender", "action_failed": "Din kalender kunne ikke ændres. Prøv igen.", + "blocked": "Et plugin stoppede denne ændring i kalenderen.", "title": { "rental_car": "Lejebil", "rental_car_company": "Lejebil: {company}" diff --git a/locales/de/common.json b/locales/de/common.json index 6572b214e..5750463d6 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender aktualisiert", "removed": "Aus Ihrem Kalender entfernt", "action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + "blocked": "Ein Plugin hat diese Kalenderänderung verhindert.", "title": { "rental_car": "Mietwagen", "rental_car_company": "Mietwagen: {company}" diff --git a/locales/en/common.json b/locales/en/common.json index f6e512ea1..c7b0120c2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendar updated", "removed": "Removed from your calendar", "action_failed": "Couldn't change your calendar. Try again.", + "blocked": "A plugin stopped this calendar change.", "title": { "rental_car": "Rental car", "rental_car_company": "Rental car: {company}" diff --git a/locales/es/common.json b/locales/es/common.json index f7cc461b1..7fb9d07be 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendario actualizado", "removed": "Quitado de tu calendario", "action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.", + "blocked": "Un complemento ha impedido este cambio en el calendario.", "title": { "rental_car": "Coche de alquiler", "rental_car_company": "Coche de alquiler: {company}" diff --git a/locales/et/common.json b/locales/et/common.json index f14c71c20..4e7881bbe 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender uuendatud", "removed": "Eemaldatud sinu kalendrist", "action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.", + "blocked": "Pistikprogramm peatas selle kalendrimuudatuse.", "title": { "rental_car": "Rendiauto", "rental_car_company": "Rendiauto: {company}" diff --git a/locales/fa/common.json b/locales/fa/common.json index ba90429d4..48e8d82a2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5963,6 +5963,7 @@ "updated": "تقویم به‌روزرسانی شد", "removed": "از تقویم شما حذف شد", "action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.", + "blocked": "یک افزونه این تغییر تقویم را متوقف کرد.", "title": { "rental_car": "خودروی اجاره‌ای", "rental_car_company": "خودروی اجاره‌ای: {company}" diff --git a/locales/fr/common.json b/locales/fr/common.json index 91b97baad..1f33ac187 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendrier mis à jour", "removed": "Retiré de votre calendrier", "action_failed": "Impossible de modifier votre calendrier. Réessayez.", + "blocked": "Un plugin a empêché cette modification du calendrier.", "title": { "rental_car": "Voiture de location", "rental_car_company": "Voiture de location : {company}" diff --git a/locales/he/common.json b/locales/he/common.json index f2ec6680f..b909a9f6b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5964,6 +5964,7 @@ "updated": "היומן עודכן", "removed": "הוסר מהיומן שלך", "action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.", + "blocked": "תוסף עצר את השינוי הזה ביומן.", "title": { "rental_car": "רכב שכור", "rental_car_company": "רכב שכור: {company}" diff --git a/locales/hu/common.json b/locales/hu/common.json index 66941bfb6..074d194cd 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5963,6 +5963,7 @@ "updated": "Naptár frissítve", "removed": "Eltávolítva a naptáradból", "action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.", + "blocked": "Egy bővítmény megakadályozta ezt a naptárváltozást.", "title": { "rental_car": "Bérautó", "rental_car_company": "Bérautó: {company}" diff --git a/locales/it/common.json b/locales/it/common.json index 12ddd4328..8c3201749 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendario aggiornato", "removed": "Rimosso dal tuo calendario", "action_failed": "Impossibile modificare il calendario. Riprova.", + "blocked": "Un plugin ha impedito questa modifica al calendario.", "title": { "rental_car": "Auto a noleggio", "rental_car_company": "Auto a noleggio: {company}" diff --git a/locales/ja/common.json b/locales/ja/common.json index 5f8b9f2df..24b42afab 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5963,6 +5963,7 @@ "updated": "カレンダーを更新しました", "removed": "カレンダーから削除しました", "action_failed": "カレンダーを変更できませんでした。もう一度お試しください。", + "blocked": "プラグインがこのカレンダーの変更を止めました。", "title": { "rental_car": "レンタカー", "rental_car_company": "レンタカー: {company}" diff --git a/locales/ko/common.json b/locales/ko/common.json index a4b9e68e1..6ec65a8ae 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5963,6 +5963,7 @@ "updated": "캘린더가 업데이트되었어요", "removed": "캘린더에서 제거되었어요", "action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.", + "blocked": "플러그인이 이 캘린더 변경을 막았어요.", "title": { "rental_car": "렌터카", "rental_car_company": "렌터카: {company}" diff --git a/locales/lv/common.json b/locales/lv/common.json index 49676a909..45676e542 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendārs atjaunināts", "removed": "Noņemts no jūsu kalendāra", "action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.", + "blocked": "Spraudnis apturēja šīs kalendāra izmaiņas.", "title": { "rental_car": "Nomas auto", "rental_car_company": "Nomas auto: {company}" diff --git a/locales/mn/common.json b/locales/mn/common.json index 765452785..895b0ca85 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5963,6 +5963,7 @@ "updated": "Хуанли шинэчлэгдлээ", "removed": "Таны хуанлиас хассан", "action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.", + "blocked": "Нэмэлт өргөтгөл энэ хуанлийн өөрчлөлтийг зогсоосон.", "title": { "rental_car": "Түрээсийн машин", "rental_car_company": "Түрээсийн машин: {company}" diff --git a/locales/nb/common.json b/locales/nb/common.json index 15cba65f7..307ab49ba 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalenderen er oppdatert", "removed": "Fjernet fra kalenderen din", "action_failed": "Kunne ikke endre kalenderen. Prøv igjen.", + "blocked": "Et programtillegg stoppet denne endringen i kalenderen.", "title": { "rental_car": "Leiebil", "rental_car_company": "Leiebil: {company}" diff --git a/locales/nl/common.json b/locales/nl/common.json index 68b4f213b..7261c9b13 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5963,6 +5963,7 @@ "updated": "Agenda bijgewerkt", "removed": "Uit je agenda verwijderd", "action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.", + "blocked": "Een plug-in heeft deze agendawijziging tegengehouden.", "title": { "rental_car": "Huurauto", "rental_car_company": "Huurauto: {company}" diff --git a/locales/pl/common.json b/locales/pl/common.json index 6144b0b4c..064d7df23 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5963,6 +5963,7 @@ "updated": "Zaktualizowano kalendarz", "removed": "Usunięto z Twojego kalendarza", "action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.", + "blocked": "Wtyczka zablokowała tę zmianę w kalendarzu.", "title": { "rental_car": "Wynajem samochodu", "rental_car_company": "Wynajem samochodu: {company}" diff --git a/locales/pt/common.json b/locales/pt/common.json index 129db2d24..19bb19a43 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendário atualizado", "removed": "Removido do seu calendário", "action_failed": "Não foi possível alterar seu calendário. Tente novamente.", + "blocked": "Um plugin impediu esta alteração no calendário.", "title": { "rental_car": "Carro alugado", "rental_car_company": "Carro alugado: {company}" diff --git a/locales/ro/common.json b/locales/ro/common.json index 942f61bf2..64bc9945a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendar actualizat", "removed": "Eliminat din calendarul dvs.", "action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.", + "blocked": "Un plugin a oprit această modificare în calendar.", "title": { "rental_car": "Mașină închiriată", "rental_car_company": "Mașină închiriată: {company}" diff --git a/locales/ru/common.json b/locales/ru/common.json index 49f2f69dc..c6b3973d5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5963,6 +5963,7 @@ "updated": "Календарь обновлён", "removed": "Удалено из вашего календаря", "action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.", + "blocked": "Плагин остановил это изменение календаря.", "title": { "rental_car": "Аренда автомобиля", "rental_car_company": "Аренда автомобиля: {company}" diff --git a/locales/sk/common.json b/locales/sk/common.json index 1a89c85fd..e3ae59ba2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendár bol aktualizovaný", "removed": "Odstránené z vášho kalendára", "action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.", + "blocked": "Túto zmenu kalendára zastavil doplnok.", "title": { "rental_car": "Prenájom auta", "rental_car_company": "Prenájom auta: {company}" diff --git a/locales/th/common.json b/locales/th/common.json index e1fc28df3..f0559d5d1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -5971,6 +5971,7 @@ "updated": "อัปเดตปฏิทินแล้ว", "removed": "นำออกจากปฏิทินของคุณแล้ว", "action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง", + "blocked": "ปลั๊กอินหยุดการเปลี่ยนแปลงปฏิทินนี้", "title": { "rental_car": "รถเช่า", "rental_car_company": "รถเช่า: {company}" diff --git a/locales/tr/common.json b/locales/tr/common.json index 6f09641bd..797b8e4cf 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5963,6 +5963,7 @@ "updated": "Takvim güncellendi", "removed": "Takviminizden kaldırıldı", "action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.", + "blocked": "Bir eklenti bu takvim değişikliğini durdurdu.", "title": { "rental_car": "Kiralık araç", "rental_car_company": "Kiralık araç: {company}" diff --git a/locales/uk/common.json b/locales/uk/common.json index 63ba95386..1936daefd 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5963,6 +5963,7 @@ "updated": "Календар оновлено", "removed": "Видалено з вашого календаря", "action_failed": "Не вдалося змінити календар. Спробуйте ще раз.", + "blocked": "Плагін зупинив цю зміну календаря.", "title": { "rental_car": "Оренда авто", "rental_car_company": "Оренда авто: {company}" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1105b77df..19007ee2a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5963,6 +5963,7 @@ "updated": "行事曆已更新", "removed": "已從您的行事曆移除", "action_failed": "無法變更您的行事曆,請再試一次。", + "blocked": "某個外掛程式阻止了此行事曆變更。", "title": { "rental_car": "租車", "rental_car_company": "租車:{company}" diff --git a/locales/zh/common.json b/locales/zh/common.json index 94f267ae0..c09f5b0d3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5963,6 +5963,7 @@ "updated": "日历已更新", "removed": "已从你的日历中移除", "action_failed": "无法更改你的日历,请重试。", + "blocked": "某个插件阻止了此日历更改。", "title": { "rental_car": "租车", "rental_car_company": "租车:{company}"