diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt new file mode 100644 index 0000000000..a1181ee75b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.nip52Calendar.appt.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-52's uppercase `D` day index on a kind:31923 time-based calendar event: the + * day-granularity unix timestamp `floor(unix_seconds / 86400)`, one tag per UTC calendar + * day the event's `start`..`end` range spans. + * + * It is what makes an event discoverable by date — a client browsing "what's on the 14th" + * queries `{"kinds":[31923],"#D":[""]}` instead of pulling every calendar event in + * existence and filtering client-side. Uppercase single-letter tags are indexed by relays + * exactly like lowercase ones; the case only distinguishes it from the `d` identifier. + */ +class DayIndexTag { + companion object { + const val TAG_NAME = "D" + + const val SECONDS_IN_DAY = 86400L + + /** + * The most `D` tags one event may carry. NIP-52 puts no ceiling on the range, but a + * multi-year event would otherwise emit thousands of tags and blow past relay event-size + * limits; capping keeps a mistyped end date from producing an unpublishable event. Beyond + * the cap the range is truncated (the start day is always emitted), never dropped. + */ + const val MAX_DAYS = 366 + + /** The day index containing [timestamp] (unix seconds). */ + fun dayIndex(timestamp: Long): Long = timestamp.floorDiv(SECONDS_IN_DAY) + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(dayIndex: Long) = arrayOf(TAG_NAME, dayIndex.toString()) + + /** + * Every day index a `start`..`end` range touches, in order. + * + * `end` is **exclusive** per NIP-52, so an event that finishes exactly at midnight does not + * claim the following day; an event with no end (or an end at/before start) is instantaneous + * and occupies only its start day. Result is capped at [MAX_DAYS]. + */ + fun dayIndexes( + start: Long, + end: Long? = null, + ): List { + val first = dayIndex(start) + if (end == null || end <= start) return listOf(first) + + // An exclusive end lands on the first instant NOT in the range, so the last day is the + // one holding end-1. Without the -1, an event ending at 00:00:00 would tag the next day. + val last = dayIndex(end - 1) + if (last <= first) return listOf(first) + + return (first..minOf(last, first + MAX_DAYS - 1)).toList() + } + + /** [dayIndexes] already assembled into tags. */ + fun assembleAll( + start: Long, + end: Long? = null, + ) = dayIndexes(start, end).map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt index af20d0abfa..a5888a45c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -76,6 +77,9 @@ class CalendarTimeSlotEvent( fun endTzId() = tags.firstTagValue("end_tzid") + /** The NIP-52 `D` day indexes this event claims. Empty for an event published without them. */ + fun dayIndexes() = tags.mapNotNull(DayIndexTag.Companion::parse) + fun summary() = tags.firstNotNullOfOrNull(SummaryTag.Companion::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag.Companion::parse) @@ -109,6 +113,9 @@ class CalendarTimeSlotEvent( end?.let { endTimestamp(it) } startTzId?.let { startTzId(it) } endTzId?.let { endTzId(it) } + // NIP-52 requires the `D` day index on 31923; without it the event is invisible to + // date-indexed calendar clients, which query by day rather than scanning every event. + dayIndexes(start, end) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt index 092eab71e0..9c8e4e06e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag fun TagArrayBuilder.titleTime(title: String) = addUnique(TitleTag.assemble(title)) @@ -34,6 +35,16 @@ fun TagArrayBuilder.startTimestamp(timestamp: Long) = add fun TagArrayBuilder.endTimestamp(timestamp: Long) = addUnique(arrayOf("end", timestamp.toString())) +/** + * Replaces the `D` day-index set with the one [start]..[end] spans (NIP-52). Removing first is what + * makes an edit correct: shortening an event that used to run three days must drop the two days it + * no longer covers, and appending alone would leave them claiming it forever. + */ +fun TagArrayBuilder.dayIndexes( + start: Long, + end: Long? = null, +) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end)) + fun TagArrayBuilder.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId)) fun TagArrayBuilder.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt new file mode 100644 index 0000000000..3805ebd246 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt @@ -0,0 +1,133 @@ +/* + * 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.quartz.nip52Calendar + +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.dayIndexes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * NIP-52's uppercase `D` day index on kind:31923 — the tag that makes a calendar event + * discoverable by date. Covers the exclusive-`end` boundary and the case split from the + * lowercase `d` identifier, which shares the same tag map. + */ +class DayIndexTagTest { + // 2026-02-14T00:00:00Z — day index 20498. + private val feb14 = 1771027200L + private val dayIndexFeb14 = 20498L + + @Test + fun dayIndexMatchesTheSpecFormula() { + // The worked example from NIP-52 itself. + assertEquals(82549L, DayIndexTag.dayIndex(82549L * 86400L)) + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14)) + // Any instant inside the day maps to the same index. + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14 + 86399)) + } + + @Test + fun noEndMeansASingleDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 3600)) + } + + @Test + fun endBeforeOrAtStartStillEmitsTheStartDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14)) + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 100, feb14)) + } + + @Test + fun aMultiDayRangeCoversEveryDayItTouches() { + // 14th 10:00 → 16th 15:00 spans three days. + val days = DayIndexTag.dayIndexes(feb14 + 10 * 3600, feb14 + 2 * 86400 + 15 * 3600) + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1, dayIndexFeb14 + 2), days) + } + + @Test + fun endIsExclusiveSoMidnightDoesNotClaimTheNextDay() { + // Ends exactly at the 15th 00:00:00 — that instant belongs to the 15th, but the event + // does not, so only the 14th is tagged. + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14 + 86400)) + // One second later it genuinely runs into the 15th. + assertEquals( + listOf(dayIndexFeb14, dayIndexFeb14 + 1), + DayIndexTag.dayIndexes(feb14, feb14 + 86401), + ) + } + + @Test + fun anAbsurdRangeIsCappedRatherThanEmittingThousandsOfTags() { + val days = DayIndexTag.dayIndexes(feb14, feb14 + 4000L * 86400L) + assertEquals(DayIndexTag.MAX_DAYS, days.size) + assertEquals(dayIndexFeb14, days.first()) + } + + @Test + fun buildEmitsTheDayTagsAndKeepsTheIdentifierSeparate() { + val template = + CalendarTimeSlotEvent.build( + title = "Nostrautica", + start = feb14 + 10 * 3600, + end = feb14 + 86400 + 2 * 3600, + dTag = "my-event", + ) + + val dayTags = template.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString(), (dayIndexFeb14 + 1).toString()), dayTags) + + // The uppercase D must not disturb the lowercase d identifier — they collide in the + // builder's tag map if either side is ever case-folded. + assertEquals("my-event", template.tags.firstTagValue("d")) + } + + @Test + fun parseReadsBackWhatBuildWrote() { + val template = CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 86401) + val event = CalendarTimeSlotEvent("id", "pub", 0L, template.tags, template.content, "sig") + + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1), event.dayIndexes()) + } + + @Test + fun rebuildingAfterAnEditDropsDaysTheEventNoLongerCovers() { + // A three-day event shortened to one: the two dropped days must not linger, or they keep + // advertising the event on dates it no longer runs. + val shortened = + CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 3 * 86400, dTag = "same") { + dayIndexes(feb14, feb14 + 3600) + } + + val dayTags = shortened.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString()), dayTags) + } + + @Test + fun parseRejectsNonDayTagsAndGarbage() { + assertEquals(null, DayIndexTag.parse(arrayOf("d", "1234"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D", "not-a-number"))) + assertTrue(DayIndexTag.parse(arrayOf("D", "82549")) == 82549L) + } +}