Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
601 changes: 601 additions & 0 deletions app/schemas/com.ethran.notable.data.db.AppDatabase/36.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ 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,
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/ethran/notable/data/db/Annotation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import javax.inject.Inject

enum class AnnotationType {
WIKILINK,
TAG
TAG,
TITLE
}

@Entity(
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/com/ethran/notable/data/db/Db.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/ethran/notable/data/db/Page.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ enum class Mode {
}

enum class AnnotationMode {
None, WikiLink, Tag
None, WikiLink, Tag, Title
}

@Stable
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/ethran/notable/editor/ui/EditorSidebar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/ethran/notable/editor/utils/draw.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
87 changes: 64 additions & 23 deletions app/src/main/java/com/ethran/notable/io/InboxSyncEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -61,7 +55,30 @@ object InboxSyncEngine {
return
}

ensureModelDownloaded()
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()) {
Expand All @@ -75,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<String>()
Expand Down Expand Up @@ -145,33 +162,44 @@ 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")
}

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<Boolean> { 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<Void?> { 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
}

/**
Expand All @@ -180,7 +208,7 @@ object InboxSyncEngine {
*/
private suspend fun recognizeStrokes(strokes: List<Stroke>): 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")
Expand Down Expand Up @@ -385,10 +413,12 @@ object InboxSyncEngine {
private fun generateMarkdown(
createdDate: String,
tags: List<String>,
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:")
Expand All @@ -400,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,47 @@ 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
)

SettingToggleRow(
label = "Include timestamp in filename",
value = settings.hwrFilenameIncludeTimestamp,
onToggle = { onSettingsChange(settings.copy(hwrFilenameIncludeTimestamp = it)) }
)
}

SettingsDivider()
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/ethran/notable/ui/views/HomeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down