feat(nip52): emit the uppercase D day-index tags on kind 31923

NIP-52 requires a `D` tag on time-based calendar events -- the day-granularity
unix timestamp floor(unix_seconds / 86400), one per UTC day the start..end
range spans. Amethyst emitted none, so events it authored were invisible to
clients that discover calendar events by date (#D queries) rather than by
scanning every 31923 in existence.

CalendarTimeSlotEvent.build now emits the full set, which covers editing too:
the create/edit screen rebuilds the whole event through build(), and the
builder extension removes before it adds, so shortening an event drops the
days it no longer covers instead of leaving them claiming it forever.

end is exclusive per the spec, so an event finishing exactly at midnight does
not tag the following day. The range is capped so a mistyped end date cannot
produce an event too large to publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
This commit is contained in:
Claude
2026-09-10 23:30:52 +00:00
parent 44b7ae165f
commit 5a5c6cffa8
4 changed files with 240 additions and 0 deletions
@@ -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":["<index>"]}` 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<String>): 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<Long> {
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) }
}
}
@@ -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()
}
}
@@ -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<CalendarTimeSlotEvent>.titleTime(title: String) = addUnique(TitleTag.assemble(title))
@@ -34,6 +35,16 @@ fun TagArrayBuilder<CalendarTimeSlotEvent>.startTimestamp(timestamp: Long) = add
fun TagArrayBuilder<CalendarTimeSlotEvent>.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<CalendarTimeSlotEvent>.dayIndexes(
start: Long,
end: Long? = null,
) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end))
fun TagArrayBuilder<CalendarTimeSlotEvent>.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId))
fun TagArrayBuilder<CalendarTimeSlotEvent>.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId))
@@ -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)
}
}