refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
live in commons so desktop and the future CLI can consume them. Package
changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
and LocalCache. The ViewModels stay for the same reason; lifting them
requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
properties don't support implicit smart-cast.
Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
January 15, 2025, 2 events, today, selected") via mergeDescendants so
TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
the concrete create action.
Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
fully-qualified androidx.compose / com.vitorpamplona references into
proper imports per the codebase style.
All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
|
||||
/**
|
||||
* Shared projection of NIP-52 calendar appointments. The 31922 (date-slot) and 31923 (time-slot)
|
||||
* event classes have identical UI surfaces but no common interface, so UI code repeated a
|
||||
* `when (event) { is Time -> e.title(); is Date -> e.title() }` block per accessor. Materialising
|
||||
* the projection once collapses those branches into a single linear read.
|
||||
*
|
||||
* [isAllDay] discriminates the two kinds; [startSeconds] is local-midnight for 31922 (matching
|
||||
* the rest of the calendar code's day-anchoring).
|
||||
*/
|
||||
@Immutable
|
||||
data class CalendarAppointmentView(
|
||||
val title: String?,
|
||||
val image: String?,
|
||||
val summary: String?,
|
||||
val location: String?,
|
||||
val startSeconds: Long?,
|
||||
val endSeconds: Long?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
fun Note.appointmentView(): CalendarAppointmentView? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
CalendarAppointmentView(
|
||||
title = e.title(),
|
||||
image = e.image(),
|
||||
summary = e.summary(),
|
||||
location = e.location(),
|
||||
startSeconds = e.start(),
|
||||
endSeconds = e.end() ?: e.start(),
|
||||
isAllDay = false,
|
||||
)
|
||||
is CalendarDateSlotEvent ->
|
||||
CalendarAppointmentView(
|
||||
title = e.title(),
|
||||
image = e.image(),
|
||||
summary = e.summary(),
|
||||
location = e.location(),
|
||||
startSeconds = parseIsoDateToUnixSeconds(e.start()),
|
||||
endSeconds = parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start()),
|
||||
isAllDay = true,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
// java.time formatters are thread-safe; the SimpleDateFormat predecessor was shared by sort
|
||||
// (background) and grouping (UI) paths and could throw under concurrent use.
|
||||
private val IsoDateParser: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||
|
||||
private fun parseIsoDate(date: String?): LocalDate? {
|
||||
if (date.isNullOrBlank()) return null
|
||||
return try {
|
||||
LocalDate.parse(date, IsoDateParser)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar 31922 carries a calendar date (no instant). Anchor it at local midnight so that
|
||||
* "Jan 15" lands on Jan 15 in the user's grid and ordering reflects their local zone — UTC
|
||||
* anchoring made date-only events appear a day early west of UTC.
|
||||
*/
|
||||
fun parseIsoDateToUnixSeconds(date: String?): Long? = parseIsoDate(date)?.atStartOfDay(ZoneId.systemDefault())?.toEpochSecond()
|
||||
|
||||
/**
|
||||
* Unified start time as unix-seconds. For 31923 (time-slot) this is the event's instant; for
|
||||
* 31922 (date-slot) it is local midnight of the calendar date. Both are suitable for ordering
|
||||
* against [TimeUtils.now] and for relative-time rendering.
|
||||
*/
|
||||
fun Note.calendarStartSeconds(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent -> e.start()
|
||||
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.start())
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Note.calendarEndSeconds(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent -> e.end() ?: e.start()
|
||||
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start())
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-day bucket key (days since 1970-01-01) for grouping events into day cells.
|
||||
*
|
||||
* For 31923, the event's instant is converted to the viewer's local date; for 31922, the ISO
|
||||
* date string is parsed directly with no zone conversion (a calendar date for "Jan 15" must
|
||||
* land on Jan 15 in every zone). Returns null when the start cannot be resolved.
|
||||
*/
|
||||
fun Note.calendarLocalDayKey(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
e.start()?.let {
|
||||
Instant
|
||||
.ofEpochSecond(it)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
}
|
||||
is CalendarDateSlotEvent -> parseIsoDate(e.start())?.toEpochDay()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets appointments by local calendar day (returned as `LocalDate.toEpochDay`). Notes that
|
||||
* are not calendar appointments or whose start can't be parsed are dropped.
|
||||
*/
|
||||
fun groupByDayKey(notes: List<Note>): Map<Long, List<Note>> {
|
||||
val map = mutableMapOf<Long, MutableList<Note>>()
|
||||
notes.forEach {
|
||||
val dayKey = it.calendarLocalDayKey() ?: return@forEach
|
||||
map.getOrPut(dayKey) { mutableListOf() }.add(it)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive `[start, end]` range of day-keys an appointment covers. A single-day event yields
|
||||
* one key; a multi-day event yields every day from start through end. Returns null when the
|
||||
* note isn't a calendar appointment or has no parseable start.
|
||||
*
|
||||
* Capped at 366 days so a malformed event with a far-future end can't blow up month-view memory.
|
||||
*/
|
||||
fun Note.calendarLocalDayKeyRange(): LongRange? {
|
||||
val startKey = calendarLocalDayKey() ?: return null
|
||||
val endKey =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
e.end()?.let {
|
||||
Instant
|
||||
.ofEpochSecond(it)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
} ?: startKey
|
||||
is CalendarDateSlotEvent -> parseIsoDate(e.end())?.toEpochDay() ?: startKey
|
||||
else -> startKey
|
||||
}
|
||||
val safeEnd = endKey.coerceAtLeast(startKey).coerceAtMost(startKey + 366)
|
||||
return startKey..safeEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [groupByDayKey] but a multi-day appointment lands in every day it covers (not just the
|
||||
* start day). Used by month/week/day views so a 3-day conference shows on all three rows; the
|
||||
* upcoming/past list view still uses [groupByDayKey] semantics via its own ordering.
|
||||
*/
|
||||
fun groupByDayKeyExpanded(notes: List<Note>): Map<Long, List<Note>> {
|
||||
val map = mutableMapOf<Long, MutableList<Note>>()
|
||||
notes.forEach { note ->
|
||||
val range = note.calendarLocalDayKeyRange() ?: return@forEach
|
||||
for (key in range) {
|
||||
map.getOrPut(key) { mutableListOf() }.add(note)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by: upcoming events ascending (closest first), then past events descending (most-recent
|
||||
* first). [nowSeconds] is captured once per sort so the comparator stays transitive across the
|
||||
* full sort run — reading the clock inside `compare` would violate the [Comparator] contract on
|
||||
* boundary elements.
|
||||
*/
|
||||
fun upcomingFirstCalendarOrder(nowSeconds: Long): Comparator<Note> =
|
||||
Comparator { a, b ->
|
||||
val sa = a.calendarStartSeconds()
|
||||
val sb = b.calendarStartSeconds()
|
||||
|
||||
val primary =
|
||||
when {
|
||||
sa == null && sb == null -> compareCreatedAt(a, b)
|
||||
sa == null -> 1
|
||||
sb == null -> -1
|
||||
else -> {
|
||||
val aUpcoming = sa >= nowSeconds
|
||||
val bUpcoming = sb >= nowSeconds
|
||||
when {
|
||||
aUpcoming && !bUpcoming -> -1
|
||||
!aUpcoming && bUpcoming -> 1
|
||||
aUpcoming -> sa.compareTo(sb) // both future: nearest first
|
||||
else -> sb.compareTo(sa) // both past: most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primary != 0) primary else a.idHex.compareTo(b.idHex)
|
||||
}
|
||||
|
||||
private fun compareCreatedAt(
|
||||
a: Note,
|
||||
b: Note,
|
||||
): Int {
|
||||
val ca = a.createdAt() ?: 0L
|
||||
val cb = b.createdAt() ?: 0L
|
||||
return cb.compareTo(ca)
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Serialises NIP-52 calendar events to RFC 5545 iCalendar (`.ics`) text. The output is the
|
||||
* universal calendar interchange format: tapping a generated file in any email/calendar app
|
||||
* lets users import the event into Google Calendar, Apple Calendar, Outlook, Thunderbird, etc.
|
||||
*
|
||||
* Implements only the subset needed for NIP-52 appointments:
|
||||
* - `VEVENT` per appointment with `UID`, `DTSTAMP`, `DTSTART`, `DTEND`, `SUMMARY`,
|
||||
* `DESCRIPTION`, `LOCATION`, and `CATEGORIES` for hashtags.
|
||||
* - 31922 date-slot events emit `DTSTART;VALUE=DATE:YYYYMMDD` (no time component); 31923
|
||||
* time-slot events emit UTC instants formatted as `YYYYMMDDTHHMMSSZ`.
|
||||
* - 31924 calendar collections wrap their member appointments in one `VCALENDAR`.
|
||||
*
|
||||
* Line folding (75-octet limit per RFC 5545) is *not* implemented — modern parsers tolerate
|
||||
* long lines and the resulting files import cleanly across the apps tested.
|
||||
*/
|
||||
object IcsExport {
|
||||
private const val PRODID = "-//Amethyst//NIP-52//EN"
|
||||
private const val CRLF = "\r\n"
|
||||
private val UtcStamp: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
private val IsoBasicDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
fun appointmentToIcs(
|
||||
event: Any,
|
||||
address: Address,
|
||||
nowSeconds: Long,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCALENDAR").append(CRLF)
|
||||
sb.append("VERSION:2.0").append(CRLF)
|
||||
sb.append("PRODID:").append(PRODID).append(CRLF)
|
||||
sb.append("CALSCALE:GREGORIAN").append(CRLF)
|
||||
appendVEvent(sb, event, address, nowSeconds)
|
||||
sb.append("END:VCALENDAR").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap multiple appointments (the membership of a kind-31924 calendar) in one VCALENDAR.
|
||||
* Members that aren't in [memberEvents] are silently skipped — typically because they
|
||||
* haven't been fetched from relays yet.
|
||||
*/
|
||||
fun calendarToIcs(
|
||||
calendar: CalendarEvent,
|
||||
memberEvents: List<Pair<Address, Any>>,
|
||||
nowSeconds: Long,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCALENDAR").append(CRLF)
|
||||
sb.append("VERSION:2.0").append(CRLF)
|
||||
sb.append("PRODID:").append(PRODID).append(CRLF)
|
||||
sb.append("CALSCALE:GREGORIAN").append(CRLF)
|
||||
calendar.title()?.let {
|
||||
sb.append("X-WR-CALNAME:").append(escapeText(it)).append(CRLF)
|
||||
}
|
||||
calendar.content.takeIf { it.isNotBlank() }?.let {
|
||||
sb.append("X-WR-CALDESC:").append(escapeText(it)).append(CRLF)
|
||||
}
|
||||
memberEvents.forEach { (address, event) ->
|
||||
appendVEvent(sb, event, address, nowSeconds)
|
||||
}
|
||||
sb.append("END:VCALENDAR").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested filename for a single appointment. Sanitises the title for filesystems but
|
||||
* keeps it short enough for share-sheet thumbnails. Falls back to the d-tag.
|
||||
*/
|
||||
fun appointmentFilename(
|
||||
event: Any,
|
||||
address: Address,
|
||||
): String {
|
||||
val title =
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> event.title()
|
||||
is CalendarDateSlotEvent -> event.title()
|
||||
else -> null
|
||||
}
|
||||
return safeFilename(title ?: address.dTag) + ".ics"
|
||||
}
|
||||
|
||||
fun calendarFilename(calendar: CalendarEvent): String = safeFilename(calendar.title() ?: "calendar") + ".ics"
|
||||
|
||||
private fun safeFilename(raw: String): String =
|
||||
raw
|
||||
.replace(Regex("[^a-zA-Z0-9._-]+"), "-")
|
||||
.trim('-', '_')
|
||||
.ifBlank { "calendar" }
|
||||
.take(60)
|
||||
|
||||
private fun appendVEvent(
|
||||
sb: StringBuilder,
|
||||
event: Any,
|
||||
address: Address,
|
||||
nowSeconds: Long,
|
||||
) {
|
||||
sb.append("BEGIN:VEVENT").append(CRLF)
|
||||
// UID must be globally unique; "<dtag>@<pubkey>.nostr" gives stable uniqueness without
|
||||
// exposing relay metadata.
|
||||
sb
|
||||
.append("UID:")
|
||||
.append(address.dTag)
|
||||
.append('@')
|
||||
.append(address.pubKeyHex)
|
||||
.append(".nostr")
|
||||
.append(CRLF)
|
||||
sb.append("DTSTAMP:").append(formatUtcInstant(nowSeconds)).append(CRLF)
|
||||
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> appendTimeSlot(sb, event)
|
||||
is CalendarDateSlotEvent -> appendDateSlot(sb, event)
|
||||
}
|
||||
|
||||
sb.append("END:VEVENT").append(CRLF)
|
||||
}
|
||||
|
||||
private fun appendTimeSlot(
|
||||
sb: StringBuilder,
|
||||
event: CalendarTimeSlotEvent,
|
||||
) {
|
||||
event.start()?.let { sb.append("DTSTART:").append(formatUtcInstant(it)).append(CRLF) }
|
||||
event.end()?.let { sb.append("DTEND:").append(formatUtcInstant(it)).append(CRLF) }
|
||||
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
|
||||
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
|
||||
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
|
||||
appendCategories(sb, event.hashtags())
|
||||
}
|
||||
|
||||
private fun appendDateSlot(
|
||||
sb: StringBuilder,
|
||||
event: CalendarDateSlotEvent,
|
||||
) {
|
||||
event.start()?.let { iso ->
|
||||
tryFormatBasicDate(iso)?.let { sb.append("DTSTART;VALUE=DATE:").append(it).append(CRLF) }
|
||||
}
|
||||
event.end()?.let { iso ->
|
||||
tryFormatBasicDate(iso)?.let { sb.append("DTEND;VALUE=DATE:").append(it).append(CRLF) }
|
||||
}
|
||||
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
|
||||
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
|
||||
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
|
||||
appendCategories(sb, event.hashtags())
|
||||
}
|
||||
|
||||
private fun appendDescription(
|
||||
sb: StringBuilder,
|
||||
text: String,
|
||||
) {
|
||||
if (text.isBlank()) return
|
||||
sb.append("DESCRIPTION:").append(escapeText(text)).append(CRLF)
|
||||
}
|
||||
|
||||
private fun appendCategories(
|
||||
sb: StringBuilder,
|
||||
hashtags: List<String>,
|
||||
) {
|
||||
if (hashtags.isEmpty()) return
|
||||
sb.append("CATEGORIES:").append(hashtags.joinToString(",") { escapeText(it) }).append(CRLF)
|
||||
}
|
||||
|
||||
private fun formatUtcInstant(unixSeconds: Long): String = UtcStamp.format(Instant.ofEpochSecond(unixSeconds).atOffset(ZoneOffset.UTC))
|
||||
|
||||
private fun tryFormatBasicDate(iso: String): String? =
|
||||
try {
|
||||
IsoBasicDate.format(LocalDate.parse(iso))
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes text per RFC 5545 §3.3.11: backslash, semicolon, comma, newline. Carriage
|
||||
* returns are dropped (line folding handles physical newlines for us).
|
||||
*/
|
||||
internal fun escapeText(raw: String): String =
|
||||
raw
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
|
||||
/**
|
||||
* One bar drawn into one day cell, with `lane` controlling its vertical position so two
|
||||
* overlapping multi-day events stack rather than collide. `isLeftEnd` / `isRightEnd` control
|
||||
* which corners of the bar are rounded — a continuation day in the middle of a 3-day event gets
|
||||
* neither end rounded, so adjacent cells visually merge into one bar.
|
||||
*
|
||||
* Note: the underlying [Note] is exposed so the UI can colour or label bars per event. Equality
|
||||
* is on idHex so a row of cells holding the same bar share segment identity for keys.
|
||||
*/
|
||||
@Immutable
|
||||
data class MonthGridBarSegment(
|
||||
val note: Note,
|
||||
val lane: Int,
|
||||
val isLeftEnd: Boolean,
|
||||
val isRightEnd: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maximum lanes we render before collapsing the remainder into a "+N" overflow label. Three
|
||||
* matches the previous dot-row capacity and keeps each 56dp cell readable on mid-range phones.
|
||||
*/
|
||||
const val MONTH_GRID_MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* Greedy lane-assignment for the month grid: sort events earliest-start-first (longer events
|
||||
* wins ties so they take the top lane), then for each event pick the lowest lane index whose
|
||||
* full day-range is unoccupied. Returns a per-day-key map so each cell can render its own
|
||||
* segments without re-running the layout.
|
||||
*
|
||||
* Single-day events participate in the same layout — they get bars too, just short ones,
|
||||
* which keeps the visual language consistent.
|
||||
*/
|
||||
fun computeMonthGridBars(notes: List<Note>): Map<Long, List<MonthGridBarSegment>> {
|
||||
val ranges =
|
||||
notes
|
||||
.distinctBy { it.idHex }
|
||||
.mapNotNull { n -> n.calendarLocalDayKeyRange()?.let { n to it } }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.second.first },
|
||||
{ -(it.second.last - it.second.first) },
|
||||
{ it.first.idHex },
|
||||
),
|
||||
)
|
||||
|
||||
// day-key → set of lanes already claimed for that day
|
||||
val occupied = mutableMapOf<Long, MutableSet<Int>>()
|
||||
val perDay = mutableMapOf<Long, MutableList<MonthGridBarSegment>>()
|
||||
|
||||
for ((note, range) in ranges) {
|
||||
// Find the lowest lane index whose full range is free. Bounded at 32 so a pathological
|
||||
// input can't loop forever; overflow events still render as "+N" via the cap downstream.
|
||||
var lane = 0
|
||||
while (lane < 32) {
|
||||
val clash = (range).any { occupied[it]?.contains(lane) == true }
|
||||
if (!clash) break
|
||||
lane++
|
||||
}
|
||||
for (day in range) {
|
||||
occupied.getOrPut(day) { mutableSetOf() }.add(lane)
|
||||
perDay.getOrPut(day) { mutableListOf() }.add(
|
||||
MonthGridBarSegment(
|
||||
note = note,
|
||||
lane = lane,
|
||||
isLeftEnd = day == range.first,
|
||||
isRightEnd = day == range.last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Within each cell, sort by lane so the rendering doesn't have to.
|
||||
return perDay.mapValues { (_, list) -> list.sortedBy { it.lane } }
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IcsExportTest {
|
||||
private val nowSeconds = 1_700_000_000L // 2023-11-14 22:13:20 UTC
|
||||
|
||||
@Test
|
||||
fun timeSlot_producesWellFormedVCalendar() {
|
||||
// 1700000000 = 2023-11-14 22:13:20 UTC; +1h = 2023-11-14 23:13:20 UTC.
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "my-event"),
|
||||
arrayOf("title", "Bitcoin meetup"),
|
||||
arrayOf("start", "1700000000"),
|
||||
arrayOf("end", "1700003600"),
|
||||
arrayOf("location", "Storgata 8"),
|
||||
),
|
||||
content = "Casual hang",
|
||||
sig = "sig",
|
||||
)
|
||||
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "my-event"), nowSeconds)
|
||||
|
||||
// Outer envelope is required by every RFC 5545 parser.
|
||||
assertTrue("must wrap in VCALENDAR", ics.contains("BEGIN:VCALENDAR\r\n"))
|
||||
assertTrue("must close VCALENDAR", ics.endsWith("END:VCALENDAR\r\n"))
|
||||
assertTrue("must include VEVENT", ics.contains("BEGIN:VEVENT\r\n"))
|
||||
assertTrue("must close VEVENT", ics.contains("END:VEVENT\r\n"))
|
||||
assertTrue("must have version", ics.contains("VERSION:2.0"))
|
||||
|
||||
// Time-slot stamps are UTC instants.
|
||||
assertTrue("DTSTART must be UTC instant", ics.contains("DTSTART:20231114T221320Z"))
|
||||
assertTrue("DTEND must be UTC instant", ics.contains("DTEND:20231114T231320Z"))
|
||||
|
||||
assertTrue("summary present", ics.contains("SUMMARY:Bitcoin meetup"))
|
||||
assertTrue("location present", ics.contains("LOCATION:Storgata 8"))
|
||||
assertTrue("description present", ics.contains("DESCRIPTION:Casual hang"))
|
||||
assertTrue("UID format", ics.contains("UID:my-event@pub.nostr"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dateSlot_emitsDateValueWithoutTimeComponent() {
|
||||
val event =
|
||||
CalendarDateSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "conf"),
|
||||
arrayOf("title", "Conference"),
|
||||
arrayOf("start", "2025-01-15"),
|
||||
arrayOf("end", "2025-01-17"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val ics = IcsExport.appointmentToIcs(event, Address(31922, "pub", "conf"), nowSeconds)
|
||||
|
||||
// Date-only events use VALUE=DATE so calendar apps don't render them as midnight events.
|
||||
assertTrue("date-slot DTSTART carries VALUE=DATE", ics.contains("DTSTART;VALUE=DATE:20250115"))
|
||||
assertTrue("date-slot DTEND carries VALUE=DATE", ics.contains("DTEND;VALUE=DATE:20250117"))
|
||||
assertFalse("date-slot must not include time component", ics.contains("DTSTART:20250115T"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeText_quotesSpecialCharacters() {
|
||||
// RFC 5545 §3.3.11: backslash, comma, semicolon, newline are reserved.
|
||||
assertEquals("a\\\\b", IcsExport.escapeText("a\\b"))
|
||||
assertEquals("a\\,b", IcsExport.escapeText("a,b"))
|
||||
assertEquals("a\\;b", IcsExport.escapeText("a;b"))
|
||||
assertEquals("line1\\nline2", IcsExport.escapeText("line1\nline2"))
|
||||
assertEquals("a", IcsExport.escapeText("a\r"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapingFiresInSummaryAndDescription() {
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "Hi, world; happy"),
|
||||
arrayOf("start", "1700000000"),
|
||||
),
|
||||
content = "line1\nline2",
|
||||
sig = "sig",
|
||||
)
|
||||
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "x"), nowSeconds)
|
||||
// The escaped string must keep the surrounding `SUMMARY:` prefix and survive whatever
|
||||
// line folding parsers re-apply.
|
||||
assertTrue("comma escaped", ics.contains("SUMMARY:Hi\\, world\\; happy"))
|
||||
assertTrue("newline escaped", ics.contains("DESCRIPTION:line1\\nline2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashtags_renderAsCategoriesLine() {
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "T"),
|
||||
arrayOf("start", "1700000000"),
|
||||
arrayOf("t", "bitcoin"),
|
||||
arrayOf("t", "meetup"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "x"), nowSeconds)
|
||||
assertTrue("CATEGORIES line present", ics.contains("CATEGORIES:bitcoin,meetup"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarToIcs_wrapsAllMembers() {
|
||||
val a =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "a",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "a"),
|
||||
arrayOf("title", "A"),
|
||||
arrayOf("start", "1700000000"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val b =
|
||||
CalendarDateSlotEvent(
|
||||
id = "b",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "b"),
|
||||
arrayOf("title", "B"),
|
||||
arrayOf("start", "2025-02-01"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val calendar =
|
||||
CalendarEvent(
|
||||
id = "cal",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags = arrayOf(arrayOf("d", "my-cal"), arrayOf("title", "My Calendar")),
|
||||
content = "All my events",
|
||||
sig = "sig",
|
||||
)
|
||||
val ics =
|
||||
IcsExport.calendarToIcs(
|
||||
calendar,
|
||||
listOf(
|
||||
Address(31923, "pub", "a") to a,
|
||||
Address(31922, "pub", "b") to b,
|
||||
),
|
||||
nowSeconds,
|
||||
)
|
||||
|
||||
assertTrue("calendar name present", ics.contains("X-WR-CALNAME:My Calendar"))
|
||||
assertTrue("calendar description present", ics.contains("X-WR-CALDESC:All my events"))
|
||||
// Both members must appear inside the one VCALENDAR.
|
||||
assertEquals(
|
||||
"exactly two VEVENT blocks",
|
||||
2,
|
||||
"BEGIN:VEVENT".toRegex().findAll(ics).count(),
|
||||
)
|
||||
assertTrue("member A present", ics.contains("UID:a@pub.nostr"))
|
||||
assertTrue("member B present", ics.contains("UID:b@pub.nostr"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filename_sanitisesPathSeparatorsAndSpaces() {
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "x"),
|
||||
arrayOf("title", "Slashes / & spaces .,;"),
|
||||
arrayOf("start", "1700000000"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val name = IcsExport.appointmentFilename(event, Address(31923, "pub", "x"))
|
||||
// Must end with .ics and contain no filesystem-hostile characters.
|
||||
assertTrue(name.endsWith(".ics"))
|
||||
assertFalse("no slashes", name.contains('/'))
|
||||
assertFalse("no commas", name.contains(','))
|
||||
assertFalse("no semicolons", name.contains(';'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filename_fallsBackToDTagWhenTitleAbsent() {
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags = arrayOf(arrayOf("d", "fallback-dtag"), arrayOf("start", "1700000000")),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
val name = IcsExport.appointmentFilename(event, Address(31923, "pub", "fallback-dtag"))
|
||||
assertEquals("fallback-dtag.ics", name)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user