From 0b1bb5fe27cbffe3073fad2d6a10c93a2294e426 Mon Sep 17 00:00:00 2001 From: Daniel Polders Date: Mon, 16 Mar 2026 21:48:38 +0100 Subject: [PATCH 1/2] Add language selection for handwriting recognition (ML Kit) - AppSettings: add hwrLanguage (default "en-US", BCP-47 format) - InboxSyncEngine: lazy-initialize recognizer per language; re-downloads model and recreates recognizer when language changes - GeneralSettings: language dropdown with 15 supported languages Co-Authored-By: Claude Sonnet 4.6 --- .../notable/data/datastore/AppSettings.kt | 1 + .../com/ethran/notable/io/InboxSyncEngine.kt | 37 +++++++++++-------- .../notable/ui/components/GeneralSettings.kt | 35 ++++++++++++++++++ 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt index c65b5cf6..ab04594b 100644 --- a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt +++ b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt @@ -51,6 +51,7 @@ data class AppSettings( // Inbox Capture val obsidianInboxPath: String = "Documents/primary/inbox", + val hwrLanguage: String = "en-US", // Debug val showWelcome: Boolean = true, diff --git a/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt b/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt index d853ca95..d6ec2e4e 100644 --- a/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt @@ -29,15 +29,9 @@ private val log = ShipBook.getLogger("InboxSyncEngine") object InboxSyncEngine { - private val modelIdentifier = - DigitalInkRecognitionModelIdentifier.fromLanguageTag("en-US") - private val model = - modelIdentifier?.let { DigitalInkRecognitionModel.builder(it).build() } - private val recognizer = model?.let { - DigitalInkRecognition.getClient( - DigitalInkRecognizerOptions.builder(it).build() - ) - } + @Volatile private var currentLanguage: String? = null + private var recognizer: com.google.mlkit.vision.digitalink.recognition.DigitalInkRecognizer? = null + private var currentModel: DigitalInkRecognitionModel? = null /** * Sync an inbox page to Obsidian. Tags come from the UI (pill selection), @@ -61,7 +55,7 @@ object InboxSyncEngine { return } - ensureModelDownloaded() + ensureRecognizer(GlobalAppSettings.current.hwrLanguage) // 1. Recognize ALL strokes together to preserve natural text flow var fullText = if (allStrokes.isNotEmpty()) { @@ -153,25 +147,36 @@ object InboxSyncEngine { log.i("Inbox sync complete for page $pageId") } - private suspend fun ensureModelDownloaded() { - val m = model ?: throw IllegalStateException("ML Kit model identifier not found") + private suspend fun ensureRecognizer(language: String) { + if (language == currentLanguage && recognizer != null) return + + val modelIdentifier = DigitalInkRecognitionModelIdentifier.fromLanguageTag(language) + ?: throw IllegalStateException("Unsupported HWR language: $language") + val model = DigitalInkRecognitionModel.builder(modelIdentifier).build() val manager = RemoteModelManager.getInstance() val isDownloaded = suspendCancellableCoroutine { cont -> - manager.isModelDownloaded(m) + manager.isModelDownloaded(model) .addOnSuccessListener { cont.resume(it) } .addOnFailureListener { cont.resumeWithException(it) } } if (!isDownloaded) { - log.i("Downloading ML Kit model...") + log.i("Downloading ML Kit model for $language...") suspendCancellableCoroutine { cont -> - manager.download(m, DownloadConditions.Builder().build()) + manager.download(model, DownloadConditions.Builder().build()) .addOnSuccessListener { cont.resume(null) } .addOnFailureListener { cont.resumeWithException(it) } } log.i("Model downloaded") } + + recognizer?.close() + currentModel = model + recognizer = DigitalInkRecognition.getClient( + DigitalInkRecognizerOptions.builder(model).build() + ) + currentLanguage = language } /** @@ -180,7 +185,7 @@ object InboxSyncEngine { */ private suspend fun recognizeStrokes(strokes: List): String { val rec = recognizer - ?: throw IllegalStateException("ML Kit recognizer not initialized") + ?: throw IllegalStateException("ML Kit recognizer not initialized — call ensureRecognizer first") val lines = segmentIntoLines(strokes) log.i("Segmented ${strokes.size} strokes into ${lines.size} lines") diff --git a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt index cb42ea3d..779147dd 100644 --- a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt +++ b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt @@ -247,6 +247,41 @@ private fun InboxCaptureSettings( style = MaterialTheme.typography.caption, color = Color.Gray ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + "Handwriting recognition language", + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val hwrLanguages = listOf( + "en-US" to "English (US)", + "en-GB" to "English (UK)", + "fr-FR" to "French", + "de-DE" to "German", + "es-ES" to "Spanish", + "it-IT" to "Italian", + "pt-BR" to "Portuguese (Brazil)", + "pt-PT" to "Portuguese (Portugal)", + "nl-NL" to "Dutch", + "ru-RU" to "Russian", + "zh-CN" to "Chinese (Simplified)", + "zh-TW" to "Chinese (Traditional)", + "ja-JP" to "Japanese", + "ko-KR" to "Korean", + "ar-SA" to "Arabic", + ) + SelectorRow( + label = "", + options = hwrLanguages, + value = settings.hwrLanguage, + onValueChange = { onSettingsChange(settings.copy(hwrLanguage = it)) }, + labelMaxLines = 1 + ) } SettingsDivider() From 8c732cf658c92a03f6e76e27543066d52e5810a5 Mon Sep 17 00:00:00 2001 From: Daniel Polders Date: Mon, 16 Mar 2026 21:57:31 +0100 Subject: [PATCH 2/2] Add title annotation, optional filename timestamp, and newline fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnnotationType/AnnotationMode: add TITLE variant - Editor sidebar: add "T" button to mark title region - Page drawing: render title annotation with orange "T" glyph and fill - Page entity: add nullable title field (DB migration 35→36) - InboxSyncEngine: recognize title strokes separately, save to Page.title, emit title: frontmatter; sanitize newlines in title text - InboxSyncEngine: optional timestamp in filename (hwrFilenameIncludeTimestamp) - GeneralSettings: add timestamp toggle - HomeView: show page title below card when set Co-Authored-By: Claude Sonnet 4.6 --- .../36.json | 601 ++++++++++++++++++ .../notable/data/datastore/AppSettings.kt | 1 + .../com/ethran/notable/data/db/Annotation.kt | 3 +- .../java/com/ethran/notable/data/db/Db.kt | 5 +- .../java/com/ethran/notable/data/db/Page.kt | 3 +- .../notable/editor/drawing/pageDrawing.kt | 40 ++ .../notable/editor/state/EditorState.kt | 2 +- .../ethran/notable/editor/ui/EditorSidebar.kt | 15 + .../com/ethran/notable/editor/utils/draw.kt | 1 + .../com/ethran/notable/io/InboxSyncEngine.kt | 50 +- .../notable/ui/components/GeneralSettings.kt | 6 + .../com/ethran/notable/ui/views/HomeView.kt | 11 + 12 files changed, 726 insertions(+), 12 deletions(-) create mode 100644 app/schemas/com.ethran.notable.data.db.AppDatabase/36.json diff --git a/app/schemas/com.ethran.notable.data.db.AppDatabase/36.json b/app/schemas/com.ethran.notable.data.db.AppDatabase/36.json new file mode 100644 index 00000000..0da5a6e7 --- /dev/null +++ b/app/schemas/com.ethran.notable.data.db.AppDatabase/36.json @@ -0,0 +1,601 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "identityHash": "a0cfb50c7fca0500fa2aa3cbc7d68f56", + "entities": [ + { + "tableName": "Folder", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Folder_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Folder_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Notebook", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `openPageId` TEXT, `pageIds` TEXT NOT NULL, `parentFolderId` TEXT, `defaultBackground` TEXT NOT NULL DEFAULT 'blank', `defaultBackgroundType` TEXT NOT NULL DEFAULT 'native', `linkedExternalUri` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPageId", + "columnName": "openPageId", + "affinity": "TEXT" + }, + { + "fieldPath": "pageIds", + "columnName": "pageIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultBackground", + "columnName": "defaultBackground", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "defaultBackgroundType", + "columnName": "defaultBackgroundType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'native'" + }, + { + "fieldPath": "linkedExternalUri", + "columnName": "linkedExternalUri", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Notebook_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Notebook_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Page", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `scroll` INTEGER NOT NULL, `notebookId` TEXT, `background` TEXT NOT NULL DEFAULT 'blank', `backgroundType` TEXT NOT NULL DEFAULT 'native', `parentFolderId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `title` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`notebookId`) REFERENCES `Notebook`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scroll", + "columnName": "scroll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "TEXT" + }, + { + "fieldPath": "background", + "columnName": "background", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'blank'" + }, + { + "fieldPath": "backgroundType", + "columnName": "backgroundType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'native'" + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Page_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + }, + { + "name": "index_Page_parentFolderId", + "unique": false, + "columnNames": [ + "parentFolderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Page_parentFolderId` ON `${TABLE_NAME}` (`parentFolderId`)" + } + ], + "foreignKeys": [ + { + "table": "Folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Notebook", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Stroke", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `size` REAL NOT NULL, `pen` TEXT NOT NULL, `color` INTEGER NOT NULL DEFAULT 0xFF000000, `maxPressure` INTEGER NOT NULL DEFAULT 4096, `top` REAL NOT NULL, `bottom` REAL NOT NULL, `left` REAL NOT NULL, `right` REAL NOT NULL, `points` BLOB NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pen", + "columnName": "pen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0xFF000000" + }, + { + "fieldPath": "maxPressure", + "columnName": "maxPressure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "4096" + }, + { + "fieldPath": "top", + "columnName": "top", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bottom", + "columnName": "bottom", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "left", + "columnName": "left", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "right", + "columnName": "right", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Stroke_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Stroke_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Image", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `height` INTEGER NOT NULL, `width` INTEGER NOT NULL, `uri` TEXT, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT" + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Image_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Image_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Kv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "Annotation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `x` REAL NOT NULL, `y` REAL NOT NULL, `width` REAL NOT NULL, `height` REAL NOT NULL, `pageId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pageId`) REFERENCES `Page`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Annotation_pageId", + "unique": false, + "columnNames": [ + "pageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Annotation_pageId` ON `${TABLE_NAME}` (`pageId`)" + } + ], + "foreignKeys": [ + { + "table": "Page", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a0cfb50c7fca0500fa2aa3cbc7d68f56')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt index ab04594b..ef13cceb 100644 --- a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt +++ b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt @@ -52,6 +52,7 @@ data class AppSettings( // Inbox Capture val obsidianInboxPath: String = "Documents/primary/inbox", val hwrLanguage: String = "en-US", + val hwrFilenameIncludeTimestamp: Boolean = true, // Debug val showWelcome: Boolean = true, diff --git a/app/src/main/java/com/ethran/notable/data/db/Annotation.kt b/app/src/main/java/com/ethran/notable/data/db/Annotation.kt index 36497f9b..032c90f3 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Annotation.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Annotation.kt @@ -14,7 +14,8 @@ import javax.inject.Inject enum class AnnotationType { WIKILINK, - TAG + TAG, + TITLE } @Entity( diff --git a/app/src/main/java/com/ethran/notable/data/db/Db.kt b/app/src/main/java/com/ethran/notable/data/db/Db.kt index 7afd0af3..50a82024 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Db.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Db.kt @@ -54,7 +54,7 @@ class Converters { @Database( entities = [Folder::class, Notebook::class, Page::class, Stroke::class, Image::class, Kv::class, Annotation::class], - version = 35, + version = 36, autoMigrations = [ AutoMigration(19, 20), AutoMigration(20, 21), @@ -70,7 +70,8 @@ class Converters { AutoMigration(31, 32, spec = AutoMigration31to32::class), AutoMigration(32, 33), AutoMigration(33, 34), - AutoMigration(34, 35) + AutoMigration(34, 35), + AutoMigration(35, 36) ], exportSchema = true ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/ethran/notable/data/db/Page.kt b/app/src/main/java/com/ethran/notable/data/db/Page.kt index 0af67e02..e30b74e3 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Page.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Page.kt @@ -36,7 +36,8 @@ data class Page( @ColumnInfo(defaultValue = "blank") val background: String = "blank", // path or native subtype @ColumnInfo(defaultValue = "native") val backgroundType: String = "native", // image, imageRepeating, coverImage, native @ColumnInfo(index = true) val parentFolderId: String? = null, - val createdAt: Date = Date(), val updatedAt: Date = Date() + val createdAt: Date = Date(), val updatedAt: Date = Date(), + val title: String? = null ) data class PageWithStrokes( diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt b/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt index 3e203fa0..5aeb39be 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt @@ -51,6 +51,16 @@ private val tagHashPaint = Paint().apply { isAntiAlias = true typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD) } +private val titleFillPaint = Paint().apply { + color = Color.argb(50, 255, 140, 0) // light orange wash + style = Paint.Style.FILL +} +private val titleGlyphPaint = Paint().apply { + color = Color.argb(200, 180, 80, 0) // orange-brown "T" + style = Paint.Style.FILL + isAntiAlias = true + typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD) +} private val annotationUnderlinePaint = Paint().apply { style = Paint.Style.STROKE strokeWidth = 3f @@ -75,6 +85,13 @@ fun annotationVisualBounds(annotation: Annotation): Rect { val bracketGap = padding * 0.5f expandLeft = bracketWidth + bracketGap expandRight = bracketWidth + bracketGap + } else if (annotation.type == AnnotationType.TITLE.name) { + val titleSize = boxHeight * 0.65f + titleGlyphPaint.textSize = titleSize + val titleWidth = titleGlyphPaint.measureText("T") + val titleGap = padding * 0.6f + expandLeft = titleWidth + titleGap + expandRight = padding } else { val hashSize = boxHeight * 0.65f tagHashPaint.textSize = hashSize @@ -132,6 +149,29 @@ fun drawAnnotation(canvas: Canvas, annotation: Annotation, offset: Offset) { // Subtle underline under the handwritten content annotationUnderlinePaint.color = Color.argb(120, 0, 80, 220) canvas.drawLine(rect.left, rect.bottom + padding * 0.3f, rect.right, rect.bottom + padding * 0.3f, annotationUnderlinePaint) + } else if (annotation.type == AnnotationType.TITLE.name) { + // TITLE: draw "T" prefix with orange styling + val titleSize = boxHeight * 0.65f + titleGlyphPaint.textSize = titleSize + + val titleWidth = titleGlyphPaint.measureText("T") + val titleGap = padding * 0.6f + + val expandedRect = RectF( + rect.left - titleWidth - titleGap, + rect.top - padding, + rect.right + padding, + rect.bottom + padding + ) + + val cornerRadius = boxHeight * 0.15f + canvas.drawRoundRect(expandedRect, cornerRadius, cornerRadius, titleFillPaint) + + val textY = rect.centerY() + titleSize * 0.35f + canvas.drawText("T", expandedRect.left + titleGap * 0.3f, textY, titleGlyphPaint) + + annotationUnderlinePaint.color = Color.argb(120, 180, 80, 0) + canvas.drawLine(rect.left, rect.bottom + padding * 0.3f, rect.right, rect.bottom + padding * 0.3f, annotationUnderlinePaint) } else { // TAG: draw # prefix val hashSize = boxHeight * 0.65f diff --git a/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt b/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt index 43bec198..6a070496 100644 --- a/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt +++ b/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt @@ -20,7 +20,7 @@ enum class Mode { } enum class AnnotationMode { - None, WikiLink, Tag + None, WikiLink, Tag, Title } @Stable diff --git a/app/src/main/java/com/ethran/notable/editor/ui/EditorSidebar.kt b/app/src/main/java/com/ethran/notable/editor/ui/EditorSidebar.kt index a4bd5988..993676d6 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/EditorSidebar.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/EditorSidebar.kt @@ -368,6 +368,21 @@ fun EditorSidebar( } ) + // Title + SidebarTextButton( + text = "T", + contentDescription = "Title", + isSelected = state.annotationMode == AnnotationMode.Title, + onClick = { + state.annotationMode = if (state.annotationMode == AnnotationMode.Title) + AnnotationMode.None else AnnotationMode.Title + if (state.annotationMode != AnnotationMode.None) state.mode = Mode.Draw + isPenPickerOpen = false + isEraserMenuOpen = false + refreshSidebar() + } + ) + SidebarDivider() // --- Utilities --- diff --git a/app/src/main/java/com/ethran/notable/editor/utils/draw.kt b/app/src/main/java/com/ethran/notable/editor/utils/draw.kt index c5fda8f2..38121c8a 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/draw.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/draw.kt @@ -63,6 +63,7 @@ fun handleAnnotation( val type = when (annotationMode) { AnnotationMode.WikiLink -> AnnotationType.WIKILINK.name AnnotationMode.Tag -> AnnotationType.TAG.name + AnnotationMode.Title -> AnnotationType.TITLE.name else -> return null } diff --git a/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt b/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt index d6ec2e4e..eec0b136 100644 --- a/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt @@ -57,6 +57,29 @@ object InboxSyncEngine { ensureRecognizer(GlobalAppSettings.current.hwrLanguage) + // Separate TITLE annotation from body annotations + val titleAnnotation = annotations.firstOrNull { it.type == AnnotationType.TITLE.name } + val bodyAnnotations = annotations.filter { it.type != AnnotationType.TITLE.name } + + // Recognize title strokes separately and save to page + var noteTitle: String? = null + if (titleAnnotation != null) { + val titleRect = RectF( + titleAnnotation.x, titleAnnotation.y, + titleAnnotation.x + titleAnnotation.width, + titleAnnotation.y + titleAnnotation.height + ) + val titleStrokes = findStrokesInRect(allStrokes, titleRect) + if (titleStrokes.isNotEmpty()) { + val raw = recognizeStrokes(titleStrokes) + noteTitle = raw.replace(Regex("\\s*[\\r\\n]+\\s*"), " ").trim().ifBlank { null } + log.i("Recognized title: '$noteTitle'") + } + } + if (noteTitle != null) { + appRepository.pageRepository.update(page.copy(title = noteTitle)) + } + // 1. Recognize ALL strokes together to preserve natural text flow var fullText = if (allStrokes.isNotEmpty()) { log.i("Recognizing all ${allStrokes.size} strokes") @@ -69,8 +92,8 @@ object InboxSyncEngine { // 2. Find annotation text by diffing full recognition vs non-annotation recognition. // Falls back to per-annotation recognition if the diff produces a count mismatch // (which happens when removing strokes changes HWR context enough to alter other words). - if (annotations.isNotEmpty()) { - val sortedAnnotations = annotations.sortedWith(compareBy({ it.y }, { it.x })) + if (bodyAnnotations.isNotEmpty()) { + val sortedAnnotations = bodyAnnotations.sortedWith(compareBy({ it.y }, { it.x })) // Collect stroke IDs that fall inside any annotation box val annotationStrokeIds = mutableSetOf() @@ -139,10 +162,10 @@ object InboxSyncEngine { val finalContent = fullText val createdDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(page.createdAt) - val markdown = generateMarkdown(createdDate, tags, finalContent) + val markdown = generateMarkdown(createdDate, tags, finalContent, noteTitle) val inboxPath = GlobalAppSettings.current.obsidianInboxPath - writeMarkdownFile(markdown, page.createdAt, inboxPath) + writeMarkdownFile(markdown, page.createdAt, inboxPath, noteTitle) log.i("Inbox sync complete for page $pageId") } @@ -390,10 +413,12 @@ object InboxSyncEngine { private fun generateMarkdown( createdDate: String, tags: List, - content: String + content: String, + title: String? = null ): String { val sb = StringBuilder() sb.appendLine("---") + if (title != null) sb.appendLine("title: \"$title\"") sb.appendLine("created: \"[[$createdDate]]\"") if (tags.isNotEmpty()) { sb.appendLine("tags:") @@ -405,9 +430,20 @@ object InboxSyncEngine { return sb.toString() } - private fun writeMarkdownFile(markdown: String, createdAt: Date, inboxPath: String) { + private fun writeMarkdownFile(markdown: String, createdAt: Date, inboxPath: String, title: String? = null) { + val includeTimestamp = GlobalAppSettings.current.hwrFilenameIncludeTimestamp val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(createdAt) - val fileName = "$timestamp.md" + val slug = title?.trim() + ?.lowercase(Locale.US) + ?.replace(Regex("[^a-z0-9]+"), "-") + ?.trim('-') + ?.take(60) + val fileName = when { + includeTimestamp && slug != null -> "$timestamp-$slug.md" + includeTimestamp -> "$timestamp.md" + slug != null -> "$slug.md" + else -> "$timestamp.md" + } val dir = if (inboxPath.startsWith("/")) { File(inboxPath) diff --git a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt index 779147dd..9f1bbbb3 100644 --- a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt +++ b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt @@ -282,6 +282,12 @@ private fun InboxCaptureSettings( onValueChange = { onSettingsChange(settings.copy(hwrLanguage = it)) }, labelMaxLines = 1 ) + + SettingToggleRow( + label = "Include timestamp in filename", + value = settings.hwrFilenameIncludeTimestamp, + onToggle = { onSettingsChange(settings.copy(hwrFilenameIncludeTimestamp = it)) } + ) } SettingsDivider() diff --git a/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt b/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt index c56f9085..6666117f 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt @@ -214,6 +214,7 @@ fun LibraryContent( items(pages) { page -> var isPageSelected by remember { mutableStateOf(false) } val isSyncing = page.id in SyncState.syncingPageIds + Column { Box { PagePreview( modifier = Modifier @@ -246,6 +247,16 @@ fun LibraryContent( onClose = { isPageSelected = false } ) } + if (page.title != null) { + Text( + text = page.title, + style = androidx.compose.material.MaterialTheme.typography.caption, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } // end Column } } }