diff --git a/CHANGELOG.md b/CHANGELOG.md index de92e2b1e..2836f2d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +### Added +- Show Apple location and car reminders (`X-APPLE-PROXIMITY`) instead of the dummy 1976-04-01 trigger date + ## 0.18.1 - 2026-06-29 ### Fixed diff --git a/l10n/ko.js b/l10n/ko.js index 1e1e63309..f7ac341d7 100644 --- a/l10n/ko.js +++ b/l10n/ko.js @@ -172,6 +172,13 @@ OC.L10N.register( "November" : "11월", "December" : "12월", "_{count} time_::_{count} times_" : ["{count}회"], + "When getting in the car" : "차에 탈 때", + "When getting out of the car" : "차에서 내릴 때", + "When arriving at {location}" : "{location}에 도착할 때", + "When arriving at a location" : "장소에 도착할 때", + "When leaving {location}" : "{location}에서 떠날 때", + "When leaving a location" : "장소에서 떠날 때", + "Location reminder" : "위치 알림", "An error occurred" : "오류가 발생함", "Message from {author} in {conversationName}" : "{conversationName}에서 {author}가 보낸 메시지" }, diff --git a/l10n/ko.json b/l10n/ko.json index 6c31ff292..e997d9a1e 100644 --- a/l10n/ko.json +++ b/l10n/ko.json @@ -170,6 +170,13 @@ "November" : "11월", "December" : "12월", "_{count} time_::_{count} times_" : ["{count}회"], + "When getting in the car" : "차에 탈 때", + "When getting out of the car" : "차에서 내릴 때", + "When arriving at {location}" : "{location}에 도착할 때", + "When arriving at a location" : "장소에 도착할 때", + "When leaving {location}" : "{location}에서 떠날 때", + "When leaving a location" : "장소에서 떠날 때", + "Location reminder" : "위치 알림", "An error occurred" : "오류가 발생함", "Message from {author} in {conversationName}" : "{conversationName}에서 {author}가 보낸 메시지" },"pluralForm" :"nplurals=1; plural=0;" diff --git a/src/components/AppSidebar/Alarm/AlarmListItem.vue b/src/components/AppSidebar/Alarm/AlarmListItem.vue index 2232482a7..d5fdd06bb 100644 --- a/src/components/AppSidebar/Alarm/AlarmListItem.vue +++ b/src/components/AppSidebar/Alarm/AlarmListItem.vue @@ -138,6 +138,11 @@ export default { return getCanonicalLocale().toLocaleLowerCase() }, canEdit() { + // Apple location/car reminders are not a wall-clock time. + if (this.alarm.appleProximity) { + return false + } + // You can always edit an alarm if it's absolute if (!this.isRelative) { return true diff --git a/src/models/alarm.js b/src/models/alarm.js index 2f932e6bc..749e93c58 100644 --- a/src/models/alarm.js +++ b/src/models/alarm.js @@ -9,6 +9,7 @@ import { } from '../utils/alarms.js' import { AlarmComponent } from '@nextcloud/calendar-js' import ICAL from 'ical.js' +import { getAppleProximityFromIcalAlarm } from '../utils/appleProximity.js' /** * Creates a complete alarm object based on given props @@ -45,6 +46,8 @@ const getDefaultAlarmObject = (props = {}) => Object.assign({}, { relativeMinutesAllDay: null, // The total amount of seconds for a relative alarm relativeTrigger: null, + // Apple location/car reminder (X-APPLE-PROXIMITY), or null + appleProximity: null, }, props) /** @@ -104,7 +107,9 @@ const mapAlarmComponentToAlarmObject = (alarmComponent) => { export function mapICALAlarmsToAlarmObjects(alarms) { return alarms.map((alarm) => { try { - return mapAlarmComponentToAlarmObject(AlarmComponent.fromICALJs(alarm)) + const mapped = mapAlarmComponentToAlarmObject(AlarmComponent.fromICALJs(alarm)) + mapped.appleProximity = getAppleProximityFromIcalAlarm(alarm) + return mapped } catch (e) { // Instead of breaking the whole page when parsing an invalid alarm, // we just print a warning on the console. diff --git a/src/utils/appleProximity.js b/src/utils/appleProximity.js new file mode 100644 index 000000000..4b1f5fc92 --- /dev/null +++ b/src/utils/appleProximity.js @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 YouCha + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const PROXIMITY_TYPES = new Set(['ARRIVE', 'DEPART', 'CONNECT', 'DISCONNECT']) +const APPLE_SENTINEL_TRIGGER = '19760401T005545' + +/** + * @param {string|string[]|undefined} value Parameter or property value + * @return {string} + */ +function firstString(value) { + if (Array.isArray(value)) { + return value.map((entry) => firstString(entry)).filter(Boolean).join(' ') + } + return value == null ? '' : String(value) +} + +/** + * @param {string} value Raw Apple address or title + * @return {string} + */ +function collapseLocationText(value) { + return firstString(value) + .replace(/\\n/gi, ' ') + .replace(/[\n\r]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * @param {import('ical.js').Property|null} property Structured location property + * @return {string} + */ +function locationFromProperty(property) { + if (!property) { + return '' + } + const title = collapseLocationText(property.getParameter('x-title')) + if (title) { + return title + } + return collapseLocationText(property.getParameter('x-address')) +} + +/** + * True when the VALARM uses Apple's dummy DATE-TIME for a location/car reminder. + * + * @param {import('ical.js').Component} alarm ICAL.js VALARM + * @return {boolean} + */ +function hasAppleSentinelTrigger(alarm) { + const trigger = alarm.getFirstProperty('trigger') + if (!trigger) { + return false + } + const value = trigger.getFirstValue() + const encoded = typeof value?.toICALString === 'function' + ? value.toICALString() + : firstString(value) + return encoded.replace(/[-:]/g, '').includes(APPLE_SENTINEL_TRIGGER) +} + +/** + * Read Apple proximity metadata from a VALARM. + * + * @param {import('ical.js').Component} alarm ICAL.js VALARM + * @return {{ type: string, location: string }|null} + */ +export function getAppleProximityFromIcalAlarm(alarm) { + if (!alarm || typeof alarm.getFirstPropertyValue !== 'function') { + return null + } + + const type = firstString(alarm.getFirstPropertyValue('x-apple-proximity')).trim().toUpperCase() + const location = locationFromProperty(alarm.getFirstProperty('x-apple-structured-location')) + + if (PROXIMITY_TYPES.has(type)) { + return { type, location } + } + + if (hasAppleSentinelTrigger(alarm)) { + return { + type: location ? 'ARRIVE' : 'LOCATION', + location, + } + } + + return null +} + +/** + * @param {{ type: string, location: string }|null|undefined} proximity Parsed Apple proximity + * @param {Function} t Nextcloud translate function + * @return {string|null} + */ +export function formatAppleProximityAlarm(proximity, t) { + if (!proximity?.type) { + return null + } + + switch (proximity.type) { + case 'CONNECT': + return t('tasks', 'When getting in the car') + case 'DISCONNECT': + return t('tasks', 'When getting out of the car') + case 'ARRIVE': + return proximity.location + ? t('tasks', 'When arriving at {location}', { location: proximity.location }) + : t('tasks', 'When arriving at a location') + case 'DEPART': + return proximity.location + ? t('tasks', 'When leaving {location}', { location: proximity.location }) + : t('tasks', 'When leaving a location') + default: + return t('tasks', 'Location reminder') + } +} diff --git a/src/utils/dateStrings.js b/src/utils/dateStrings.js index c63978b0b..090a2c95a 100644 --- a/src/utils/dateStrings.js +++ b/src/utils/dateStrings.js @@ -23,6 +23,7 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { convertTimeZone } from './alarms.js' +import { formatAppleProximityAlarm } from './appleProximity.js' /** * Returns a formatted string for the due date @@ -156,6 +157,11 @@ export function startDateString(task) { * @return {string} */ export function formatAlarm(alarm, isAllDay, currentUserTimezone, locale) { + const proximityText = formatAppleProximityAlarm(alarm.appleProximity, t) + if (proximityText) { + return proximityText + } + if (alarm.relativeTrigger !== null) { // Relative trigger if (isAllDay && alarm.relativeIsRelatedToStart && alarm.relativeTrigger < 86400) { diff --git a/tests/javascript/unit/utils/appleProximity.spec.js b/tests/javascript/unit/utils/appleProximity.spec.js new file mode 100644 index 000000000..1d69e470e --- /dev/null +++ b/tests/javascript/unit/utils/appleProximity.spec.js @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2026 YouCha + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import ICAL from 'ical.js' +import { describe, expect, it } from 'vitest' + +import { + formatAppleProximityAlarm, + getAppleProximityFromIcalAlarm, +} from '../../../../src/utils/appleProximity.js' + +/** + * @param {string} icsSnippet VALARM fragment + * @return {ICAL.Component} + */ +function alarmFrom(icsSnippet) { + const jCal = ICAL.parse(`BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VTODO\nUID:test\nSUMMARY:t\n${icsSnippet}\nEND:VTODO\nEND:VCALENDAR`) + const vtodo = new ICAL.Component(jCal).getFirstSubcomponent('vtodo') + return vtodo.getFirstSubcomponent('valarm') +} + +const t = (app, str, vars = {}) => str.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`) + +describe('utils/appleProximity', () => { + it('reads a car CONNECT reminder without a place', () => { + const alarm = alarmFrom(`BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER;VALUE=DATE-TIME:19760401T005545Z +X-APPLE-PROXIMITY:CONNECT +END:VALARM`) + + expect(getAppleProximityFromIcalAlarm(alarm)).toEqual({ + type: 'CONNECT', + location: '', + }) + expect(formatAppleProximityAlarm(getAppleProximityFromIcalAlarm(alarm), t)) + .toBe('When getting in the car') + }) + + it('reads DEPART with a structured location title', () => { + const alarm = alarmFrom(`BEGIN:VALARM +ACTION:DISPLAY +TRIGGER;VALUE=DATE-TIME:19760401T005545Z +X-APPLE-PROXIMITY:DEPART +X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS=;X-APPLE-RADIUS=1192.046111510581;X-TITLE=대한민국 경기도 수원시 16677:geo:37.255199,127.050757 +END:VALARM`) + + expect(getAppleProximityFromIcalAlarm(alarm)).toEqual({ + type: 'DEPART', + location: '대한민국 경기도 수원시 16677', + }) + expect(formatAppleProximityAlarm(getAppleProximityFromIcalAlarm(alarm), t)) + .toBe('When leaving 대한민국 경기도 수원시 16677') + }) + + it('reads ARRIVE and flattens a folded address', () => { + const alarm = alarmFrom(`BEGIN:VALARM +ACTION:DISPLAY +TRIGGER;VALUE=DATE-TIME:19760401T005545Z +X-APPLE-PROXIMITY:ARRIVE +X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS=대한민국\\n경기도\\n수원시\\n16677;X-TITLE=대한민국 경기도 수원시 16677:geo:37.255199,127.050757 +END:VALARM`) + + expect(getAppleProximityFromIcalAlarm(alarm)).toEqual({ + type: 'ARRIVE', + location: '대한민국 경기도 수원시 16677', + }) + expect(formatAppleProximityAlarm(getAppleProximityFromIcalAlarm(alarm), t)) + .toBe('When arriving at 대한민국 경기도 수원시 16677') + }) + + it('ignores ordinary timed alarms', () => { + const alarm = alarmFrom(`BEGIN:VALARM +ACTION:DISPLAY +TRIGGER;VALUE=DATE-TIME:20261026T030000Z +END:VALARM`) + + expect(getAppleProximityFromIcalAlarm(alarm)).toBeNull() + expect(formatAppleProximityAlarm(null, t)).toBeNull() + }) +})