feat(calendar): say whom a calendar is shared with

The sidebar showed the same people icon for any calendar with a share,
labelled "Share calendar", and the settings badge only gave a count, so
there was no way to tell from either who could see the calendar. A share
granted to a service account read like a share with other people.

Both now carry a hint listing the recipients ("Shared with
bot@example.com (Booking robot)"), resolved from the principals the
server lists. Until every recipient is known the hint falls back to the
count. Principals are fetched only when a calendar on screen is shared.

The per-client principal cache of the attendee availability check moves
to lib/jmap/principal-cache.ts so both use it. It no longer keeps an
empty or failed answer: the next caller asks again instead of seeing
no principals for the rest of the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-10-15 22:19:53 +02:00
co-authored by Claude Opus 5.5
parent c349e8e288
commit 8cf6783cdb
35 changed files with 326 additions and 22 deletions
@@ -0,0 +1,83 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, Principal } from '@/lib/jmap/types';
// Show interpolated values, so the tests can read whom a calendar is shared with.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key} ${Object.values(values).join(' ')}` : key,
useLocale: () => 'en',
}));
function cal(overrides: Partial<Calendar>): Calendar {
return {
id: 'c', name: 'Cal', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayWriteOwn: true, mayShare: true, mayDelete: true },
...overrides,
} as unknown as Calendar;
}
const rights = { mayReadItems: true };
function clientWith(principals: Principal[]): IJMAPClient {
return {
getAccountId: () => 'acct',
getPrincipals: vi.fn().mockResolvedValue(principals),
} as unknown as IJMAPClient;
}
describe('CalendarSidebarPanel shared calendars', () => {
it('names whom a calendar is shared with', async () => {
const client = clientWith([
{ id: 'g', type: 'individual', name: 'bot@example.com', email: 'bot@example.com', description: 'Booking robot' },
{ id: 'h', type: 'individual', name: 'alice', email: 'alice@example.com', description: null },
]);
render(
<CalendarSidebarPanel
calendars={[cal({ id: 's', name: 'Meetings', shareWith: { g: rights, h: rights } as never })]}
selectedCalendarIds={[]}
onToggleVisibility={() => {}}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('calendar-shared-indicator')).toHaveAttribute(
'title',
'shared_with bot@example.com (Booking robot), alice@example.com',
);
});
});
it('falls back to a count while a recipient is unknown', async () => {
const client = clientWith([]);
render(
<CalendarSidebarPanel
calendars={[cal({ id: 's', name: 'Meetings', shareWith: { g: rights } as never })]}
selectedCalendarIds={[]}
onToggleVisibility={() => {}}
client={client}
/>,
);
await waitFor(() => expect(client.getPrincipals).toHaveBeenCalled());
expect(screen.getByTestId('calendar-shared-indicator')).toHaveAttribute('title', 'shared_with_count 1');
});
it('fetches no principals when nothing is shared', () => {
const client = clientWith([]);
render(
<CalendarSidebarPanel
calendars={[cal({ id: 'a', name: 'Work' })]}
selectedCalendarIds={[]}
onToggleVisibility={() => {}}
client={client}
/>,
);
expect(client.getPrincipals).not.toHaveBeenCalled();
expect(screen.queryByTestId('calendar-shared-indicator')).toBeNull();
});
});
+24 -6
View File
@@ -18,6 +18,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels";
/**
* Split a per-account calendar list into "owned" (the user's own) and
@@ -139,6 +140,20 @@ export function CalendarSidebarPanel({
const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]);
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]);
// Names for the "shared with" hint. Principal ids belong to one server, so
// only calendars of the account `client` talks to are resolved.
const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId;
const hasSharedCalendar = eventCalendars.some(
(c) => !c.isShared && Object.keys(c.shareWith ?? {}).length > 0 && resolvesShares(c),
);
const shareLabels = useShareRecipientLabels(client, hasSharedCalendar);
const sharedWithLabel = (cal: Calendar) => {
const names = resolvesShares(cal) ? shareRecipientNames(cal.shareWith, shareLabels) : null;
return names
? tMgmt('shared_with', { names: names.join(', ') })
: tMgmt('shared_with_count', { count: Object.keys(cal.shareWith ?? {}).length });
};
const sharedAccountGroups = useMemo(() => {
const shared = eventCalendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
@@ -286,12 +301,15 @@ export function CalendarSidebarPanel({
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
<Users
className="w-3 h-3 text-muted-foreground flex-shrink-0"
aria-label={tMgmt('share')}
/>
)}
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (() => {
const label = sharedWithLabel(cal);
return (
<span className="flex-shrink-0 flex items-center" title={label} data-testid="calendar-shared-indicator">
<Users className="w-3 h-3 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{label}</span>
</span>
);
})()}
</button>
{hasMenu && (
<button
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2 } from "@/components/icons";
import { useAuthStore } from "@/stores/auth-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { cachedPrincipals } from "@/lib/jmap/principal-cache";
import type { BusyPeriod, Principal } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -42,17 +42,6 @@ export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date):
return status;
}
const principalCache = new WeakMap<IJMAPClient, Promise<Principal[]>>();
function principalsOf(client: IJMAPClient): Promise<Principal[]> {
let cached = principalCache.get(client);
if (!cached) {
cached = client.getPrincipals().catch(() => [] as Principal[]);
principalCache.set(client, cached);
}
return cached;
}
/**
* Free/busy of the invited attendees for the event window
* (Principal/getAvailability). Only attendees that are principals on this
@@ -84,7 +73,7 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv
// Debounce: typing a time or adding several people must not fire a
// query per keystroke.
const timer = setTimeout(async () => {
const principals = await principalsOf(client);
const principals = await cachedPrincipals(client);
const byEmail = new Map<string, Principal>();
for (const p of principals) {
if (p.email) byEmail.set(p.email.toLowerCase(), p);
@@ -0,0 +1,43 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Calendar } from '@/lib/jmap/types';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key} ${Object.values(values).join(' ')}` : key,
useLocale: () => 'en',
}));
const client = {
getAccountId: () => 'acct',
getPrincipals: vi.fn().mockResolvedValue([
{ id: 'g', type: 'individual', name: 'bot@example.com', email: 'bot@example.com', description: 'Booking robot' },
]),
};
beforeEach(() => {
useAuthStore.setState({ client: client as never, serverUrl: null as never, username: null as never });
useCalendarStore.setState({
calendars: [{
id: 'rdv', name: 'Meetings', color: '#3b82f6', sortOrder: 1, isDefault: false, isShared: false,
shareWith: { g: { mayReadItems: true } },
myRights: { mayWriteAll: true, mayWriteOwn: true, mayShare: true, mayDelete: true },
} as unknown as Calendar],
icalSubscriptions: [],
} as never);
});
describe('Calendar settings share badge', () => {
it('names whom the calendar is shared with', async () => {
render(<CalendarManagementSettings />);
await waitFor(() => {
expect(screen.getByTestId('calendar-shared-indicator')).toHaveAttribute(
'title',
'shared_with bot@example.com (Booking robot)',
);
});
});
});
@@ -19,9 +19,11 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useAccountStore } from '@/stores/account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
import { IS_LITE } from '@/lib/lite';
import { shareRecipientNames, useShareRecipientLabels } from '@/hooks/use-share-recipient-labels';
function CalendarColorPicker({
value,
@@ -265,6 +267,15 @@ export function CalendarManagementSettings() {
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null);
// Principal ids belong to one server: only calendars of the account
// `client` talks to get their recipients named.
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId;
const shareLabels = useShareRecipientLabels(
client,
calendars.some(cal => !cal.isShared && Object.keys(cal.shareWith ?? {}).length > 0 && resolvesShares(cal)),
);
// Load calendars if not yet loaded
useEffect(() => {
if (client && calendars.length === 0) {
@@ -646,13 +657,19 @@ export function CalendarManagementSettings() {
{(() => {
const shareCount = Object.keys(cal.shareWith || {}).length;
if (shareCount === 0 || cal.isShared) return null;
const names = resolvesShares(cal) ? shareRecipientNames(cal.shareWith, shareLabels) : null;
const label = names
? t('shared_with', { names: names.join(', ') })
: t('shared_with_count', { count: shareCount });
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t('share')}
title={label}
data-testid="calendar-shared-indicator"
>
<Users className="w-3 h-3" />
{shareCount}
<Users className="w-3 h-3" aria-hidden="true" />
<span aria-hidden="true">{shareCount}</span>
<span className="sr-only">{label}</span>
</span>
);
})()}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Principal } from '@/lib/jmap/types';
import { cachedPrincipals } from '@/lib/jmap/principal-cache';
/** How a share recipient is named: its address, then its description. */
export function principalLabel(principal: Principal): string {
const address = principal.email || principal.name || principal.id;
return principal.description && principal.description !== address
? `${address} (${principal.description})`
: address;
}
/**
* The names of the principals in `shareWith`, or null while any of them is
* unknown (principals not loaded yet, or not listed by the server).
*/
export function shareRecipientNames(
shareWith: Record<string, unknown> | null | undefined,
labels: ReadonlyMap<string, string>,
): string[] | null {
const names = Object.keys(shareWith ?? {}).map((id) => labels.get(id));
return names.every((name): name is string => !!name) ? names : null;
}
/**
* Labels of the server's principals by id, so a shared collection can say
* whom it is shared with. Nothing is fetched until `enabled`, i.e. until
* something on screen is shared.
*/
export function useShareRecipientLabels(client: IJMAPClient | null | undefined, enabled: boolean): ReadonlyMap<string, string> {
const [labels, setLabels] = useState<ReadonlyMap<string, string>>(() => new Map());
useEffect(() => {
if (!client || !enabled) return;
let cancelled = false;
void cachedPrincipals(client).then((principals) => {
if (!cancelled) setLabels(new Map(principals.map((p) => [p.id, principalLabel(p)])));
});
return () => { cancelled = true; };
}, [client, enabled]);
return labels;
}
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { cachedPrincipals } from '../principal-cache';
import type { IJMAPClient } from '../client-interface';
import type { Principal } from '../types';
const alice: Principal = { id: 'a', type: 'individual', name: 'alice', email: 'alice@example.com' };
describe('cachedPrincipals', () => {
it('asks the server once per client', async () => {
const getPrincipals = vi.fn().mockResolvedValue([alice]);
const client = { getPrincipals } as unknown as IJMAPClient;
expect(await cachedPrincipals(client)).toEqual([alice]);
expect(await cachedPrincipals(client)).toEqual([alice]);
expect(getPrincipals).toHaveBeenCalledTimes(1);
});
it('does not keep an empty or failed answer, so the next caller asks again', async () => {
const getPrincipals = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce([])
.mockResolvedValueOnce([alice]);
const client = { getPrincipals } as unknown as IJMAPClient;
expect(await cachedPrincipals(client)).toEqual([]);
expect(await cachedPrincipals(client)).toEqual([]);
expect(await cachedPrincipals(client)).toEqual([alice]);
expect(await cachedPrincipals(client)).toEqual([alice]);
expect(getPrincipals).toHaveBeenCalledTimes(3);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Principal } from '@/lib/jmap/types';
const principalCache = new WeakMap<IJMAPClient, Promise<Principal[]>>();
/**
* The principals the server lists, fetched once per client: they rarely change
* during a session, and the directory can take several requests to page
* through. An empty answer (failed fetch, client not signed in yet) is not
* kept, so the next caller asks again.
*/
export function cachedPrincipals(client: IJMAPClient): Promise<Principal[]> {
let cached = principalCache.get(client);
if (!cached) {
cached = Promise.resolve()
.then(() => client.getPrincipals())
.catch(() => [] as Principal[])
.then((principals) => {
if (principals.length === 0) principalCache.delete(client);
return principals;
});
principalCache.set(client, cached);
}
return cached;
}
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "تم نسخ رابط CalDAV إلى الحافظة",
"share": "مشاركة التقويم",
"new_event_in_calendar": "حدث جديد في هذا التقويم",
"shared_with": "مشترك مع {names}",
"shared_with_count": "{count, plural, zero {غير مشترك مع أحد} one {مشترك مع شخص واحد} two {مشترك مع شخصين} few {مشترك مع # أشخاص} many {مشترك مع # شخصًا} other {مشترك مع # شخص}}",
"kind": "المحتوى",
"kind_events": "الأحداث",
"kind_tasks": "المهام",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "URL de CalDAV copiat al porta-retalls",
"share": "Comparteix el calendari",
"new_event_in_calendar": "Esdeveniment nou en aquest calendari",
"shared_with": "Compartit amb {names}",
"shared_with_count": "{count, plural, one {Compartit amb 1 persona} other {Compartit amb # persones}}",
"kind": "Contingut",
"kind_events": "Esdeveniments",
"kind_tasks": "Tasques",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "URL CalDAV zkopírováno do schránky",
"share": "Sdílet kalendář",
"new_event_in_calendar": "Nová událost v tomto kalendáři",
"shared_with": "Sdíleno s: {names}",
"shared_with_count": "{count, plural, one {Sdíleno s 1 osobou} few {Sdíleno s # osobami} many {Sdíleno s # osoby} other {Sdíleno s # osobami}}",
"kind": "Obsah",
"kind_events": "Události",
"kind_tasks": "Úkoly",
+2
View File
@@ -3457,6 +3457,8 @@
"url_copied": "CalDAV-URL kopieret til udklipsholder",
"share": "Del kalender",
"new_event_in_calendar": "Ny begivenhed i denne kalender",
"shared_with": "Delt med {names}",
"shared_with_count": "{count, plural, one {Delt med 1 person} other {Delt med # personer}}",
"kind": "Indhold",
"kind_events": "Begivenheder",
"kind_tasks": "Opgaver",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
"share": "Kalender freigeben",
"new_event_in_calendar": "Neuer Termin in diesem Kalender",
"shared_with": "Geteilt mit {names}",
"shared_with_count": "{count, plural, one {Mit 1 Person geteilt} other {Mit # Personen geteilt}}",
"kind": "Inhalt",
"kind_events": "Termine",
"kind_tasks": "Aufgaben",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL copied to clipboard",
"share": "Share calendar",
"new_event_in_calendar": "New event in this calendar",
"shared_with": "Shared with {names}",
"shared_with_count": "{count, plural, one {Shared with 1 person} other {Shared with # people}}",
"kind": "Contents",
"kind_events": "Events",
"kind_tasks": "Tasks",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "No se pudieron borrar los eventos del calendario",
"share": "Compartir calendario",
"new_event_in_calendar": "Nuevo evento en este calendario",
"shared_with": "Compartido con {names}",
"shared_with_count": "{count, plural, one {Compartido con 1 persona} other {Compartido con # personas}}",
"kind": "Contenido",
"kind_events": "Eventos",
"kind_tasks": "Tareas",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "آدرس CalDAV در کلیپ‌بورد کپی شد",
"share": "اشتراک‌گذاری تقویم",
"new_event_in_calendar": "رویداد جدید در این تقویم",
"shared_with": "به اشتراک گذاشته شده با {names}",
"shared_with_count": "{count, plural, one {با ۱ نفر به اشتراک گذاشته شده} other {با # نفر به اشتراک گذاشته شده}}",
"kind": "محتوا",
"kind_events": "رویدادها",
"kind_tasks": "وظایف",
+2
View File
@@ -3457,6 +3457,8 @@
"error_clear": "Impossible de supprimer les événements du calendrier",
"share": "Partager le calendrier",
"new_event_in_calendar": "Nouvel événement dans ce calendrier",
"shared_with": "Partagé avec {names}",
"shared_with_count": "{count, plural, one {Partagé avec 1 personne} other {Partagé avec # personnes}}",
"kind": "Contenu",
"kind_events": "Événements",
"kind_tasks": "Tâches",
+2
View File
@@ -3345,6 +3345,8 @@
"error_default": "נכשל בהגדרת לוח שנה ברירת מחדל",
"share": "שתף לוח שנה",
"new_event_in_calendar": "אירוע חדש בלוח שנה זה",
"shared_with": "משותף עם {names}",
"shared_with_count": "{count, plural, one {משותף עם אדם אחד} two {משותף עם שני אנשים} other {משותף עם # אנשים}}",
"kind": "תוכן",
"kind_events": "אירועים",
"kind_tasks": "משימות",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL a vágólapra másolva",
"share": "Naptár megosztása",
"new_event_in_calendar": "Új esemény ebben a naptárban",
"shared_with": "Megosztva: {names}",
"shared_with_count": "{count, plural, one {Megosztva 1 személlyel} other {Megosztva # személlyel}}",
"kind": "Tartalom",
"kind_events": "Események",
"kind_tasks": "Feladatok",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Impossibile cancellare gli eventi del calendario",
"share": "Condividi calendario",
"new_event_in_calendar": "Nuovo evento in questo calendario",
"shared_with": "Condiviso con {names}",
"shared_with_count": "{count, plural, one {Condiviso con 1 persona} other {Condiviso con # persone}}",
"kind": "Contenuto",
"kind_events": "Eventi",
"kind_tasks": "Attività",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "カレンダーイベントの削除に失敗しました",
"share": "カレンダーを共有",
"new_event_in_calendar": "このカレンダーに新規イベント",
"shared_with": "{names} と共有",
"shared_with_count": "{count, plural, other {#人と共有}}",
"kind": "内容",
"kind_events": "イベント",
"kind_tasks": "タスク",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
"share": "캘린더 공유",
"new_event_in_calendar": "이 캘린더에 새 일정",
"shared_with": "공유 대상: {names}",
"shared_with_count": "{count, plural, other {#명과 공유됨}}",
"kind": "내용",
"kind_events": "일정",
"kind_tasks": "할 일",
+2
View File
@@ -3430,6 +3430,8 @@
"url_copied": "CalDAV URL nokopēts starpliktuvē",
"share": "Kopīgot kalendāru",
"new_event_in_calendar": "Jauns notikums šajā kalendārā",
"shared_with": "Kopīgots ar: {names}",
"shared_with_count": "{count, plural, zero {Kopīgots ar # personām} one {Kopīgots ar # personu} other {Kopīgots ar # personām}}",
"kind": "Saturs",
"kind_events": "Notikumi",
"kind_tasks": "Uzdevumi",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL-г санах ой руу хуулсан",
"share": "Хуанлигаа хуваалцах",
"new_event_in_calendar": "Энэ хуанли дахь шинэ арга хэмжээ",
"shared_with": "Хуваалцсан: {names}",
"shared_with_count": "{count, plural, one {1 хүнтэй хуваалцсан} other {# хүнтэй хуваалцсан}}",
"kind": "Агуулга",
"kind_events": "Үйл явдлууд",
"kind_tasks": "Даалгаврууд",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV-URL kopiert til utklippstavlen",
"share": "Del kalender",
"new_event_in_calendar": "Ny hendelse i denne kalenderen",
"shared_with": "Delt med {names}",
"shared_with_count": "{count, plural, one {Delt med 1 person} other {Delt med # personer}}",
"kind": "Innhold",
"kind_events": "Hendelser",
"kind_tasks": "Oppgaver",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
"share": "Agenda delen",
"new_event_in_calendar": "Nieuwe afspraak in deze agenda",
"shared_with": "Gedeeld met {names}",
"shared_with_count": "{count, plural, one {Gedeeld met 1 persoon} other {Gedeeld met # personen}}",
"kind": "Inhoud",
"kind_events": "Afspraken",
"kind_tasks": "Taken",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "Adres URL CalDAV skopiowano do schowka",
"share": "Udostępnij kalendarz",
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu",
"shared_with": "Udostępniono: {names}",
"shared_with_count": "{count, plural, one {Udostępniono 1 osobie} few {Udostępniono # osobom} many {Udostępniono # osobom} other {Udostępniono # osoby}}",
"kind": "Zawartość",
"kind_events": "Wydarzenia",
"kind_tasks": "Zadania",
+2
View File
@@ -3457,6 +3457,8 @@
"error_clear": "Falha ao limpar os eventos do calendário",
"share": "Compartilhar calendário",
"new_event_in_calendar": "Novo evento neste calendário",
"shared_with": "Compartilhado com {names}",
"shared_with_count": "{count, plural, one {Compartilhado com 1 pessoa} other {Compartilhado com # pessoas}}",
"kind": "Conteúdo",
"kind_events": "Eventos",
"kind_tasks": "Tarefas",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL copiat în clipboard",
"share": "Partajare calendar",
"new_event_in_calendar": "Eveniment nou în acest calendar",
"shared_with": "Partajat cu {names}",
"shared_with_count": "{count, plural, one {Partajat cu 1 persoană} few {Partajat cu # persoane} other {Partajat cu # de persoane}}",
"kind": "Conținut",
"kind_events": "Evenimente",
"kind_tasks": "Sarcini",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL скопирован в буфер обмена",
"share": "Поделиться календарём",
"new_event_in_calendar": "Новое событие в этом календаре",
"shared_with": "Доступ открыт: {names}",
"shared_with_count": "{count, plural, one {Доступ открыт # человеку} few {Доступ открыт # людям} many {Доступ открыт # людям} other {Доступ открыт # человека}}",
"kind": "Содержимое",
"kind_events": "События",
"kind_tasks": "Задачи",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "URL CalDAV skopírovaná do schránky",
"share": "Zdieľať kalendár",
"new_event_in_calendar": "Nová udalosť v tomto kalendári",
"shared_with": "Zdieľané s: {names}",
"shared_with_count": "{count, plural, one {Zdieľané s 1 osobou} few {Zdieľané s # osobami} many {Zdieľané s # osoby} other {Zdieľané s # osobami}}",
"kind": "Obsah",
"kind_events": "Udalosti",
"kind_tasks": "Úlohy",
+2
View File
@@ -3457,6 +3457,8 @@
"url_copied": "CalDAV URL'si panoya kopyalandı",
"share": "Takvimi paylaş",
"new_event_in_calendar": "Bu takvimde yeni etkinlik",
"shared_with": "Paylaşılan kişiler: {names}",
"shared_with_count": "{count, plural, one {1 kişiyle paylaşıldı} other {# kişiyle paylaşıldı}}",
"kind": "İçerik",
"kind_events": "Etkinlikler",
"kind_tasks": "Görevler",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
"share": "Поділитися календарем",
"new_event_in_calendar": "Нова подія в цьому календарі",
"shared_with": "Доступ надано: {names}",
"shared_with_count": "{count, plural, one {Доступ надано # особі} few {Доступ надано # особам} many {Доступ надано # особам} other {Доступ надано # особи}}",
"kind": "Вміст",
"kind_events": "Події",
"kind_tasks": "Завдання",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL 已複製到剪貼簿",
"share": "分享行事曆",
"new_event_in_calendar": "在此行事曆中新增活動",
"shared_with": "已與 {names} 共用",
"shared_with_count": "{count, plural, other {已與 # 人共用}}",
"kind": "內容",
"kind_events": "活動",
"kind_tasks": "任務",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL 已复制到剪贴板",
"share": "共享日历",
"new_event_in_calendar": "在此日历中新建事件",
"shared_with": "已与 {names} 共享",
"shared_with_count": "{count, plural, other {已与 # 人共享}}",
"kind": "内容",
"kind_events": "日程",
"kind_tasks": "任务",