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:
Claude
2026-05-19 20:11:43 +00:00
parent ab5d884b34
commit 5116bc21ae
26 changed files with 187 additions and 113 deletions
@@ -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
}
@@ -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)
}
@@ -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(";", "\\;")
}
@@ -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 } }
}
@@ -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)
}
}