
Parses GEDCOM genealogy files into type-safe models, maps family relationships, intelligently parses historical/partial dates, supports MacFamilyTree tags, offers flexible data sources and smart defaults.
Spindler is a delightfully powerful Kotlin Multiplatform Compose library that transforms GEDCOM genealogy files into beautiful, type-safe Kotlin models! 🎉
Whether you're building a family tree app, analysing genealogical data, or just want to explore your family history programmatically, Spindler got you covered! It handles everything from marriage records, family relationships and most important of all tricky date formats from the past centuries!
Built with modern Kotlin Multiplatform magic ✨, it works seamlessly across Android, iOS, and
Desktop - because family trees shouldn't be platform-locked!
Try out the
sampleapp!
Add the dependency in your build.gradle.kts:
Add the mavenCentral repository to your settings.gradle.kts file:
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}Add the dependency:
dependencies {
implementation("com.dontsaybojio:spindler:X.X.X")
}If you're reading from a local file on device.
object SpindlerLocalDataSource {
private val path: String = "files/sample.ged"
private val gedcomIndexDtoToModelMapper: GedcomIndexDtoToModelMapper by lazy { GedcomIndexDtoToModelMapper() }
suspend fun getData(): GedcomIndex {
val text = Res.readBytes(path = path).decodeToString()
return gedcomIndexDtoToModelMapper(text)
}
}
// Usage
val familyTree = SpindlerLocalDataSource.getData()
println("Found ${familyTree.individuals.size} individuals!")
println("Found ${familyTree.families.size} families!") If you're reading from an API that returns .ged.
object SpindlerRemoteDataSource {
private val httpClient = HttpClient()
private val gedcomMapper = GedcomIndexDtoToModelMapper()
suspend fun loadData(url: String, headers: Map<String, String>? = null): GedcomIndex {
try {
val gedcomContent = httpClient.get(url) {
headers {
append(
HttpHeaders.Accept,
"text/plain, text/gedcom, application/octet-stream, */*"
)
headers?.forEach { (key, value) ->
append(key, value)
}
}
}.body<String>()
return gedcomIndexDtoToModelMapper(gedcomContent)
} catch (e: Exception) {
throw RemoteDataSourceException(
"Failed to download or parse GEDCOM file from $url",
e
)
} finally {
close()
}
}
}
// Usage
val familyTree = SpindlerRemoteDataSource.loadData("https://example.com/family.ged")
println("Found ${familyTree.individuals.size} individuals!")
println("Found ${familyTree.families.size} families!") GEDCOM separates the data into groups of Individual and Family, Spindler is structured similar
to it as well. Within those data models, it consist of id: String and node: List<GedcomNode>.
data class Individual(
val id: String,
val nodes: List<GedcomNode>,
)
data class Family(
val id: String,
val nodes: List<GedcomNode>,
)Spindler takes another step further by providing all the common attributes in convenient methods
that handles all the mapping. Here's a code snippet within the Family data model.
data class Family(
val id: String,
val nodes: List<GedcomNode>,
) {
val marriageDateRaw: String?
get() = nodes.firstOrNull { it.tag == Tag.MARRIAGE }?.children
?.firstOrNull { it.tag == Tag.DATE }?.value
val marriageDate: LocalDate?
get() = DateParsing.tryParseDate(marriageDateRaw)
val marriageDateFormatted: String
get() = marriageDate?.toString() ?: "~${marriageDateRaw ?: "N/A"}"
val marriagePlace: String?
get() = nodes.firstOrNull { it.tag == Tag.MARRIAGE }?.children
?.firstOrNull { it.tag == Tag.PLACE }?.value
}val individual = familyTree.individuals["I001"]
// Basic Information
println("Name: ${individual.formattedName}")
println("Given Names: ${individual.givenNames.joinToString(", ")}")
println("Surnames: ${individual.surnames.joinToString(", ")}")
println("Sex: ${individual.sex.name}")
// Life Events
println("Born: ${individual.birthDateFormatted}")
println("Birth Place: ${individual.birthPlace ?: "Unknown"}")
println("Died: ${individual.deathDateFormatted}")
// Additional Details
println("Education: ${individual.education ?: "N/A"}")
println("Religion: ${individual.religion ?: "N/A"}")
// Family Relationships
individual.familyIDAsChild?.let { familyId ->
val childFamily = familyTree.families[familyId]
println("Parents' Family: $familyId")
}
individual.familyIDAsSpouse?.let { familyId ->
val spouseFamily = familyTree.families[familyId]
println("Spouse Family: $familyId")
}
// MacFamilyTree Integration
individual.macFamilyTreeID?.let {
println("MacFamilyTree ID: $it")
}
// Metadata
println("Last Changed: ${individual.changeDate ?: "N/A"}")
println("Created: ${individual.creationDate ?: "N/A"}") val family = familyTree.families["F001"]
// Marriage Information
println("Marriage Date: ${family.marriageDateFormatted}")
println("Marriage Place: ${family.marriagePlace ?: "Unknown"}")
// Family Members
family.husbandID?.let { husbandId ->
val husband = familyTree.individuals[husbandId]
println("Husband: ${husband.formattedName}")
}
family.wifeID?.let { wifeId ->
val wife = familyTree.individuals[wifeId]
println("Wife: ${wife.formattedName}")
}
// Children
if (family.childrenIDs.isNotEmpty()) {
println("Children:")
family.childrenIDs.forEach { childId ->
val child = familyTree.individuals[childId]
println(" - ${child.formattedName}")
}
}
// MacFamilyTree Extensions
family.macFamilyTreeLabel?.let {
println("MacFamilyTree Label: $it")
} Like how GEDCOM structures their data, each Individual and Familywould have their related IDs
store in their data model.
individual.familyIDAsChild
individual.familyIDAsSpouse
family.husbandID
family.wifeID
family.childrenIDs| Property | Type | Description |
|---|---|---|
id |
String |
Unique individual identifier from GEDCOM |
formattedName |
String |
Complete name (given names + surnames) |
givenNames |
List<String> |
All given/first names |
surnames |
List<String> |
All surname/family names |
sex |
Sex |
Gender (MALE, FEMALE, UNKNOWN) |
birthDate |
LocalDate? |
Parsed birth date (null if unparseable) |
birthDateRaw |
String? |
Original birth date string from GEDCOM |
birthDateFormatted |
String |
User-friendly birth date display |
birthPlace |
String? |
Birth location |
deathDate |
LocalDate? |
Parsed death date (null if unparseable) |
deathDateRaw |
String? |
Original death date string from GEDCOM |
deathDateFormatted |
String |
User-friendly death date display |
education |
String? |
Educational information |
religion |
String? |
Religious affiliation |
familyIDAsChild |
String? |
Family ID where this person is a child |
familyIDAsSpouse |
String? |
Family ID where this person is a spouse |
macFamilyTreeID |
String? |
MacFamilyTree-specific identifier (_FID) |
changeDate |
String? |
Last modification date |
creationDate |
String? |
Creation date |
nodes |
List<GedcomNode> |
Raw GEDCOM nodes for advanced access |
| Property | Type | Description |
|---|---|---|
id |
String |
Unique family identifier from GEDCOM |
marriageDate |
LocalDate? |
Parsed marriage date (null if unparseable) |
marriageDateRaw |
String? |
Original marriage date string from GEDCOM |
marriageDateFormatted |
String |
User-friendly marriage date display |
marriagePlace |
String? |
Marriage location |
husbandID |
String? |
Individual ID of the husband |
wifeID |
String? |
Individual ID of the wife |
childrenIDs |
List<String> |
List of individual IDs for all children |
macFamilyTreeLabel |
String? |
MacFamilyTree-specific label |
changeDate |
String? |
Last modification date |
creationDate |
String? |
Creation date |
nodes |
List<GedcomNode> |
Raw GEDCOM nodes for advanced access |
| Property | Type | Description |
|---|---|---|
individuals |
Map<String, Individual> |
All individuals indexed by ID |
families |
Map<String, Family> |
All families indexed by ID |
Spindler handles complex historical dates automatically:
// These all parse correctly:
// "1845" -> 1845-01-01
// "ABT 1845" -> ~1845 (approximate)
// "BEF 1900" -> ~1900 (before)
// "EST 1820" -> ~1820 (estimated)
// "25 DEC 1800" -> 1800-12-25
val individual = familyTree.individuals["I001"]
individual.birthDate // LocalDate? - null if not parseable
individual.birthDateRaw // String? - Raw GEDCOM text
individual.birthDateFormatted // String - always has a value For advanced use cases or if the methods aren't covered, you can easily use the GedcomNode to
access the raw GEDCOM structure to get what you need:
val individual = familyTree.individuals["I001"]
// Find all custom tags
val customTags = individual.nodes.filter {
it.tag.startsWith("_") // Custom tags often start with _}
// Access specific node data
val occupationNode = individual.nodes.firstOrNull { it.tag == "OCCU" }
val occupation = occupationNode?.valueWe'd love your help making Spindler even better! Here's how:
Spindler is a delightfully powerful Kotlin Multiplatform Compose library that transforms GEDCOM genealogy files into beautiful, type-safe Kotlin models! 🎉
Whether you're building a family tree app, analysing genealogical data, or just want to explore your family history programmatically, Spindler got you covered! It handles everything from marriage records, family relationships and most important of all tricky date formats from the past centuries!
Built with modern Kotlin Multiplatform magic ✨, it works seamlessly across Android, iOS, and
Desktop - because family trees shouldn't be platform-locked!
Try out the
sampleapp!
Add the dependency in your build.gradle.kts:
Add the mavenCentral repository to your settings.gradle.kts file:
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}Add the dependency:
dependencies {
implementation("com.dontsaybojio:spindler:X.X.X")
}If you're reading from a local file on device.
object SpindlerLocalDataSource {
private val path: String = "files/sample.ged"
private val gedcomIndexDtoToModelMapper: GedcomIndexDtoToModelMapper by lazy { GedcomIndexDtoToModelMapper() }
suspend fun getData(): GedcomIndex {
val text = Res.readBytes(path = path).decodeToString()
return gedcomIndexDtoToModelMapper(text)
}
}
// Usage
val familyTree = SpindlerLocalDataSource.getData()
println("Found ${familyTree.individuals.size} individuals!")
println("Found ${familyTree.families.size} families!") If you're reading from an API that returns .ged.
object SpindlerRemoteDataSource {
private val httpClient = HttpClient()
private val gedcomMapper = GedcomIndexDtoToModelMapper()
suspend fun loadData(url: String, headers: Map<String, String>? = null): GedcomIndex {
try {
val gedcomContent = httpClient.get(url) {
headers {
append(
HttpHeaders.Accept,
"text/plain, text/gedcom, application/octet-stream, */*"
)
headers?.forEach { (key, value) ->
append(key, value)
}
}
}.body<String>()
return gedcomIndexDtoToModelMapper(gedcomContent)
} catch (e: Exception) {
throw RemoteDataSourceException(
"Failed to download or parse GEDCOM file from $url",
e
)
} finally {
close()
}
}
}
// Usage
val familyTree = SpindlerRemoteDataSource.loadData("https://example.com/family.ged")
println("Found ${familyTree.individuals.size} individuals!")
println("Found ${familyTree.families.size} families!") GEDCOM separates the data into groups of Individual and Family, Spindler is structured similar
to it as well. Within those data models, it consist of id: String and node: List<GedcomNode>.
data class Individual(
val id: String,
val nodes: List<GedcomNode>,
)
data class Family(
val id: String,
val nodes: List<GedcomNode>,
)Spindler takes another step further by providing all the common attributes in convenient methods
that handles all the mapping. Here's a code snippet within the Family data model.
data class Family(
val id: String,
val nodes: List<GedcomNode>,
) {
val marriageDateRaw: String?
get() = nodes.firstOrNull { it.tag == Tag.MARRIAGE }?.children
?.firstOrNull { it.tag == Tag.DATE }?.value
val marriageDate: LocalDate?
get() = DateParsing.tryParseDate(marriageDateRaw)
val marriageDateFormatted: String
get() = marriageDate?.toString() ?: "~${marriageDateRaw ?: "N/A"}"
val marriagePlace: String?
get() = nodes.firstOrNull { it.tag == Tag.MARRIAGE }?.children
?.firstOrNull { it.tag == Tag.PLACE }?.value
}val individual = familyTree.individuals["I001"]
// Basic Information
println("Name: ${individual.formattedName}")
println("Given Names: ${individual.givenNames.joinToString(", ")}")
println("Surnames: ${individual.surnames.joinToString(", ")}")
println("Sex: ${individual.sex.name}")
// Life Events
println("Born: ${individual.birthDateFormatted}")
println("Birth Place: ${individual.birthPlace ?: "Unknown"}")
println("Died: ${individual.deathDateFormatted}")
// Additional Details
println("Education: ${individual.education ?: "N/A"}")
println("Religion: ${individual.religion ?: "N/A"}")
// Family Relationships
individual.familyIDAsChild?.let { familyId ->
val childFamily = familyTree.families[familyId]
println("Parents' Family: $familyId")
}
individual.familyIDAsSpouse?.let { familyId ->
val spouseFamily = familyTree.families[familyId]
println("Spouse Family: $familyId")
}
// MacFamilyTree Integration
individual.macFamilyTreeID?.let {
println("MacFamilyTree ID: $it")
}
// Metadata
println("Last Changed: ${individual.changeDate ?: "N/A"}")
println("Created: ${individual.creationDate ?: "N/A"}") val family = familyTree.families["F001"]
// Marriage Information
println("Marriage Date: ${family.marriageDateFormatted}")
println("Marriage Place: ${family.marriagePlace ?: "Unknown"}")
// Family Members
family.husbandID?.let { husbandId ->
val husband = familyTree.individuals[husbandId]
println("Husband: ${husband.formattedName}")
}
family.wifeID?.let { wifeId ->
val wife = familyTree.individuals[wifeId]
println("Wife: ${wife.formattedName}")
}
// Children
if (family.childrenIDs.isNotEmpty()) {
println("Children:")
family.childrenIDs.forEach { childId ->
val child = familyTree.individuals[childId]
println(" - ${child.formattedName}")
}
}
// MacFamilyTree Extensions
family.macFamilyTreeLabel?.let {
println("MacFamilyTree Label: $it")
} Like how GEDCOM structures their data, each Individual and Familywould have their related IDs
store in their data model.
individual.familyIDAsChild
individual.familyIDAsSpouse
family.husbandID
family.wifeID
family.childrenIDs| Property | Type | Description |
|---|---|---|
id |
String |
Unique individual identifier from GEDCOM |
formattedName |
String |
Complete name (given names + surnames) |
givenNames |
List<String> |
All given/first names |
surnames |
List<String> |
All surname/family names |
sex |
Sex |
Gender (MALE, FEMALE, UNKNOWN) |
birthDate |
LocalDate? |
Parsed birth date (null if unparseable) |
birthDateRaw |
String? |
Original birth date string from GEDCOM |
birthDateFormatted |
String |
User-friendly birth date display |
birthPlace |
String? |
Birth location |
deathDate |
LocalDate? |
Parsed death date (null if unparseable) |
deathDateRaw |
String? |
Original death date string from GEDCOM |
deathDateFormatted |
String |
User-friendly death date display |
education |
String? |
Educational information |
religion |
String? |
Religious affiliation |
familyIDAsChild |
String? |
Family ID where this person is a child |
familyIDAsSpouse |
String? |
Family ID where this person is a spouse |
macFamilyTreeID |
String? |
MacFamilyTree-specific identifier (_FID) |
changeDate |
String? |
Last modification date |
creationDate |
String? |
Creation date |
nodes |
List<GedcomNode> |
Raw GEDCOM nodes for advanced access |
| Property | Type | Description |
|---|---|---|
id |
String |
Unique family identifier from GEDCOM |
marriageDate |
LocalDate? |
Parsed marriage date (null if unparseable) |
marriageDateRaw |
String? |
Original marriage date string from GEDCOM |
marriageDateFormatted |
String |
User-friendly marriage date display |
marriagePlace |
String? |
Marriage location |
husbandID |
String? |
Individual ID of the husband |
wifeID |
String? |
Individual ID of the wife |
childrenIDs |
List<String> |
List of individual IDs for all children |
macFamilyTreeLabel |
String? |
MacFamilyTree-specific label |
changeDate |
String? |
Last modification date |
creationDate |
String? |
Creation date |
nodes |
List<GedcomNode> |
Raw GEDCOM nodes for advanced access |
| Property | Type | Description |
|---|---|---|
individuals |
Map<String, Individual> |
All individuals indexed by ID |
families |
Map<String, Family> |
All families indexed by ID |
Spindler handles complex historical dates automatically:
// These all parse correctly:
// "1845" -> 1845-01-01
// "ABT 1845" -> ~1845 (approximate)
// "BEF 1900" -> ~1900 (before)
// "EST 1820" -> ~1820 (estimated)
// "25 DEC 1800" -> 1800-12-25
val individual = familyTree.individuals["I001"]
individual.birthDate // LocalDate? - null if not parseable
individual.birthDateRaw // String? - Raw GEDCOM text
individual.birthDateFormatted // String - always has a value For advanced use cases or if the methods aren't covered, you can easily use the GedcomNode to
access the raw GEDCOM structure to get what you need:
val individual = familyTree.individuals["I001"]
// Find all custom tags
val customTags = individual.nodes.filter {
it.tag.startsWith("_") // Custom tags often start with _}
// Access specific node data
val occupationNode = individual.nodes.firstOrNull { it.tag == "OCCU" }
val occupation = occupationNode?.valueWe'd love your help making Spindler even better! Here's how: