diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 9bde310bf..b24f5016a 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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; 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); diff --git a/components/calendar/event-context-menu.tsx b/components/calendar/event-context-menu.tsx index 9bc109a3c..e85873edb 100644 --- a/components/calendar/event-context-menu.tsx +++ b/components/calendar/event-context-menu.tsx @@ -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 ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index fc458f9c7..98d08e4c7 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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 | 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 && (
- {/^https?:\/\//i.test(locationName) ? ( - - {(() => { + + {/^https?:\/\//i.test(locationName) + ? (() => { try { return new URL(locationName).hostname; } catch { return locationName; } - })()} - - ) : ( - {locationName} - )} + })() + : locationName} + +
)} diff --git a/lib/__tests__/event-links.test.ts b/lib/__tests__/event-links.test.ts new file mode 100644 index 000000000..fba4eb554 --- /dev/null +++ b/lib/__tests__/event-links.test.ts @@ -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 | Rejoindre sur le web', + 'En savoir plus | Options de réunion', +].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'); + }); +}); diff --git a/lib/event-links.ts b/lib/event-links.ts new file mode 100644 index 000000000..8c350c37d --- /dev/null +++ b/lib/event-links.ts @@ -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 | null; + virtualLocations?: Record | 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): 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}`; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 9be55b223..950a1789a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2928,6 +2928,7 @@ "save_note": "حفظ", "note_saved": "تمت إضافة الملاحظة", "open_link": "فتح الرابط", + "copy_location": "نسخ الموقع", "meeting_link": "رابط الاجتماع", "tentative": "مبدئي", "cancelled": "ملغى", diff --git a/locales/ca/common.json b/locales/ca/common.json index aa1f88e06..ecb402b91 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 1a847f4d5..3664d0b43 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index d2daa42a6..3caa14583 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 3464c797f..770435414 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 0d673bcd5..b4f9be364 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index c3ed1e589..0d945c826 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4fbb95afd..2d4f04da2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2928,6 +2928,7 @@ "save_note": "ذخیره", "note_saved": "یادداشت اضافه شد", "open_link": "باز کردن لینک", + "copy_location": "کپی مکان", "meeting_link": "لینک جلسه", "tentative": "احتمالی", "cancelled": "لغو شده", diff --git a/locales/fr/common.json b/locales/fr/common.json index b55560e06..524bb55f0 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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é", diff --git a/locales/he/common.json b/locales/he/common.json index 13e01b811..5f30eca55 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2852,6 +2852,7 @@ "save_note": "שמור", "note_saved": "הערה נוספה", "open_link": "פתח קישור", + "copy_location": "העתק מיקום", "meeting_link": "קישור לפגישה", "tentative": "טנטטיבי", "cancelled": "בוטל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 36a74c9fb..e57c534c7 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 39f3e474e..9f05e4ec5 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 007bc2501..0a865dd83 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2928,6 +2928,7 @@ "save_note": "保存", "note_saved": "メモを追加しました", "open_link": "リンクを開く", + "copy_location": "場所をコピー", "meeting_link": "会議リンク", "tentative": "仮", "cancelled": "キャンセル済み", diff --git a/locales/ko/common.json b/locales/ko/common.json index 78440edaf..35cac66e2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2928,6 +2928,7 @@ "save_note": "저장", "note_saved": "메모가 추가되었어요", "open_link": "링크 열기", + "copy_location": "위치 복사", "meeting_link": "화상 회의 링크", "tentative": "미정", "cancelled": "취소됨", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9f0d08c83..fae6716d7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 308c1c74f..a40bc852e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2928,6 +2928,7 @@ "save_note": "Хадгалах", "note_saved": "Тэмдэглэл нэмсэн", "open_link": "Нээлттэй холбоос", + "copy_location": "Байршлыг хуулах", "meeting_link": "Уулзалтын холбоос", "tentative": "Урьдчилсан", "cancelled": "Цуцлагдсан", diff --git a/locales/nb/common.json b/locales/nb/common.json index 491908da2..257e9656e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 2e0754f35..bf526848c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7d5785ddb..f1e882522 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index a3a0162be..9eda58464 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index c7cc8700a..b84e6383f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 1cdb7f81e..d28eb40d9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2928,6 +2928,7 @@ "save_note": "Сохранить", "note_saved": "Заметка добавлена", "open_link": "Открыть ссылку", + "copy_location": "Копировать место", "meeting_link": "Ссылка на встречу", "tentative": "Предварительно", "cancelled": "Отменено", diff --git a/locales/sk/common.json b/locales/sk/common.json index 150b5f873..685434051 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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é", diff --git a/locales/tr/common.json b/locales/tr/common.json index 39a02778d..af7411d2e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index f7cf8ec47..e5bcc0854 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2928,6 +2928,7 @@ "save_note": "зберегти", "note_saved": "Примітка додана", "open_link": "Відкрити посилання", + "copy_location": "Копіювати місце", "meeting_link": "Посилання на зустріч", "tentative": "Орієнтовний", "cancelled": "Скасовано", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 8deaa3fe9..6e6fcde07 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2928,6 +2928,7 @@ "save_note": "儲存", "note_saved": "已新增備註", "open_link": "開啟連結", + "copy_location": "複製地點", "meeting_link": "會議連結", "tentative": "暫定", "cancelled": "已取消", diff --git a/locales/zh/common.json b/locales/zh/common.json index c8a55cac3..6e8be267a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2928,6 +2928,7 @@ "save_note": "保存", "note_saved": "注释已添加", "open_link": "打开链接", + "copy_location": "复制地点", "meeting_link": "会议链接", "tentative": "暂定", "cancelled": "已取消",