Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions l10n/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}가 보낸 메시지"
},
Expand Down
7 changes: 7 additions & 0 deletions l10n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
Expand Down
5 changes: 5 additions & 0 deletions src/components/AppSidebar/Alarm/AlarmListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/models/alarm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

/**
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 119 additions & 0 deletions src/utils/appleProximity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* SPDX-FileCopyrightText: 2026 YouCha <chanha0406@gmail.com>
* 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')
}
}
6 changes: 6 additions & 0 deletions src/utils/dateStrings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 84 additions & 0 deletions tests/javascript/unit/utils/appleProximity.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* SPDX-FileCopyrightText: 2026 YouCha <chanha0406@gmail.com>
* 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()
})
})