mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c349e8e288
commit
8cf6783cdb
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "المهام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "وظایف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "משימות",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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à",
|
||||
|
||||
@@ -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": "タスク",
|
||||
|
||||
@@ -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": "할 일",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Даалгаврууд",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Задачи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Завдання",
|
||||
|
||||
@@ -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": "任務",
|
||||
|
||||
@@ -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": "任务",
|
||||
|
||||
Reference in New Issue
Block a user