Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3b4ed71
feat(generator): skip duplicate books when Sefaria version exists
kdroidFilter Jan 24, 2026
f06a5ea
feat(book): add heRef property for stable Hebrew reference identifica…
kdroidFilter Jan 24, 2026
68d300b
Merge pull request #40 from kdroidFilter/feat/add-book-heref
kdroidFilter Jan 24, 2026
2425b1b
Merge pull request #41 from kdroidFilter/feat/sefaria-priority-dedup
kdroidFilter Jan 24, 2026
16ff587
fix: rename "פירושים מודרניים" categories to "מחברי זמננו"
kdroidFilter Jan 24, 2026
8fb9ef7
Merge pull request #42 from kdroidFilter/fix/rename-modern-categories
kdroidFilter Jan 24, 2026
45fa366
fix: use base_text_order for commentary book ordering
kdroidFilter Jan 24, 2026
fd3d191
Merge pull request #43 from kdroidFilter/fix/book-ordering-base-text-…
kdroidFilter Jan 24, 2026
8daec49
fix: skip empty categories when building catalog
kdroidFilter Jan 24, 2026
da06691
Merge pull request #44 from kdroidFilter/fix/skip-empty-categories-in…
kdroidFilter Jan 24, 2026
5c66e3b
feat: unify Sefaria/Otzaria category naming with merge support
kdroidFilter Jan 24, 2026
5578e22
Merge pull request #45 from kdroidFilter/fix/unify-sefaria-otzaria-ca…
kdroidFilter Jan 24, 2026
5f51c61
feat(search): add computeFacets() for instant aggregates
kdroidFilter Jan 25, 2026
3be58f0
feat(search): add baseBookOnly filter and ancestorCategoryIds indexing
kdroidFilter Jan 25, 2026
587ce5e
Merge pull request #47 from kdroidFilter/feat/instant-lucene-filterin…
kdroidFilter Jan 25, 2026
9b77f4c
refactor(dao): extract LineSelectionRepository interface for testability
kdroidFilter Jan 29, 2026
c36fc66
docs: add CLI README with build and usage instructions
kdroidFilter Jan 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ tasks.register("generateSeforimDb") {
dependsOn(":sefariasqlite:generateSefariaSqlite")
dependsOn(":otzariasqlite:appendOtzaria")
dependsOn(":otzariasqlite:generateHavroutaLinks")
dependsOn(":sefariasqlite:renameCategories")
dependsOn(":catalog:buildCatalog")
dependsOn(":searchindex:buildLuceneIndexDefault")
dependsOn(":packaging:writeReleaseInfo")
Expand All @@ -31,6 +32,9 @@ project(":otzariasqlite").tasks.matching {
"appendOtzaria"
)
}.configureEach {
mustRunAfter(":sefariasqlite:renameCategories")
}
project(":sefariasqlite").tasks.matching { it.name == "renameCategories" }.configureEach {
mustRunAfter(":sefariasqlite:generateSefariaSqlite")
}
project(":otzariasqlite").tasks.matching { it.name == "generateHavroutaLinks" }.configureEach {
Expand Down
93 changes: 93 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Seforim CLI

Command-line tool for searching the Seforim database.

## Building

### Fat JAR (development)

```bash
./gradlew :cli:fatJar
```

The JAR is generated at `cli/build/libs/seforim-cli-1.0.0-all.jar`.

### Direct execution

```bash
java -jar cli/build/libs/seforim-cli-1.0.0-all.jar <command> [options]
```

### Native package (jpackage)

```bash
# Installer (.deb, .dmg, .exe depending on OS)
./gradlew :cli:jpackage

# Portable app image (no installer)
./gradlew :cli:jpackageAppImage

# Optimized version with ProGuard
./gradlew :cli:jpackageOptimized
```

Packages are generated in `cli/build/jpackage/` or `cli/build/jpackage-image/`.

## Usage

### Commands

```bash
seforim-cli search <query> # Search for text
seforim-cli books <prefix> # Search books by title prefix
seforim-cli facets <query> # Get facets (counts by book/category)
seforim-cli help # Show help
```

### Options

| Option | Description | Default |
|--------|-------------|---------|
| `--db <path>` | Path to seforim.db | Same location as SeforimApp |
| `--index <path>` | Path to Lucene index | `<db>.lucene` |
| `--dict <path>` | Path to lexical.db dictionary | `<db>/../lexical.db` |
| `--limit <n>` | Results per page | 25 |
| `--near <n>` | Proximity slop for phrases (0=exact) | 5 |
| `--book <id>` | Filter by book ID | - |
| `--category <id>` | Filter by category ID | - |
| `--base-only` | Search base books only (not commentaries) | false |
| `--json` | Output as JSON | false |
| `--no-snippets` | Disable snippets (faster) | false |
| `--all` | Fetch all results (not just first page) | false |

### Examples

```bash
# Simple search
seforim-cli search "בראשית ברא" --limit 10

# Search with filter and JSON output
seforim-cli search "אברהם" --book 123 --json

# Search books by prefix
seforim-cli books "בראש" --limit 5

# Get facets
seforim-cli facets "משה" --base-only

# With custom database path
seforim-cli search "תורה" --db /path/to/seforim.db --index /path/to/seforim.db.lucene
```

## Requirements

- JDK 21+ (JetBrains Runtime recommended)
- `seforim.db` database with its Lucene index
- Optional: `lexical.db` for search expansion

## File structure

The CLI uses the same default paths as the SeforimApp:
- Database: `~/.local/share/io.github.kdroidfilter.seforimapp/databases/seforim.db`
- Lucene index: `seforim.db.lucene` (next to the DB)
- Dictionary: `lexical.db` (next to the DB)
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import kotlinx.serialization.Serializable
* @property id The unique identifier of the book
* @property categoryId The identifier of the category this book belongs to
* @property title The title of the book
* @property heRef A stable Hebrew reference identifier for the book, used for consistent identification
* across database regenerations (e.g., "בראשית", "רש״י על בראשית")
* @property sourceId The identifier of the source this book originates from
* @property authors The list of authors of this book
* @property topics The list of topics associated with this book
Expand All @@ -28,6 +30,7 @@ data class Book(
val categoryId: Long,
val sourceId: Long,
val title: String,
val heRef: String? = null,
val authors: List<Author> = emptyList(),
val topics: List<Topic> = emptyList(),
val pubPlaces: List<PubPlace> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fun io.github.kdroidfilter.seforimlibrary.db.Book.toModel(json: Json, authors: L
categoryId = categoryId,
sourceId = sourceId,
title = title,
heRef = heRef,
authors = authors,
topics = emptyList(),
pubPlaces = pubPlaces,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.github.kdroidfilter.seforimlibrary.dao.repository

import io.github.kdroidfilter.seforimlibrary.core.models.Line
import io.github.kdroidfilter.seforimlibrary.core.models.TocEntry

/**
* Interface for line selection and navigation related repository operations.
* This interface is extracted to allow mocking in tests.
*/
interface LineSelectionRepository {
/**
* Returns the TOC entry whose heading line is the given line id, or null if not a TOC heading.
*/
suspend fun getHeadingTocEntryByLineId(lineId: Long): TocEntry?

/**
* Returns all line ids that belong to the given TOC entry (section), ordered by lineIndex.
*/
suspend fun getLineIdsForTocEntry(tocEntryId: Long): List<Long>

/**
* Returns the TOC entry ID for a given line, or null if the line has no TOC mapping.
*/
suspend fun getTocEntryIdForLine(lineId: Long): Long?

/**
* Returns a TOC entry by its ID.
*/
suspend fun getTocEntry(id: Long): TocEntry?

/**
* Returns a line by its ID.
*/
suspend fun getLine(id: Long): Line?

/**
* Returns the previous line in the book, or null if at the beginning.
*/
suspend fun getPreviousLine(bookId: Long, currentLineIndex: Int): Line?

/**
* Returns the next line in the book, or null if at the end.
*/
suspend fun getNextLine(bookId: Long, currentLineIndex: Int): Line?

/**
* Returns lines in a range for a book.
*/
suspend fun getLines(bookId: Long, startIndex: Int, endIndex: Int): List<Line>
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import kotlinx.serialization.json.Json
* @property driver The SQL driver used to connect to the database
* @constructor Creates a repository with the specified database path and driver
*/
class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
class SeforimRepository(databasePath: String, private val driver: SqlDriver) : LineSelectionRepository {
private val database = SeforimDb(driver)
private val json = Json { ignoreUnknownKeys = true }
private val logger = Logger.withTag("SeforimRepository")
Expand Down Expand Up @@ -160,7 +160,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
/**
* Gets the tocEntryId associated with a line via the mapping table.
*/
suspend fun getTocEntryIdForLine(lineId: Long): Long? = withContext(Dispatchers.IO) {
override suspend fun getTocEntryIdForLine(lineId: Long): Long? = withContext(Dispatchers.IO) {
database.lineTocQueriesQueries.selectTocEntryIdByLineId(lineId).executeAsOneOrNull()
}

Expand All @@ -183,14 +183,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
/**
* Returns the TOC entry whose heading line is the given line id, or null if not a TOC heading.
*/
suspend fun getHeadingTocEntryByLineId(lineId: Long): TocEntry? = withContext(Dispatchers.IO) {
override suspend fun getHeadingTocEntryByLineId(lineId: Long): TocEntry? = withContext(Dispatchers.IO) {
database.tocQueriesQueries.selectByLineId(lineId).executeAsOneOrNull()?.toModel()
}

/**
* Returns all line ids that belong to the given TOC entry (section), ordered by lineIndex.
*/
suspend fun getLineIdsForTocEntry(tocEntryId: Long): List<Long> = withContext(Dispatchers.IO) {
override suspend fun getLineIdsForTocEntry(tocEntryId: Long): List<Long> = withContext(Dispatchers.IO) {
database.lineTocQueriesQueries.selectLineIdsByTocEntryId(tocEntryId).executeAsList()
}

Expand Down Expand Up @@ -289,6 +289,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
database.categoryClosureQueriesQueries.selectDescendants(ancestorId).executeAsList()
}

/**
* Returns all ancestor category IDs (including the category itself) using the
* category_closure table. Used for pre-indexing ancestors in search indexes.
*/
suspend fun getAncestorCategoryIds(categoryId: Long): List<Long> = withContext(Dispatchers.IO) {
database.categoryClosureQueriesQueries.selectAncestors(categoryId).executeAsList()
}

/**
* Finds categories whose title matches the LIKE pattern. Use %term% for contains.
*/
Expand Down Expand Up @@ -641,6 +649,19 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
return@withContext bookData.toModel(json, authors, pubPlaces, pubDates).copy(topics = topics)
}

/**
* Retrieves a book by its stable Hebrew reference identifier (heRef).
* Returns null if no book with the given heRef exists.
*/
suspend fun getBookByHeRef(heRef: String): Book? = withContext(Dispatchers.IO) {
val bookData = database.bookQueriesQueries.selectByHeRef(heRef).executeAsOneOrNull() ?: return@withContext null
val authors = getBookAuthors(bookData.id)
val topics = getBookTopics(bookData.id)
val pubPlaces = getBookPubPlaces(bookData.id)
val pubDates = getBookPubDates(bookData.id)
return@withContext bookData.toModel(json, authors, pubPlaces, pubDates).copy(topics = topics)
}

/**
* Retrieves a book by approximate title (exact, normalized, or LIKE).
*/
Expand Down Expand Up @@ -852,6 +873,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
categoryId = book.categoryId,
sourceId = book.sourceId,
title = book.title,
heRef = book.heRef,
heShortDesc = book.heShortDesc,
notesContent = book.notesContent,
orderIndex = book.order.toLong(),
Expand Down Expand Up @@ -909,6 +931,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
categoryId = book.categoryId,
sourceId = book.sourceId,
title = book.title,
heRef = book.heRef,
heShortDesc = book.heShortDesc,
notesContent = book.notesContent,
orderIndex = book.order.toLong(),
Expand Down Expand Up @@ -1017,7 +1040,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {

// --- Lines ---

suspend fun getLine(id: Long): Line? = withContext(Dispatchers.IO) {
override suspend fun getLine(id: Long): Line? = withContext(Dispatchers.IO) {
database.lineQueriesQueries.selectById(id).executeAsOneOrNull()?.toModel()
}

Expand All @@ -1026,7 +1049,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
.executeAsOneOrNull()?.toModel()
}

suspend fun getLines(bookId: Long, startIndex: Int, endIndex: Int): List<Line> =
override suspend fun getLines(bookId: Long, startIndex: Int, endIndex: Int): List<Line> =
withContext(Dispatchers.IO) {
database.lineQueriesQueries.selectByBookIdRange(
bookId = bookId,
Expand All @@ -1048,22 +1071,22 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {
* @param currentLineIndex The index of the current line
* @return The previous line, or null if there is no previous line
*/
suspend fun getPreviousLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) {
override suspend fun getPreviousLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) {
if (currentLineIndex <= 0) return@withContext null

val previousIndex = currentLineIndex - 1
database.lineQueriesQueries.selectByBookIdAndIndex(bookId, previousIndex.toLong())
.executeAsOneOrNull()?.toModel()
}

/**
* Gets the next line for a given book and line index.
*
*
* @param bookId The ID of the book
* @param currentLineIndex The index of the current line
* @return The next line, or null if there is no next line
*/
suspend fun getNextLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) {
override suspend fun getNextLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) {
val nextIndex = currentLineIndex + 1
database.lineQueriesQueries.selectByBookIdAndIndex(bookId, nextIndex.toLong())
.executeAsOneOrNull()?.toModel()
Expand Down Expand Up @@ -1142,7 +1165,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) {

// --- Table of Contents ---

suspend fun getTocEntry(id: Long): TocEntry? = withContext(Dispatchers.IO) {
override suspend fun getTocEntry(id: Long): TocEntry? = withContext(Dispatchers.IO) {
database.tocQueriesQueries.selectTocById(id).executeAsOneOrNull()?.toModel()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ selectBaseIds:
SELECT id FROM book WHERE isBaseBook = 1 ORDER BY orderIndex, title;

insert:
INSERT INTO book (categoryId, sourceId, title, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO book (categoryId, sourceId, title, heRef, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

insertWithId:
INSERT INTO book (id, categoryId, sourceId, title, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO book (id, categoryId, sourceId, title, heRef, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

selectByHeRef:
SELECT * FROM book WHERE heRef = ? LIMIT 1;

updateTotalLines:
UPDATE book SET totalLines = ? WHERE id = ?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ CREATE TABLE IF NOT EXISTS book (
categoryId INTEGER NOT NULL,
sourceId INTEGER NOT NULL,
title TEXT NOT NULL,
heRef TEXT,
heShortDesc TEXT,
-- Optional raw notes attached to the base book (when a companion file 'הערות על <title>' exists)
notesContent TEXT,
Expand All @@ -91,6 +92,7 @@ CREATE INDEX IF NOT EXISTS idx_book_category ON book(categoryId);
CREATE INDEX IF NOT EXISTS idx_book_title ON book(title);
CREATE INDEX IF NOT EXISTS idx_book_order ON book(orderIndex);
CREATE INDEX IF NOT EXISTS idx_book_source ON book(sourceId);
CREATE INDEX IF NOT EXISTS idx_book_heref ON book(heRef);

-- Book-publication place junction table
CREATE TABLE IF NOT EXISTS book_pub_place (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ private suspend fun buildCatalogTree(repository: SeforimRepository, logger: Logg

val rootCategories = repository.getRootCategories().sortedBy { it.order }
var totalCategories = 0
val roots = rootCategories.map { root ->
buildCatalogCategoryRecursive(root, booksByCategory, repository).also {
val roots = rootCategories.mapNotNull { root ->
buildCatalogCategoryRecursive(root, booksByCategory, repository)?.also {
totalCategories += countCategories(it)
}
}
Expand All @@ -83,7 +83,7 @@ private suspend fun buildCatalogCategoryRecursive(
category: Category,
booksByCategory: Map<Long, List<Book>>,
repository: SeforimRepository
): CatalogCategory {
): CatalogCategory? {
val books = booksByCategory[category.id]
?.map { book ->
CatalogBook(
Expand All @@ -107,7 +107,12 @@ private suspend fun buildCatalogCategoryRecursive(

val subcategories = repository.getCategoryChildren(category.id)
.sortedBy { it.order }
.map { child -> buildCatalogCategoryRecursive(child, booksByCategory, repository) }
.mapNotNull { child -> buildCatalogCategoryRecursive(child, booksByCategory, repository) }

// Skip empty categories (no books and no non-empty subcategories)
if (books.isEmpty() && subcategories.isEmpty()) {
return null
}

return CatalogCategory(
id = category.id,
Expand Down
Loading
Loading