From 78c29660510847f88b610cf69d9caba1d098dfb4 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 8 Oct 2026 22:33:46 +0300 Subject: [PATCH] feat(authors): aliases, life years and era from Sefaria author topics Read authors.json (SefariaExport PR #7): for each Sefaria author slug, store the other Hebrew titles in author_alias and the birth/death years (+ approx flags) and era on author. author.name stays the raw name, the natural key of its id. author_alias is tracked by the patch producer and the logical content hash. Signed-off-by: Elie Gambache --- .../dao/repository/SeforimRepository.kt | 22 ++++++++++ .../seforimlibrary/db/AuthorQueries.sq | 11 +++++ .../seforimlibrary/db/Database.sq | 21 ++++++++- .../common/patch/LogicalContentHasher.kt | 1 + .../common/patch/PatchTables.kt | 1 + .../sefariasqlite/SefariaAuthorTopics.kt | 43 +++++++++++++++++++ .../sefariasqlite/SefariaBookPayloadReader.kt | 9 +++- .../sefariasqlite/SefariaDirectImporter.kt | 17 +++++++- .../sefariasqlite/SefariaImportModels.kt | 2 + .../sefariasqlite/SefariaAuthorTopicsTest.kt | 31 +++++++++++++ 10 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopics.kt create mode 100644 generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopicsTest.kt diff --git a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt index 2989896f..6f2b81ff 100644 --- a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt @@ -2708,6 +2708,28 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L database.authorQueriesQueries.insertWithId(id, name) } + suspend fun updateAuthorLifeData( + authorId: Long, + birthYear: Int?, + birthYearApprox: Boolean?, + deathYear: Int?, + deathYearApprox: Boolean?, + era: String?, + ) = withContext(Dispatchers.IO) { + database.authorQueriesQueries.updateLifeData( + birthYear?.toLong(), + birthYearApprox?.let { if (it) 1L else 0L }, + deathYear?.toLong(), + deathYearApprox?.let { if (it) 1L else 0L }, + era, + authorId, + ) + } + + suspend fun insertAuthorAlias(authorId: Long, alias: String) = withContext(Dispatchers.IO) { + database.authorQueriesQueries.insertAlias(authorId, alias) + } + suspend fun insertLicenseWithId( id: Long, code: String, diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/AuthorQueries.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/AuthorQueries.sq index 2cd062c9..1a8b5f55 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/AuthorQueries.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/AuthorQueries.sq @@ -27,6 +27,17 @@ VALUES (?); insertWithId: INSERT OR IGNORE INTO author (id, name) VALUES (?, ?); +updateLifeData: +UPDATE author +SET birthYear = ?, birthYearApprox = ?, deathYear = ?, deathYearApprox = ?, era = ? +WHERE id = ?; + +insertAlias: +INSERT OR IGNORE INTO author_alias (authorId, alias) VALUES (?, ?); + +selectAliasesByAuthorId: +SELECT alias FROM author_alias WHERE authorId = ? ORDER BY alias; + selectIdByName: SELECT id FROM author WHERE name = ? LIMIT 1; diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq index e77b2cb2..94020e76 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq @@ -55,6 +55,7 @@ -- default_commentator.commentatorBookId -> book(id) ON DELETE CASCADE -- default_targum.bookId -> book(id) ON DELETE CASCADE -- default_targum.targumBookId -> book(id) ON DELETE CASCADE +-- author_alias.authorId -> author(id) ON DELETE CASCADE -- edition.licenseId -> license(id) ON DELETE RESTRICT -- book_edition.bookId -> book(id) ON DELETE CASCADE -- book_edition.editionId -> edition(id) ON DELETE CASCADE @@ -95,11 +96,29 @@ CREATE INDEX IF NOT EXISTS idx_category_closure_descendant ON category_closure(d -- Authors table CREATE TABLE IF NOT EXISTS author ( id INTEGER PRIMARY KEY NOT NULL, - name TEXT NOT NULL UNIQUE + -- Raw upstream name: the id's natural key, never rewritten. + name TEXT NOT NULL UNIQUE, + -- Life data from the Sefaria author topic, when known. + birthYear INTEGER, + birthYearApprox INTEGER, + deathYear INTEGER, + deathYearApprox INTEGER, + -- Sefaria era code (T, A, GN, RI, AH, CO...). + era TEXT ); CREATE INDEX IF NOT EXISTS idx_author_name ON author(name); +-- Other names of an author (רעק"א, ראב"ע...), for author search. +CREATE TABLE IF NOT EXISTS author_alias ( + authorId INTEGER NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (authorId, alias), + FOREIGN KEY (authorId) REFERENCES author(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_author_alias_alias ON author_alias(alias); + -- Table des topics CREATE TABLE IF NOT EXISTS topic ( id INTEGER PRIMARY KEY NOT NULL, diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt index 8f8114dc..7745f015 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt @@ -87,6 +87,7 @@ class LogicalContentHasher( "book", "book_topic", "book_author", + "author_alias", "book_pub_place", "book_pub_date", "book_edition", diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt index f04521c3..d62153d1 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt @@ -48,6 +48,7 @@ internal val PATCH_TABLES_IN_FK_ORDER: List = listOf( // Book-attribute junctions — depend on book + author/topic/pubPlace/pubDate. PatchTable("book_author", listOf("bookId", "authorId"), updatable = false), + PatchTable("author_alias", listOf("authorId", "alias"), updatable = false), PatchTable("book_topic", listOf("bookId", "topicId"), updatable = false), PatchTable("book_pub_place", listOf("bookId", "pubPlaceId"), updatable = false), PatchTable("book_pub_date", listOf("bookId", "pubDateId"), updatable = false), diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopics.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopics.kt new file mode 100644 index 00000000..eb4b1016 --- /dev/null +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopics.kt @@ -0,0 +1,43 @@ +package io.github.kdroidfilter.seforimlibrary.sefariasqlite + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** One author topic of `authors.json`, as dumped raw by SefariaExport. */ +@Serializable +internal data class SefariaAuthorTopic( + val slug: String, + val heTitles: List = emptyList(), + val birthYear: Int? = null, + val birthYearIsApprox: Boolean? = null, + val deathYear: Int? = null, + val deathYearIsApprox: Boolean? = null, + val era: String? = null, +) { + /** The Hebrew titles other than [name], trimmed and deduplicated. */ + fun aliasesOf(name: String): List = + heTitles.map { it.trim() }.filter { it.isNotEmpty() && it != name }.distinct() +} + +/** Sefaria author topics keyed by slug, from `authors.json`. */ +internal class SefariaAuthorTopics(topics: List) { + private val bySlug = topics.associateBy { it.slug } + + operator fun get(slug: String): SefariaAuthorTopic? = bySlug[slug] + + companion object { + const val FILE_NAME = "authors.json" + + fun load(dbRoot: Path, json: Json): SefariaAuthorTopics { + val file = dbRoot.resolve(FILE_NAME) + require(file.exists()) { + "$FILE_NAME missing from the Sefaria export at $dbRoot " + + "(produced by SefariaExport since PR #7)" + } + return SefariaAuthorTopics(json.decodeFromString>(file.readText())) + } + } +} diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt index 416c3f33..abb09ebc 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt @@ -105,7 +105,13 @@ internal class SefariaBookPayloadReader( ?: textJson["categories"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() - val authors = schemaJson["authors"]?.jsonArray?.mapNotNull(::resolveSefariaAuthorName).orEmpty() + val authorEntries = schemaJson["authors"]?.jsonArray.orEmpty() + val authors = authorEntries.mapNotNull(::resolveSefariaAuthorName) + val authorSlugs = authorEntries.mapNotNull { entry -> + val name = resolveSefariaAuthorName(entry) ?: return@mapNotNull null + val slug = (entry as? JsonObject)?.get("slug")?.stringOrNull() ?: return@mapNotNull null + name to slug + }.toMap() val (lines, refs, headings) = buildBookContent( schemaObj = schemaObj, @@ -157,6 +163,7 @@ internal class SefariaBookPayloadReader( titleAliasKeys = titleAliasKeys, isReferenceWork = isReferenceWork, sefariaTitle = fileTitle ?: englishTitle, + authorSlugs = authorSlugs, versions = versions, ) }.onFailure { e -> diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt index 41d27048..d4a2089b 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt @@ -87,6 +87,8 @@ class SefariaDirectImporter( // Resolve every merged version's license up front: a version missing from // versions.json means the export is inconsistent, so fail before inserting anything. val versionLicenses = SefariaVersionLicenses.load(dbRoot, json) + val authorTopics = SefariaAuthorTopics.load(dbRoot, json) + val enrichedAuthorIds = HashSet() val licensesByTitle = bookPayloads.associate { payload -> payload.sefariaTitle to payload.versions.map { version -> versionLicenses.resolve(payload.sefariaTitle, version.versionTitle) @@ -237,7 +239,20 @@ class SefariaDirectImporter( // would assign a fresh auto-increment id on every build and break the // delta producer's secondary-UNIQUE collision pre-check). val resolvedAuthors = payload.authors.map { name -> - Author(id = bindings.upsertAuthor(name), name = name) + val authorId = bindings.upsertAuthor(name) + val topic = payload.authorSlugs[name]?.let { authorTopics[it] } + if (topic != null && enrichedAuthorIds.add(authorId)) { + repository.updateAuthorLifeData( + authorId = authorId, + birthYear = topic.birthYear, + birthYearApprox = topic.birthYearIsApprox, + deathYear = topic.deathYear, + deathYearApprox = topic.deathYearIsApprox, + era = topic.era, + ) + topic.aliasesOf(name).forEach { repository.insertAuthorAlias(authorId, it) } + } + Author(id = authorId, name = name) } val resolvedPubDates = payload.pubDates.map { pd -> io.github.kdroidfilter.seforimlibrary.core.models.PubDate( diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt index 0bd0db3a..0900647d 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt @@ -75,6 +75,8 @@ internal data class BookPayload( // joined against versions.json to resolve each version's license. val sefariaTitle: String = enTitle, val versions: List = emptyList(), + // Sefaria topic slug of each author, keyed by the resolved author name. + val authorSlugs: Map = emptyMap(), ) internal data class SefariaVersionRef( diff --git a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopicsTest.kt b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopicsTest.kt new file mode 100644 index 00000000..68ffc0ac --- /dev/null +++ b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAuthorTopicsTest.kt @@ -0,0 +1,31 @@ +package io.github.kdroidfilter.seforimlibrary.sefariasqlite + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SefariaAuthorTopicsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parsesExportRecords() { + val topics = SefariaAuthorTopics( + json.decodeFromString( + """[{"slug":"rambam","heTitles":["רמב\"ם","משה בן מימון"],"birthYear":1137, + "birthYearIsApprox":null,"deathYear":1204,"deathYearIsApprox":false,"era":"RI"}]""", + ), + ) + val rambam = topics["rambam"]!! + assertEquals(1204, rambam.deathYear) + assertNull(rambam.birthYearIsApprox) + assertEquals("RI", rambam.era) + assertNull(topics["unknown-slug"]) + } + + @Test + fun aliasesExcludeTheAuthorNameAndBlanks() { + val topic = SefariaAuthorTopic(slug = "akiva-eiger", heTitles = listOf("עקיבא איגר", " רעק\"א ", "", "רעק\"א")) + assertEquals(listOf("רעק\"א"), topic.aliasesOf("עקיבא איגר")) + } +}