mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
Merge pull request #1095 from lucletoffe/fix/calendar-location-links
fix(calendar): surface the meeting link and open the location in maps
This commit is contained in:
@@ -90,6 +90,7 @@ import {
|
||||
type ScrollWindowState, type ScrollWindowViewProps,
|
||||
} from "@/lib/calendar-scroll-window";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -1266,9 +1267,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}, [t]);
|
||||
|
||||
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||
const uri = event.virtualLocations
|
||||
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||
: undefined;
|
||||
const uri = findMeetingLink(event)?.uri;
|
||||
if (!uri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { buildCalendarPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -60,9 +61,7 @@ export function EventContextMenu({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const hasMeetingLink = !!(
|
||||
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||
);
|
||||
const hasMeetingLink = !!findMeetingLink(event);
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -149,16 +150,36 @@ export function EventDetailPopover({
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
return Object.values(event.locations)[0]?.name || null;
|
||||
}, [event.locations]);
|
||||
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
|
||||
|
||||
const virtualLocation = useMemo(() => {
|
||||
if (!event.virtualLocations) return null;
|
||||
const first = Object.values(event.virtualLocations)[0];
|
||||
return first?.uri || null;
|
||||
}, [event.virtualLocations]);
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
const meeting = useMemo(() => findMeetingLink(event), [event]);
|
||||
const virtualLocation = meeting?.uri ?? null;
|
||||
|
||||
// A URL opens as a link, a location that only names the service joins the
|
||||
// meeting, a postal address opens in the maps.
|
||||
const locationHref = useMemo(() => {
|
||||
if (!locationName) return null;
|
||||
const action = locationAction(locationName, meeting);
|
||||
return action.kind === "url" ? action.uri : mapsUrl(action.query);
|
||||
}, [locationName, meeting]);
|
||||
const [locationCopied, setLocationCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
}, []);
|
||||
const copyLocation = useCallback(async () => {
|
||||
if (!locationName) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(locationName);
|
||||
setLocationCopied(true);
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = setTimeout(() => setLocationCopied(false), 1500);
|
||||
} catch {
|
||||
// clipboard refused (insecure context or permission) - nothing to do
|
||||
}
|
||||
}, [locationName]);
|
||||
|
||||
const participants = useMemo(
|
||||
() => getParticipantList(event, { resolveName: resolveContactName }),
|
||||
@@ -407,21 +428,28 @@ export function EventDetailPopover({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
<a
|
||||
href={locationHref ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline min-w-0 break-words"
|
||||
title={locationName}
|
||||
>
|
||||
{/^https?:\/\//i.test(locationName)
|
||||
? (() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
})()
|
||||
: locationName}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyLocation}
|
||||
className="ms-auto p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
title={t("detail.copy_location")}
|
||||
aria-label={t("detail.copy_location")}
|
||||
>
|
||||
{locationCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findMeetingLink,
|
||||
isMeetingLabel,
|
||||
locationAction,
|
||||
mapsUrl,
|
||||
meetingProviderOf,
|
||||
primaryLocationName,
|
||||
unwrapSafeLink,
|
||||
} from '../event-links';
|
||||
|
||||
/**
|
||||
* A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves
|
||||
* the join URL in the description, next to help and dial-in links. The popover
|
||||
* must still offer the join link, and a real address must open the maps.
|
||||
*/
|
||||
|
||||
const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d';
|
||||
|
||||
const TEAMS_DESCRIPTION = [
|
||||
'________________________________________________________________________________',
|
||||
'Réunion Microsoft Teams',
|
||||
'Rejoindre sur votre ordinateur, application mobile ou appareil de salle',
|
||||
`Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`,
|
||||
'ID de la réunion : 319 560 123 45',
|
||||
'Code secret : CTYa9',
|
||||
'Télécharger Teams<https://www.microsoft.com/fr-fr/microsoft-teams/download-app> | Rejoindre sur le web<https://www.microsoft.com/microsoft-teams/join-a-meeting>',
|
||||
'En savoir plus<https://aka.ms/JoinTeamsMeeting> | Options de réunion<https://teams.microsoft.com/meetingOptions/?organizerId=1>',
|
||||
].join('\n');
|
||||
|
||||
describe('findMeetingLink', () => {
|
||||
it('prefers the structured virtual location', () => {
|
||||
expect(findMeetingLink({
|
||||
virtualLocations: { v: { uri: 'https://zoom.us/j/123' } },
|
||||
description: TEAMS_DESCRIPTION,
|
||||
})).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false });
|
||||
});
|
||||
|
||||
it('digs the Teams join URL out of the description, skipping help links', () => {
|
||||
expect(findMeetingLink({
|
||||
locations: { l: { name: 'Réunion Microsoft Teams' } },
|
||||
description: TEAMS_DESCRIPTION,
|
||||
})).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true });
|
||||
});
|
||||
|
||||
it('recognises the short Teams, Meet and Zoom forms', () => {
|
||||
expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams');
|
||||
expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij');
|
||||
expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom');
|
||||
});
|
||||
|
||||
it('takes a meeting URL used as the location', () => {
|
||||
expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup');
|
||||
});
|
||||
|
||||
it('unwraps Outlook Safe Links', () => {
|
||||
const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`;
|
||||
expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN);
|
||||
});
|
||||
|
||||
it('ignores ordinary links', () => {
|
||||
expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull();
|
||||
expect(findMeetingLink({})).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to an app-scheme virtual location, never a script one', () => {
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'msteams:/l/meetup-join/19%3a1' } } }))
|
||||
.toEqual({ uri: 'msteams:/l/meetup-join/19%3a1', derived: false });
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull();
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull();
|
||||
});
|
||||
|
||||
it('recognises the Zoom web-client join path', () => {
|
||||
expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom');
|
||||
});
|
||||
|
||||
it('skips a virtual location that is not a web URL', () => {
|
||||
expect(findMeetingLink({
|
||||
virtualLocations: { v: { uri: 'tel:+33123456789' } },
|
||||
description: 'https://meet.google.com/abc-defg-hij',
|
||||
})?.provider).toBe('Google Meet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('meetingProviderOf / unwrapSafeLink', () => {
|
||||
it('rejects look-alike hosts', () => {
|
||||
expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull();
|
||||
expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves non-wrapped URLs alone', () => {
|
||||
expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('locationAction', () => {
|
||||
const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true };
|
||||
|
||||
it('searches the maps for a postal address', () => {
|
||||
expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' });
|
||||
});
|
||||
|
||||
it('joins the meeting when the location only names the service', () => {
|
||||
expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN });
|
||||
expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' });
|
||||
});
|
||||
|
||||
it('opens a URL location', () => {
|
||||
expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' });
|
||||
});
|
||||
|
||||
it('only treats a bare service name as a meeting', () => {
|
||||
for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) {
|
||||
expect(isMeetingLabel(label)).toBe(true);
|
||||
}
|
||||
for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) {
|
||||
expect(isMeetingLabel(label)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsUrl', () => {
|
||||
it('builds a Google Maps search, collapsing line breaks', () => {
|
||||
expect(mapsUrl('12 rue de la Paix,\n75002 Paris')).toBe('https://www.google.com/maps/search/?api=1&query=12%20rue%20de%20la%20Paix%2C%2075002%20Paris');
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryLocationName', () => {
|
||||
it('trims and drops blank names', () => {
|
||||
expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined();
|
||||
expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* What an event's place fields should do when tapped.
|
||||
*
|
||||
* Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives
|
||||
* with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in
|
||||
* the description, among dial-in numbers and help links. `findMeetingLink`
|
||||
* digs it out so the popover can offer the meeting link without touching the
|
||||
* event on the server (it is someone else's invitation).
|
||||
*
|
||||
* A physical address opens in the maps instead.
|
||||
*/
|
||||
|
||||
/** The place fields read here, loose enough for any event shape. */
|
||||
export interface EventPlaces {
|
||||
locations?: Record<string, { name?: string | null; description?: string | null } | null> | null;
|
||||
virtualLocations?: Record<string, { uri?: string | null } | null> | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface MeetingLink {
|
||||
uri: string;
|
||||
/** Display name of the conferencing service, when recognised. */
|
||||
provider?: string;
|
||||
/** True when the link was found in the description, not a structured field. */
|
||||
derived: boolean;
|
||||
}
|
||||
|
||||
// Hosts whose URLs are joinable meetings. The path test keeps help pages,
|
||||
// "learn more" and dial-in links on the same host out of the result.
|
||||
const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [
|
||||
{ name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i },
|
||||
{ name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i },
|
||||
{ name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i },
|
||||
{ name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i },
|
||||
{ name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ },
|
||||
{ name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ },
|
||||
{ name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ },
|
||||
{ name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ },
|
||||
];
|
||||
|
||||
const UNSAFE_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'blob', 'about']);
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
|
||||
const isHttpUrl = (value: string | undefined | null): value is string =>
|
||||
!!value && /^https?:\/\/\S+$/i.test(value.trim());
|
||||
|
||||
/**
|
||||
* Outlook's Safe Links and similar gateways wrap the real URL in a query
|
||||
* parameter; unwrap one level so the provider can be recognised.
|
||||
*/
|
||||
export function unwrapSafeLink(uri: string): string {
|
||||
try {
|
||||
const u = new URL(uri);
|
||||
if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) {
|
||||
const inner = u.searchParams.get('url');
|
||||
if (inner && isHttpUrl(inner)) return inner;
|
||||
}
|
||||
} catch {
|
||||
// not a parseable URL - keep as is
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** The conferencing service a URL joins, or null for any other link. */
|
||||
export function meetingProviderOf(uri: string): string | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(unwrapSafeLink(uri));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = u.hostname.toLowerCase();
|
||||
const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname)));
|
||||
return provider ? provider.name : null;
|
||||
}
|
||||
|
||||
function firstMeetingUrl(text: string | undefined | null): MeetingLink | null {
|
||||
if (!text) return null;
|
||||
for (const raw of text.match(URL_RE) ?? []) {
|
||||
// Trailing punctuation belongs to the sentence, not the URL.
|
||||
const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, ''));
|
||||
const provider = meetingProviderOf(candidate);
|
||||
if (provider) return { uri: candidate, provider, derived: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link that joins the meeting: the event's own virtual location first,
|
||||
* then a URL used as the location, then a known conferencing URL found in the
|
||||
* location or the description.
|
||||
*/
|
||||
export function findMeetingLink(event: EventPlaces): MeetingLink | null {
|
||||
for (const vl of Object.values(event.virtualLocations ?? {})) {
|
||||
const uri = vl?.uri;
|
||||
if (isHttpUrl(uri)) {
|
||||
return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false };
|
||||
}
|
||||
}
|
||||
const locations = Object.values(event.locations ?? {});
|
||||
for (const loc of locations) {
|
||||
const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description);
|
||||
if (found) return found;
|
||||
}
|
||||
const inDescription = firstMeetingUrl(event.description);
|
||||
if (inDescription) return inDescription;
|
||||
// Last resort: a virtual location in an app scheme (msteams:, zoommtg:,
|
||||
// sip:…) still beats no link at all. Never a scheme that runs code.
|
||||
for (const vl of Object.values(event.virtualLocations ?? {})) {
|
||||
const uri = vl?.uri?.trim();
|
||||
const scheme = uri ? /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1].toLowerCase() : undefined;
|
||||
if (uri && scheme && !UNSAFE_SCHEMES.has(scheme)) return { uri, derived: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first location's display name, if any. */
|
||||
export function primaryLocationName(event: Pick<EventPlaces, 'locations'>): string | undefined {
|
||||
const name = Object.values(event.locations ?? {})[0]?.name?.trim();
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"…
|
||||
// A location like this names the service, not a place: tapping it should join
|
||||
// the meeting, never search the maps for it. The whole label must be a service
|
||||
// name, so "Salle Teams" or "Studio Zoom" stay places.
|
||||
const MEETING_LABELS = new Set([
|
||||
'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby',
|
||||
'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne',
|
||||
]);
|
||||
|
||||
export function isMeetingLabel(location: string): boolean {
|
||||
const label = location
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^(reunion|meeting|online meeting|appel|call) /, '')
|
||||
.replace(/ (meeting|reunion|call)$/, '');
|
||||
return MEETING_LABELS.has(label);
|
||||
}
|
||||
|
||||
export type LocationAction =
|
||||
| { kind: 'url'; uri: string }
|
||||
| { kind: 'maps'; query: string };
|
||||
|
||||
/**
|
||||
* What tapping the location does: open it when it is a URL, join the meeting
|
||||
* when it only names the service, search the maps otherwise.
|
||||
*/
|
||||
export function locationAction(location: string, meeting: MeetingLink | null): LocationAction {
|
||||
const value = location.trim();
|
||||
if (isHttpUrl(value)) return { kind: 'url', uri: value };
|
||||
if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri };
|
||||
return { kind: 'maps', query: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* A maps URL for a free-text address. The Google Maps universal link opens
|
||||
* the Maps app on a phone when it is installed, the site otherwise.
|
||||
*/
|
||||
export function mapsUrl(query: string): string {
|
||||
const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim());
|
||||
return `https://www.google.com/maps/search/?api=1&query=${q}`;
|
||||
}
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "حفظ",
|
||||
"note_saved": "تمت إضافة الملاحظة",
|
||||
"open_link": "فتح الرابط",
|
||||
"copy_location": "نسخ الموقع",
|
||||
"meeting_link": "رابط الاجتماع",
|
||||
"tentative": "مبدئي",
|
||||
"cancelled": "ملغى",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Desa",
|
||||
"note_saved": "Nota afegida",
|
||||
"open_link": "Obre l'enllaç",
|
||||
"copy_location": "Copia la ubicació",
|
||||
"meeting_link": "Enllaç de la reunió",
|
||||
"tentative": "Provisional",
|
||||
"cancelled": "Cancel·lat",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Uložit",
|
||||
"note_saved": "Poznámka přidána",
|
||||
"open_link": "Otevřít odkaz",
|
||||
"copy_location": "Kopírovat místo",
|
||||
"meeting_link": "Odkaz na schůzku",
|
||||
"tentative": "Nezávazně",
|
||||
"cancelled": "Zrušeno",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Gem",
|
||||
"note_saved": "Note tilføjet",
|
||||
"open_link": "Åbn link",
|
||||
"copy_location": "Kopiér sted",
|
||||
"meeting_link": "Mødelink",
|
||||
"tentative": "Foreløbig",
|
||||
"cancelled": "Aflyst",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Speichern",
|
||||
"note_saved": "Notiz hinzugefügt",
|
||||
"open_link": "Link öffnen",
|
||||
"copy_location": "Ort kopieren",
|
||||
"meeting_link": "Besprechungslink",
|
||||
"tentative": "Vorläufig",
|
||||
"cancelled": "Abgesagt",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Save",
|
||||
"note_saved": "Note added",
|
||||
"open_link": "Open link",
|
||||
"copy_location": "Copy location",
|
||||
"meeting_link": "Meeting link",
|
||||
"tentative": "Tentative",
|
||||
"cancelled": "Cancelled",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Guardar",
|
||||
"note_saved": "Nota añadida",
|
||||
"open_link": "Abrir enlace",
|
||||
"copy_location": "Copiar ubicación",
|
||||
"meeting_link": "Enlace de reunión",
|
||||
"tentative": "Provisional",
|
||||
"cancelled": "Cancelado",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "ذخیره",
|
||||
"note_saved": "یادداشت اضافه شد",
|
||||
"open_link": "باز کردن لینک",
|
||||
"copy_location": "کپی مکان",
|
||||
"meeting_link": "لینک جلسه",
|
||||
"tentative": "احتمالی",
|
||||
"cancelled": "لغو شده",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Enregistrer",
|
||||
"note_saved": "Note ajoutée",
|
||||
"open_link": "Ouvrir le lien",
|
||||
"copy_location": "Copier le lieu",
|
||||
"meeting_link": "Lien de réunion",
|
||||
"tentative": "Provisoire",
|
||||
"cancelled": "Annulé",
|
||||
|
||||
@@ -2852,6 +2852,7 @@
|
||||
"save_note": "שמור",
|
||||
"note_saved": "הערה נוספה",
|
||||
"open_link": "פתח קישור",
|
||||
"copy_location": "העתק מיקום",
|
||||
"meeting_link": "קישור לפגישה",
|
||||
"tentative": "טנטטיבי",
|
||||
"cancelled": "בוטל",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Mentés",
|
||||
"note_saved": "Megjegyzés hozzáadva",
|
||||
"open_link": "Link megnyitása",
|
||||
"copy_location": "Helyszín másolása",
|
||||
"meeting_link": "Találkozó link",
|
||||
"tentative": "Előzetes",
|
||||
"cancelled": "Lemondva",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Salva",
|
||||
"note_saved": "Nota aggiunta",
|
||||
"open_link": "Apri link",
|
||||
"copy_location": "Copia luogo",
|
||||
"meeting_link": "Link riunione",
|
||||
"tentative": "Provvisorio",
|
||||
"cancelled": "Annullato",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "保存",
|
||||
"note_saved": "メモを追加しました",
|
||||
"open_link": "リンクを開く",
|
||||
"copy_location": "場所をコピー",
|
||||
"meeting_link": "会議リンク",
|
||||
"tentative": "仮",
|
||||
"cancelled": "キャンセル済み",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "저장",
|
||||
"note_saved": "메모가 추가되었어요",
|
||||
"open_link": "링크 열기",
|
||||
"copy_location": "위치 복사",
|
||||
"meeting_link": "화상 회의 링크",
|
||||
"tentative": "미정",
|
||||
"cancelled": "취소됨",
|
||||
|
||||
@@ -2927,6 +2927,7 @@
|
||||
"save_note": "Saglabāt",
|
||||
"note_saved": "Piezīme pievienota",
|
||||
"open_link": "Atvērt saiti",
|
||||
"copy_location": "Kopēt vietu",
|
||||
"meeting_link": "Sapulces saite",
|
||||
"tentative": "Pagaidām",
|
||||
"cancelled": "Atcelts",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Хадгалах",
|
||||
"note_saved": "Тэмдэглэл нэмсэн",
|
||||
"open_link": "Нээлттэй холбоос",
|
||||
"copy_location": "Байршлыг хуулах",
|
||||
"meeting_link": "Уулзалтын холбоос",
|
||||
"tentative": "Урьдчилсан",
|
||||
"cancelled": "Цуцлагдсан",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Lagre",
|
||||
"note_saved": "Notatet er lagret",
|
||||
"open_link": "Åpne lenken",
|
||||
"copy_location": "Kopier sted",
|
||||
"meeting_link": "Møtelenke",
|
||||
"tentative": "Foreløpig",
|
||||
"cancelled": "Avlyst",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Opslaan",
|
||||
"note_saved": "Notitie toegevoegd",
|
||||
"open_link": "Link openen",
|
||||
"copy_location": "Locatie kopiëren",
|
||||
"meeting_link": "Vergaderlink",
|
||||
"tentative": "Voorlopig",
|
||||
"cancelled": "Geannuleerd",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Zapisz",
|
||||
"note_saved": "Dodano notatkę",
|
||||
"open_link": "Otwórz link",
|
||||
"copy_location": "Kopiuj lokalizację",
|
||||
"meeting_link": "Link do spotkania",
|
||||
"tentative": "Wstępnie",
|
||||
"cancelled": "Odwołane",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Salvar",
|
||||
"note_saved": "Nota adicionada",
|
||||
"open_link": "Abrir link",
|
||||
"copy_location": "Copiar local",
|
||||
"meeting_link": "Link da reunião",
|
||||
"tentative": "Provisório",
|
||||
"cancelled": "Cancelado",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Salvare",
|
||||
"note_saved": "Notă adăugată",
|
||||
"open_link": "Deschideți linkul",
|
||||
"copy_location": "Copiază locația",
|
||||
"meeting_link": "Link către întâlnire",
|
||||
"tentative": "Provizoriu",
|
||||
"cancelled": "Anulat",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Сохранить",
|
||||
"note_saved": "Заметка добавлена",
|
||||
"open_link": "Открыть ссылку",
|
||||
"copy_location": "Копировать место",
|
||||
"meeting_link": "Ссылка на встречу",
|
||||
"tentative": "Предварительно",
|
||||
"cancelled": "Отменено",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Uložiť",
|
||||
"note_saved": "Poznámka pridaná",
|
||||
"open_link": "Otvoriť odkaz",
|
||||
"copy_location": "Kopírovať miesto",
|
||||
"meeting_link": "Odkaz na stretnutie",
|
||||
"tentative": "Nezáväzne",
|
||||
"cancelled": "Zrušené",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "Kaydet",
|
||||
"note_saved": "Not eklendi",
|
||||
"open_link": "Bağlantıyı aç",
|
||||
"copy_location": "Konumu kopyala",
|
||||
"meeting_link": "Toplantı bağlantısı",
|
||||
"tentative": "Geçici",
|
||||
"cancelled": "İptal edildi",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "зберегти",
|
||||
"note_saved": "Примітка додана",
|
||||
"open_link": "Відкрити посилання",
|
||||
"copy_location": "Копіювати місце",
|
||||
"meeting_link": "Посилання на зустріч",
|
||||
"tentative": "Орієнтовний",
|
||||
"cancelled": "Скасовано",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "儲存",
|
||||
"note_saved": "已新增備註",
|
||||
"open_link": "開啟連結",
|
||||
"copy_location": "複製地點",
|
||||
"meeting_link": "會議連結",
|
||||
"tentative": "暫定",
|
||||
"cancelled": "已取消",
|
||||
|
||||
@@ -2928,6 +2928,7 @@
|
||||
"save_note": "保存",
|
||||
"note_saved": "注释已添加",
|
||||
"open_link": "打开链接",
|
||||
"copy_location": "复制地点",
|
||||
"meeting_link": "会议链接",
|
||||
"tentative": "暂定",
|
||||
"cancelled": "已取消",
|
||||
|
||||
Reference in New Issue
Block a user