fix(library): name nested directory entries, and read the schema.org spelling

Two things a real bookshelf turned up.

**A nested directory read "Untitled section".** `Library.kt` reuses
`PublicationSectionRow`, whose title `when` knew only the publication kinds, so a
directory listing another directory fell to `else -> null` and then to the
section placeholder -- both the wrong name and the wrong noun. A directory lists
whatever it likes, so the row now names the library kinds too, learning resources
and piece indexes included.

**A learning resource read as its `d` slug.** `title()` looked only at `title`,
but publishers that tag themselves `type: LearningResource` follow schema.org and
emit `name`/`description`. Both spellings are accepted now, `title`/`summary`
winning where an event carries both, so nothing that renders today changes.

Not a bug, checked and left alone: Laeserin's directory shows `my-book-collection`
because that event carries no `title` *or* `name` at all -- the `d` fallback is
the right answer there.

Verified on device against the same two events that showed the defects: entry 3
of `my-book-collection` now reads "nostr" instead of "Untitled section", and the
German resource reads "Caesar-Scheibe - 30 Buchstaben: A-Z plus Ae Oe Ue ss"
instead of "17xu8qb7". Two tests added for the vocabulary split, including the
precedence case; LibraryEventsTest 11/11 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
This commit is contained in:
Vitor Pamplona
2026-09-08 12:43:12 -04:00
co-authored by Claude Opus 5
parent 9ba5a7663c
commit 6099ed6d03
3 changed files with 66 additions and 2 deletions
@@ -73,6 +73,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.experimental.library.BlossomPieceIndexEvent
import com.vitorpamplona.quartz.experimental.library.BookshelfDirectoryEvent
import com.vitorpamplona.quartz.experimental.library.LearningResourceEvent
import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent
import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent
import com.vitorpamplona.quartz.experimental.publications.PublicationSectionRef
@@ -335,6 +338,12 @@ private fun ObservedSectionRow(
is PublicationContentEvent -> event.titleOrIdentifier()
is LongTextNoteEvent -> event.title()
is WikiNoteEvent -> event.title()
// A bookshelf directory lists whatever it likes, including another directory, so the
// row has to name the library kinds too -- otherwise a nested entry falls through to
// the section placeholder and reads "Untitled section", which it is not.
is BookshelfDirectoryEvent -> event.titleOrIdentifier()
is LearningResourceEvent -> event.titleOrIdentifier()
is BlossomPieceIndexEvent -> event.titleOrIdentifier()
else -> null
} ?: ref.title
@@ -33,6 +33,8 @@ 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.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -55,9 +57,15 @@ class LearningResourceEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
/**
* Publishers split on which vocabulary they use: most emit `title`/`summary`, but the ones
* that tag themselves `type: LearningResource` follow schema.org and emit `name`/`description`
* instead. Reading only the first spelling left those rendering as their `d` slug, so both are
* accepted with `title`/`summary` winning where an event carries both.
*/
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) ?: tags.firstNotNullOfOrNull(NameTag::parse)
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) ?: tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
@@ -65,6 +65,53 @@ class LibraryEventsTest {
assertEquals("Intro to Nostr\nThe basics.\nLesson one.", event.indexableContent())
}
@Test
fun aLearningResourceReadsTheSchemaOrgNameAndDescription() {
// The `type: LearningResource` publishers emit schema.org's spelling. Shape taken from a
// real event (`4fa5d1c4...`, d=17xu8qb7), which rendered as its slug before this.
val event =
LearningResourceEvent(
"id",
author,
0L,
arrayOf(
arrayOf("d", "17xu8qb7"),
arrayOf("type", "LearningResource"),
arrayOf("name", "Caesar-Scheibe"),
arrayOf("description", "Eine Anleitung."),
),
"Body.",
"sig",
)
assertEquals("Caesar-Scheibe", event.title())
assertEquals("Eine Anleitung.", event.summary())
assertEquals("Caesar-Scheibe", event.titleOrIdentifier())
assertEquals("Caesar-Scheibe\nEine Anleitung.\nBody.", event.indexableContent())
}
@Test
fun titleAndSummaryWinOverNameAndDescription() {
val event =
LearningResourceEvent(
"id",
author,
0L,
arrayOf(
arrayOf("d", "both"),
arrayOf("title", "Preferred"),
arrayOf("name", "Ignored"),
arrayOf("summary", "Preferred summary."),
arrayOf("description", "Ignored description."),
),
"",
"sig",
)
assertEquals("Preferred", event.title())
assertEquals("Preferred summary.", event.summary())
}
@Test
fun aLearningResourceFallsBackToItsIdentifier() {
val event = LearningResourceEvent("id", author, 0L, arrayOf(arrayOf("d", "untitled-course")), "", "sig")