From e70000a68e56365583fb158026b9ed0d331b691e Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 10:09:06 +0545 Subject: [PATCH 1/9] Add database schema for PKM integration - Add optional `name` field to Page entity for named pages - Add `getAll()` method to FolderDao for index export - Add `getByTitle()` method to FolderDao for folder lookup by path - Add `getAll()` method to NotebookDao for index export - Bump database version to 35 with AutoMigration These changes support the JSON index export feature which allows external PKM tools to browse Notable's notebook structure. Co-Authored-By: Claude Opus 4.5 --- .../35.json | 511 ++++++++++++++++++ .../java/com/ethran/notable/data/db/Db.kt | 5 +- .../java/com/ethran/notable/data/db/Folder.kt | 15 +- .../com/ethran/notable/data/db/Notebook.kt | 7 + .../java/com/ethran/notable/data/db/Page.kt | 14 +- 5 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 app/schemas/com.ethran.notable.data.db.AppDatabase/35.json diff --git a/app/schemas/com.ethran.notable.data.db.AppDatabase/35.json b/app/schemas/com.ethran.notable.data.db.AppDatabase/35.json new file mode 100644 index 00000000..43931007 --- /dev/null +++ b/app/schemas/com.ethran.notable.data.db.AppDatabase/35.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "c4669bac4b9572202631557aa01eed9d", + "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, `name` TEXT, `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, 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": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "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 + } + ], + "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" + ] + } + } + ], + "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, 'c4669bac4b9572202631557aa01eed9d')" + ] + } +} \ No newline at end of file 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 c2493e86..dd0da4ec 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 @@ -48,7 +48,7 @@ class Converters { @Database( entities = [Folder::class, Notebook::class, Page::class, Stroke::class, Image::class, Kv::class], - version = 34, + version = 35, autoMigrations = [ AutoMigration(19, 20), AutoMigration(20, 21), @@ -63,7 +63,8 @@ class Converters { AutoMigration(30, 31, spec = AutoMigration30to31::class), AutoMigration(31, 32, spec = AutoMigration31to32::class), AutoMigration(32, 33), - AutoMigration(33, 34) + AutoMigration(33, 34), + AutoMigration(34, 35) ], exportSchema = true ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/ethran/notable/data/db/Folder.kt b/app/src/main/java/com/ethran/notable/data/db/Folder.kt index f4365821..70cfc151 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Folder.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Folder.kt @@ -42,6 +42,11 @@ interface FolderDao { @Query("SELECT * FROM folder WHERE id IS :folderId") fun get(folderId: String): Folder + @Query("SELECT * FROM folder") + fun getAll(): List + + @Query("SELECT * FROM folder WHERE title = :title LIMIT 1") + fun getByTitle(title: String): Folder? @Insert fun create(folder: Folder): Long @@ -64,10 +69,18 @@ class FolderRepository(context: Context) { db.update(folder) } + fun getAll(): List { + return db.getAll() + } + fun getAllInFolder(folderId: String? = null): LiveData> { return db.getChildrenFolders(folderId) } + fun getByTitle(title: String): Folder? { + return db.getByTitle(title) + } + fun getParent(folderId: String? = null): String? { if (folderId == null) return null @@ -79,9 +92,7 @@ class FolderRepository(context: Context) { return db.get(folderId) } - fun delete(id: String) { db.delete(id) } - } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/data/db/Notebook.kt b/app/src/main/java/com/ethran/notable/data/db/Notebook.kt index 82146ae5..674eefd3 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Notebook.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Notebook.kt @@ -51,6 +51,9 @@ interface NotebookDao { @Query("SELECT * FROM notebook WHERE parentFolderId is :folderId") fun getAllInFolder(folderId: String? = null): LiveData> + @Query("SELECT * FROM notebook") + fun getAll(): List + @Query("SELECT * FROM notebook WHERE id = (:notebookId)") fun getByIdLive(notebookId: String): LiveData @@ -100,6 +103,10 @@ class BookRepository(context: Context) { db.update(updatedNotebook) } + fun getAll(): List { + return db.getAll() + } + fun getAllInFolder(folderId: String? = null): LiveData> { return db.getAllInFolder(folderId) } 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 675811cd..b5c0ff0b 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 @@ -31,12 +31,15 @@ import java.util.UUID )] ) data class Page( - @PrimaryKey val id: String = UUID.randomUUID().toString(), val scroll: Int = 0, + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val name: String? = null, + val scroll: Int = 0, @ColumnInfo(index = true) val notebookId: String? = null, @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() ) data class PageWithStrokes( @@ -58,6 +61,9 @@ interface PageDao { @Query("SELECT * FROM page WHERE id IN (:ids)") fun getByIds(ids: List): List + @Query("SELECT * FROM page") + fun getAll(): List + @Query("SELECT * FROM page WHERE id = (:pageId)") fun getById(pageId: String): Page? @@ -100,6 +106,10 @@ class PageRepository(context: Context) { return db.updateScroll(id, scroll) } + fun getAll(): List { + return db.getAll() + } + fun getById(pageId: String): Page? { return db.getById(pageId) } From 28f11963f3648fd0be22ad36a1c2dce0f6f06af5 Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 10:09:18 +0545 Subject: [PATCH 2/9] Remove clipboard-based link copying system - Remove `linkCopyEnabled`, `linkTemplate`, `exportBaseDirectory` from AppSettings - Remove `copyToClipboard` option and clipboard helper methods from ExportEngine - Add `ignoreUnknownKeys = true` to KvProxy JSON decoder for backward compatibility The clipboard-based link system is replaced by deep links and JSON index export, which provide better integration with external PKM tools. Co-Authored-By: Claude Opus 4.5 --- .../notable/data/datastore/AppSettings.kt | 5 +- .../java/com/ethran/notable/data/db/Kv.kt | 5 +- .../com/ethran/notable/io/ExportEngine.kt | 46 ++----------------- 3 files changed, 10 insertions(+), 46 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 b00eb98a..f4531650 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 @@ -48,9 +48,8 @@ data class AppSettings( val twoFingerSwipeLeftAction: GestureAction? = defaultTwoFingerSwipeLeftAction, val twoFingerSwipeRightAction: GestureAction? = defaultTwoFingerSwipeRightAction, val holdAction: GestureAction? = defaultHoldAction, - val continuousStrokeSlider: Boolean = false, - - ) { + val continuousStrokeSlider: Boolean = false +) { companion object { val defaultDoubleTapAction get() = GestureAction.Undo val defaultTwoFingerTapAction get() = GestureAction.ChangeTool diff --git a/app/src/main/java/com/ethran/notable/data/db/Kv.kt b/app/src/main/java/com/ethran/notable/data/db/Kv.kt index b3c0cedd..b04f7864 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Kv.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Kv.kt @@ -72,19 +72,20 @@ class KvRepository(context: Context) { class KvProxy(context: Context) { private val kvRepository = KvRepository(context) + private val json = Json { ignoreUnknownKeys = true } fun observeKv(key: String, serializer: KSerializer, default: T): LiveData { return kvRepository.getLive(key).map { if (it == null) return@map default val jsonValue = it.value - Json.decodeFromString(serializer, jsonValue) + json.decodeFromString(serializer, jsonValue) } } fun get(key: String, serializer: KSerializer): T? { val kv = kvRepository.get(key) ?: return null //returns null when there is no database val jsonValue = kv.value - return Json.decodeFromString(serializer, jsonValue) + return json.decodeFromString(serializer, jsonValue) } diff --git a/app/src/main/java/com/ethran/notable/io/ExportEngine.kt b/app/src/main/java/com/ethran/notable/io/ExportEngine.kt index 802f9844..900fbb3c 100644 --- a/app/src/main/java/com/ethran/notable/io/ExportEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/ExportEngine.kt @@ -1,7 +1,5 @@ package com.ethran.notable.io -import android.content.ClipData -import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context import android.graphics.Bitmap @@ -53,9 +51,8 @@ sealed class ExportTarget { } data class ExportOptions( - val copyToClipboard: Boolean = true, - val targetFolderUri: Uri? = null, // can be made to also get from it fileName. - val overwrite: Boolean = false, // TODO: Fix it -- for now it does not work correctly (it overwrites the files too often) + val targetFolderUri: Uri? = null, + val overwrite: Boolean = false, val fileName: String? = null ) @@ -110,9 +107,6 @@ class ExportEngine( doc.writeTo(out) } } - if (options.copyToClipboard) copyPagePngLink( - context, target.pageId - ) // You may want a separate PDF variant } } @@ -143,8 +137,7 @@ class ExportEngine( when (target) { is ExportTarget.Page -> { - val pageId = target.pageId - val bitmap = renderBitmapForPage(pageId) + val bitmap = renderBitmapForPage(target.pageId) bitmap.useAndRecycle { bmp -> val bytes = bmp.toBytes(compressFormat) saveBytes( @@ -152,15 +145,11 @@ class ExportEngine( ext, mime, options.overwrite, bytes ) } - if (options.copyToClipboard && format == ExportFormat.PNG) { - copyPagePngLink(context, pageId) - } return "Page exported: $baseFileName.$ext" } is ExportTarget.Book -> { val book = bookRepo.getById(target.bookId) ?: return "Book ID not found" - // Export each page separately (same folder = book title) book.pageIds.forEachIndexed { index, pageId -> val fileName = "$baseFileName-p${index + 1}" val bitmap = renderBitmapForPage(pageId) @@ -169,9 +158,6 @@ class ExportEngine( saveBytes(folderUri, fileName, ext, mime, options.overwrite, bytes) } } - if (options.copyToClipboard) { - Log.w(TAG, "Can't copy book links or images to clipboard -- batch export.") - } return "Book exported: ${book.title} (${book.pageIds.size} pages)" } } @@ -302,11 +288,9 @@ class ExportEngine( // Create a default directory Uri under Documents/notable/ using file:// scheme. private fun getDefaultExportDirectoryUri(subfolderPath: String): Uri { - val documentsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) - + val baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) val targetPath = listOfNotBlank("notable", subfolderPath).joinToString(File.separator) - val dir = File(documentsDir, targetPath) + val dir = File(baseDir, targetPath) if (!dir.exists()) dir.mkdirs() return dir.toUri() } @@ -550,26 +534,6 @@ class ExportEngine( } - /* -------------------- Clipboard Helpers -------------------- */ - - private fun copyPagePngLink(context: Context, pageId: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val text = """ - [[../attachments/Notable/Pages/notable-page-$pageId.png]] - [[Notable Link][notable://page-$pageId]] - """.trimIndent() - clipboard.setPrimaryClip(ClipData.newPlainText("Notable Page Link", text)) - } - - private fun copyBookPdfLink(context: Context, bookId: String, bookName: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val text = """ - [[../attachments/Notable/Notebooks/$bookName.pdf]] - [[Notable Book Link][notable://book-$bookId]] - """.trimIndent() - clipboard.setPrimaryClip(ClipData.newPlainText("Notable Book PDF Link", text)) - } - /* -------------------- Utilities -------------------- */ /** From d57ed16dbc58405e35dbab9c43006e3b126c2b0f Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 10:09:30 +0545 Subject: [PATCH 3/9] Add JSON index exporter for PKM integration - Add IndexExporter object with debounced export (2-second delay) - Export lightweight JSON index to Documents/notabledb/notable-index.json - Index contains folders, notebooks, and pages with metadata only (no stroke data) - Trigger index export on app startup and when app goes to background The JSON index allows external tools (Emacs, Obsidian, etc.) to browse Notable's notebook structure without direct database access. Index structure includes: - Folder hierarchy with paths - Notebooks with page lists and folder paths - Pages with notebook association and page indices Co-Authored-By: Claude Opus 4.5 --- .../java/com/ethran/notable/MainActivity.kt | 13 +- .../com/ethran/notable/io/IndexExporter.kt | 203 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/ethran/notable/io/IndexExporter.kt diff --git a/app/src/main/java/com/ethran/notable/MainActivity.kt b/app/src/main/java/com/ethran/notable/MainActivity.kt index ef74b4d6..ecec1da5 100644 --- a/app/src/main/java/com/ethran/notable/MainActivity.kt +++ b/app/src/main/java/com/ethran/notable/MainActivity.kt @@ -30,6 +30,7 @@ import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.data.db.KvProxy import com.ethran.notable.data.db.reencodeStrokePointsToSB1 import com.ethran.notable.editor.DrawCanvas +import com.ethran.notable.io.IndexExporter import com.ethran.notable.ui.LocalSnackContext import com.ethran.notable.ui.Router import com.ethran.notable.ui.SnackBar @@ -85,10 +86,13 @@ class MainActivity : ComponentActivity() { this.lifecycleScope.launch(Dispatchers.IO) { reencodeStrokePointsToSB1(this@MainActivity) } + // Export index for external tools (e.g., Emacs integration) + IndexExporter.scheduleExport(this) } //EpdDeviceManager.enterAnimationUpdate(true); -// val intentData = intent.data?.lastPathSegment + // Extract deep link data from intent (e.g., notable://page-{id}) + val intentData = intent.data?.toString() setContent { InkaTheme { @@ -97,7 +101,7 @@ class MainActivity : ComponentActivity() { Modifier .background(Color.White) ) { - Router() + Router(intentData = intentData) } Box( Modifier @@ -126,9 +130,12 @@ class MainActivity : ComponentActivity() { super.onPause() this.lifecycleScope.launch { Log.d("QuickSettings", "App is paused - maybe quick settings opened?") - DrawCanvas.refreshUi.emit(Unit) } + // Export index when app goes to background + if (hasFilePermission(this)) { + IndexExporter.scheduleExport(this) + } } override fun onWindowFocusChanged(hasFocus: Boolean) { diff --git a/app/src/main/java/com/ethran/notable/io/IndexExporter.kt b/app/src/main/java/com/ethran/notable/io/IndexExporter.kt new file mode 100644 index 00000000..eec723e6 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/io/IndexExporter.kt @@ -0,0 +1,203 @@ +package com.ethran.notable.io + +import android.content.Context +import com.ethran.notable.data.AppRepository +import com.ethran.notable.data.db.Folder +import com.ethran.notable.data.getDbDir +import io.shipbook.shipbooksdk.ShipBook +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +private val log = ShipBook.getLogger("IndexExporter") + +/** + * Lightweight JSON index for PKM systems (Emacs, Obsidian, etc.) to navigate Notable data. + * + * The index contains only metadata (IDs, names, relationships) without stroke data, + * keeping the file small regardless of notebook size. + * + * Index location: Documents/notabledb/notable-index.json + * + * Deep link reference: + * - notable://page-{id} - Open page + * - notable://book-{id} - Open book + * - notable://new-folder?name=X&parent=Y - Create folder + * - notable://new-book?name=X&folder=Y - Create book + * - notable://new-page/{uuid}?name=X&folder=Y - Create quick page + * - notable://book/{id}/new-page/{uuid}?name=X - Create page in book + * - notable://export/page/{id}?format=pdf|png|jpg|xopp - Export page + * - notable://export/book/{id}?format=pdf|png|xopp - Export book + * - notable://sync-index - Refresh this index + */ +object IndexExporter { + private const val INDEX_FILENAME = "notable-index.json" + private const val DEBOUNCE_MS = 2000L + + private var exportJob: Job? = null + private val json = Json { prettyPrint = true } + + /** + * Schedule an index export with debouncing. + * Multiple rapid calls will only trigger one export after the debounce period. + */ + fun scheduleExport(context: Context) { + exportJob?.cancel() + exportJob = CoroutineScope(Dispatchers.IO).launch { + delay(DEBOUNCE_MS) + exportNow(context) + } + } + + /** + * Export the index immediately, bypassing debounce. + */ + fun exportNow(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + try { + val index = buildIndex(context) + writeIndex(index) + log.d("Index exported: ${index.folders.size} folders, ${index.notebooks.size} notebooks, ${index.pages.size} pages") + } catch (e: Exception) { + log.e("Failed to export index", e) + } + } + } + + /** + * Export the index synchronously (for use in deep link handlers). + */ + fun exportSync(context: Context) { + try { + val index = buildIndex(context) + writeIndex(index) + log.d("Index exported (sync): ${index.folders.size} folders, ${index.notebooks.size} notebooks, ${index.pages.size} pages") + } catch (e: Exception) { + log.e("Failed to export index (sync)", e) + } + } + + private fun buildIndex(context: Context): NotableIndex { + val repo = AppRepository(context) + + // Build folder map for path computation + val allFolders = repo.folderRepository.getAll() + val folderMap = allFolders.associateBy { it.id } + + val folders = allFolders.map { folder -> + IndexFolder( + id = folder.id, + name = folder.title, + parentId = folder.parentFolderId, + path = buildFolderPath(folder, folderMap) + ) + } + + val notebooks = repo.bookRepository.getAll().map { notebook -> + IndexNotebook( + id = notebook.id, + name = notebook.title, + folderId = notebook.parentFolderId, + folderPath = notebook.parentFolderId?.let { buildFolderPathById(it, folderMap) }, + pageIds = notebook.pageIds, + pageCount = notebook.pageIds.size + ) + } + + val pages = repo.pageRepository.getAll().map { page -> + // Find page index in notebook if it belongs to one + val pageIndex = if (page.notebookId != null) { + repo.bookRepository.getById(page.notebookId)?.pageIds?.indexOf(page.id)?.takeIf { it >= 0 } + } else null + + IndexPage( + id = page.id, + name = page.name, + notebookId = page.notebookId, + folderId = page.parentFolderId, + folderPath = page.parentFolderId?.let { buildFolderPathById(it, folderMap) }, + pageIndex = pageIndex + ) + } + + return NotableIndex( + version = 2, + exportFormats = listOf("pdf", "png", "jpg", "xopp"), + folders = folders, + notebooks = notebooks, + pages = pages + ) + } + + private fun buildFolderPath(folder: Folder, folderMap: Map): String { + val pathParts = mutableListOf() + var current: Folder? = folder + while (current != null) { + pathParts.add(0, current.title) + current = current.parentFolderId?.let { folderMap[it] } + } + return pathParts.joinToString("/") + } + + private fun buildFolderPathById(folderId: String, folderMap: Map): String? { + val folder = folderMap[folderId] ?: return null + return buildFolderPath(folder, folderMap) + } + + private fun writeIndex(index: NotableIndex) { + val dbDir = getDbDir() + val indexFile = File(dbDir, INDEX_FILENAME) + val jsonString = json.encodeToString(index) + indexFile.writeText(jsonString) + } + + /** + * Get the path to the index file. + */ + fun getIndexPath(): String { + return File(getDbDir(), INDEX_FILENAME).absolutePath + } +} + +@Serializable +data class NotableIndex( + val version: Int, + val exportFormats: List, + val folders: List, + val notebooks: List, + val pages: List +) + +@Serializable +data class IndexFolder( + val id: String, + val name: String, + val parentId: String?, + val path: String +) + +@Serializable +data class IndexNotebook( + val id: String, + val name: String, + val folderId: String?, + val folderPath: String?, + val pageIds: List, + val pageCount: Int +) + +@Serializable +data class IndexPage( + val id: String, + val name: String?, + val notebookId: String?, + val folderId: String?, + val folderPath: String?, + val pageIndex: Int? +) From 83ebc43832181a5d6990fd8a966ebde746f5c5ea Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 10:10:01 +0545 Subject: [PATCH 4/9] Add deep link handlers for PKM integration Router.kt now handles the following deep links: - notable://page-{id} - Open page - notable://book-{id} - Open notebook - notable://new-folder?name=X&parent=Y - Create folder (Boox only) - notable://new-book?name=X&folder=Y - Create notebook (Boox only) - notable://new-page/{uuid}?name=X&folder=Y - Create quick page (Boox only) - notable://book/{id}/new-page/{uuid}?name=X - Create page in notebook (Boox only) - notable://export/page/{id}?format=X - Export page - notable://export/book/{id}?format=X - Export notebook - notable://sync-index - Refresh JSON index Also removes remaining copyToClipboard references from autoExport.kt and ConfirmationDialog.kt. Co-Authored-By: Claude Opus 4.5 --- .../java/com/ethran/notable/io/autoExport.kt | 1 - .../main/java/com/ethran/notable/ui/Router.kt | 348 +++++++++++++++++- .../notable/ui/dialogs/ConfirmationDialog.kt | 1 - 3 files changed, 347 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/io/autoExport.kt b/app/src/main/java/com/ethran/notable/io/autoExport.kt index 08343a13..f075f769 100644 --- a/app/src/main/java/com/ethran/notable/io/autoExport.kt +++ b/app/src/main/java/com/ethran/notable/io/autoExport.kt @@ -40,7 +40,6 @@ fun exportToLinkedFile( target = ExportTarget.Book(bookId), format = ExportFormat.XOPP, options = ExportOptions( - copyToClipboard = false, targetFolderUri = uriStr.toUri(), overwrite = true ) diff --git a/app/src/main/java/com/ethran/notable/ui/Router.kt b/app/src/main/java/com/ethran/notable/ui/Router.kt index 602054fb..ffdf40c4 100644 --- a/app/src/main/java/com/ethran/notable/ui/Router.kt +++ b/app/src/main/java/com/ethran/notable/ui/Router.kt @@ -1,5 +1,7 @@ package com.ethran.notable.ui +import android.content.Context +import android.net.Uri import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi @@ -19,15 +21,25 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.ethran.notable.data.AppRepository import com.ethran.notable.data.datastore.GlobalAppSettings +import com.ethran.notable.data.db.Folder +import com.ethran.notable.data.db.Notebook +import com.ethran.notable.data.db.Page +import com.ethran.notable.data.db.newPage import com.ethran.notable.editor.DrawCanvas import com.ethran.notable.editor.EditorView import com.ethran.notable.editor.utils.refreshScreen +import com.ethran.notable.io.ExportEngine +import com.ethran.notable.io.ExportFormat +import com.ethran.notable.io.ExportTarget +import com.ethran.notable.io.IndexExporter import com.ethran.notable.ui.components.Anchor import com.ethran.notable.ui.components.QuickNav import com.ethran.notable.ui.views.BugReportScreen @@ -38,6 +50,8 @@ import com.ethran.notable.ui.views.SystemInformationView import com.ethran.notable.ui.views.WelcomeView import com.ethran.notable.ui.views.hasFilePermission import io.shipbook.shipbooksdk.ShipBook +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException @@ -47,17 +61,29 @@ private val logRouter = ShipBook.getLogger("Router") @ExperimentalFoundationApi @ExperimentalComposeUiApi @Composable -fun Router() { +fun Router(intentData: String? = null) { + val context = LocalContext.current val navController = rememberNavController() var isQuickNavOpen by remember { mutableStateOf(false) } var currentPageId: String? by remember { mutableStateOf(null) } + var deepLinkHandled by remember { mutableStateOf(false) } LaunchedEffect(isQuickNavOpen) { logRouter.d("Changing drawing state, isQuickNavOpen: $isQuickNavOpen") DrawCanvas.isDrawing.emit(!isQuickNavOpen) } + + // Handle deep links + LaunchedEffect(intentData) { + if (intentData == null || deepLinkHandled) return@LaunchedEffect + if (!hasFilePermission(context)) return@LaunchedEffect + + deepLinkHandled = true + handleDeepLink(context, navController, intentData) + } + val startDestination = if (GlobalAppSettings.current.showWelcome || !hasFilePermission(LocalContext.current)) "welcome" else "library?folderId={folderId}" @@ -266,4 +292,324 @@ private fun Modifier.detectThreeFingerTouchToOpenQuickNav( logRouter.e("Router: Error in pointerInput", e) } } +} + +/** + * Handles deep link navigation for PKM systems integration. + * + * Supported URL formats: + * + * Navigation: + * - notable://page-{uuid} - Open existing page + * - notable://book-{uuid} - Open existing book + * + * Create: + * - notable://new-folder?name=FolderName&parent=ParentFolderName - Create folder + * - notable://new-book?name=BookName&folder=FolderName - Create book in folder + * - notable://new-page/{uuid}?name=PageName - Create standalone quick page + * - notable://new-page/{uuid}?name=PageName&folder=FolderName - Create quick page in folder + * - notable://book/{bookId}/new-page/{uuid}?name=PageName - Create page in book + * + * Export: + * - notable://export/page/{pageId}?format=pdf|png|jpg|xopp - Export page + * - notable://export/book/{bookId}?format=pdf|png|xopp - Export book + * + * Utility: + * - notable://sync-index - Force refresh the JSON index + */ +private suspend fun handleDeepLink(context: Context, navController: NavController, intentData: String) { + try { + val uri = Uri.parse(intentData) + if (uri.scheme != "notable") { + logRouter.w("Invalid deep link scheme: ${uri.scheme}") + return + } + + // Get the path - handle both host-based and path-based formats + val path = uri.host ?: uri.path?.removePrefix("/") ?: return + logRouter.d("Handling deep link path: $path") + + when { + // Sync index: notable://sync-index + path == "sync-index" -> { + logRouter.d("Syncing index") + withContext(Dispatchers.IO) { + IndexExporter.exportSync(context) + } + } + + // Create new folder: notable://new-folder?name=FolderName&parent=ParentFolderName + path == "new-folder" -> { + val folderName = uri.getQueryParameter("name") ?: "New Folder" + val parentName = uri.getQueryParameter("parent") + logRouter.d("Creating new folder: $folderName in parent: $parentName") + withContext(Dispatchers.IO) { + createNewFolder(context, folderName, parentName) + IndexExporter.exportSync(context) + } + } + + // Create new book: notable://new-book?name=BookName&folder=FolderName + path == "new-book" -> { + val bookName = uri.getQueryParameter("name") ?: "New Notebook" + val folderName = uri.getQueryParameter("folder") + logRouter.d("Creating new book: $bookName in folder: $folderName") + val bookId = withContext(Dispatchers.IO) { + val id = createNewBook(context, bookName, folderName) + IndexExporter.exportSync(context) + id + } + if (bookId != null) { + navController.navigate("books/$bookId/pages") + } + } + + // Export page: notable://export/page/{pageId}?format=pdf + path.startsWith("export/page/") -> { + val pageId = path.removePrefix("export/page/") + val format = uri.getQueryParameter("format") ?: "png" + logRouter.d("Exporting page $pageId as $format") + withContext(Dispatchers.IO) { + exportPage(context, pageId, format) + } + } + + // Export book: notable://export/book/{bookId}?format=pdf + path.startsWith("export/book/") -> { + val bookId = path.removePrefix("export/book/") + val format = uri.getQueryParameter("format") ?: "pdf" + logRouter.d("Exporting book $bookId as $format") + withContext(Dispatchers.IO) { + exportBook(context, bookId, format) + } + } + + // Open existing page: notable://page-{uuid} + path.startsWith("page-") -> { + val pageId = path.removePrefix("page-") + logRouter.d("Opening existing page: $pageId") + navController.navigate("pages/$pageId") + } + + // Open existing book: notable://book-{uuid} + path.startsWith("book-") && !path.contains("/") -> { + val bookId = path.removePrefix("book-") + logRouter.d("Opening existing book: $bookId") + navController.navigate("books/$bookId/pages") + } + + // Create new standalone page: notable://new-page/{uuid}?name=PageName&folder=FolderName + path.startsWith("new-page/") || path.startsWith("new-page-") -> { + val pageId = path.removePrefix("new-page/").removePrefix("new-page-") + val pageName = uri.getQueryParameter("name") + val folderName = uri.getQueryParameter("folder") + logRouter.d("Creating new page: $pageId, name: $pageName, folder: $folderName") + withContext(Dispatchers.IO) { + if (folderName != null) { + createNewPageInFolderByName(context, folderName, pageId, pageName) + } else { + createNewPageIfNotExists(context, pageId, pageName) + } + IndexExporter.exportSync(context) + } + navController.navigate("pages/$pageId") + } + + // Create new page in book: notable://book/{bookId}/new-page/{uuid}?name=PageName + path.startsWith("book/") && path.contains("/new-page/") -> { + val parts = path.removePrefix("book/").split("/new-page/") + if (parts.size == 2) { + val bookId = parts[0] + val pageId = parts[1] + val pageName = uri.getQueryParameter("name") + logRouter.d("Creating new page $pageId in book $bookId with name: $pageName") + withContext(Dispatchers.IO) { + createNewPageInBookIfNotExists(context, bookId, pageId, pageName) + IndexExporter.exportSync(context) + } + navController.navigate("books/$bookId/pages/$pageId") + } + } + + // Legacy: Create new page in folder by name (keeping for compatibility) + path.startsWith("folder/") && path.contains("/new-page/") -> { + val parts = path.removePrefix("folder/").split("/new-page/") + if (parts.size == 2) { + val folderName = Uri.decode(parts[0]) + val pageId = parts[1] + val pageName = uri.getQueryParameter("name") + logRouter.d("Creating new page $pageId in folder '$folderName' with name: $pageName") + withContext(Dispatchers.IO) { + createNewPageInFolderByName(context, folderName, pageId, pageName) + IndexExporter.exportSync(context) + } + navController.navigate("pages/$pageId") + } + } + + else -> { + logRouter.w("Unknown deep link format: $path") + } + } + } catch (e: Exception) { + logRouter.e("Error handling deep link: $intentData", e) + } +} + +/** + * Creates a new standalone page if it doesn't already exist. + */ +private fun createNewPageIfNotExists(context: Context, pageId: String, pageName: String? = null) { + val repo = AppRepository(context) + if (repo.pageRepository.getById(pageId) == null) { + val settings = GlobalAppSettings.current + val page = Page( + id = pageId, + name = pageName, + background = settings.defaultNativeTemplate + ) + repo.pageRepository.create(page) + logRouter.d("Created new standalone page: $pageId with name: $pageName") + } else { + logRouter.d("Page already exists: $pageId") + } +} + +/** + * Creates a new page in a book if it doesn't already exist. + */ +private fun createNewPageInBookIfNotExists( + context: Context, + bookId: String, + pageId: String, + pageName: String? = null +) { + val repo = AppRepository(context) + val book = repo.bookRepository.getById(bookId) + + if (book == null) { + logRouter.w("Book not found: $bookId") + return + } + + if (repo.pageRepository.getById(pageId) == null) { + val page = book.newPage().copy(id = pageId, name = pageName) + repo.pageRepository.create(page) + repo.bookRepository.addPage(bookId, pageId) + logRouter.d("Created new page $pageId in book $bookId with name: $pageName") + } else { + logRouter.d("Page already exists: $pageId") + // If page exists but not in book, add it + if (!book.pageIds.contains(pageId)) { + repo.bookRepository.addPage(bookId, pageId) + logRouter.d("Added existing page $pageId to book $bookId") + } + } +} + +/** + * Creates a new page in a folder (looked up by name) if it doesn't already exist. + * If no folder with the given name exists, creates the page in the root. + */ +private fun createNewPageInFolderByName( + context: Context, + folderName: String, + pageId: String, + pageName: String? = null +) { + val repo = AppRepository(context) + val folder = repo.folderRepository.getByTitle(folderName) + + if (folder == null) { + logRouter.w("Folder not found: '$folderName', creating page in root") + } + + if (repo.pageRepository.getById(pageId) == null) { + val settings = GlobalAppSettings.current + val page = Page( + id = pageId, + name = pageName, + parentFolderId = folder?.id, + background = settings.defaultNativeTemplate + ) + repo.pageRepository.create(page) + logRouter.d("Created new page $pageId in folder '${folderName}' (${folder?.id}) with name: $pageName") + } else { + logRouter.d("Page already exists: $pageId") + } +} + +/** + * Creates a new folder. + */ +private fun createNewFolder(context: Context, folderName: String, parentFolderName: String?) { + val repo = AppRepository(context) + val parentFolder = parentFolderName?.let { repo.folderRepository.getByTitle(it) } + + val folder = Folder( + title = folderName, + parentFolderId = parentFolder?.id + ) + repo.folderRepository.create(folder) + logRouter.d("Created new folder: $folderName in parent: ${parentFolder?.title ?: "root"}") +} + +/** + * Creates a new book/notebook in a folder. + * Returns the book ID if created successfully. + */ +private fun createNewBook(context: Context, bookName: String, folderName: String?): String? { + val repo = AppRepository(context) + val folder = folderName?.let { repo.folderRepository.getByTitle(it) } + + val notebook = Notebook( + title = bookName, + parentFolderId = folder?.id + ) + repo.bookRepository.create(notebook) + logRouter.d("Created new book: $bookName in folder: ${folder?.title ?: "root"}") + return notebook.id +} + +/** + * Exports a page to the specified format. + */ +private suspend fun exportPage(context: Context, pageId: String, format: String) { + val exportFormat = when (format.lowercase()) { + "pdf" -> ExportFormat.PDF + "png" -> ExportFormat.PNG + "jpg", "jpeg" -> ExportFormat.JPEG + "xopp" -> ExportFormat.XOPP + else -> { + logRouter.w("Unknown export format: $format, defaulting to PNG") + ExportFormat.PNG + } + } + + val result = ExportEngine(context).export( + target = ExportTarget.Page(pageId = pageId), + format = exportFormat + ) + logRouter.d("Export result: $result") +} + +/** + * Exports a book to the specified format. + */ +private suspend fun exportBook(context: Context, bookId: String, format: String) { + val exportFormat = when (format.lowercase()) { + "pdf" -> ExportFormat.PDF + "png" -> ExportFormat.PNG + "xopp" -> ExportFormat.XOPP + else -> { + logRouter.w("Unknown export format: $format, defaulting to PDF") + ExportFormat.PDF + } + } + + val result = ExportEngine(context).export( + target = ExportTarget.Book(bookId = bookId), + format = exportFormat + ) + logRouter.d("Export result: $result") } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/ui/dialogs/ConfirmationDialog.kt b/app/src/main/java/com/ethran/notable/ui/dialogs/ConfirmationDialog.kt index 94836b16..d87e7a3e 100644 --- a/app/src/main/java/com/ethran/notable/ui/dialogs/ConfirmationDialog.kt +++ b/app/src/main/java/com/ethran/notable/ui/dialogs/ConfirmationDialog.kt @@ -123,7 +123,6 @@ fun ShowExportDialog( target = ExportTarget.Book(bookId = bookId), format = ExportFormat.PDF, options = ExportOptions( - copyToClipboard = false, ) ) From 16f88a7766473a9af143ac3123745e91fe822274 Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 10:10:10 +0545 Subject: [PATCH 5/9] Add documentation for deep links and PKM integration - Add docs/deep-links.md with complete reference for: - Deep link API (navigation, creation, export, utility) - JSON index structure and location - Export directory layout - Emacs integration guide - Update docs/export-formats.md to remove clipboard references Co-Authored-By: Claude Opus 4.5 --- docs/deep-links.md | 211 +++++++++++++++++++++++++++++++++++++++++ docs/export-formats.md | 8 +- 2 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 docs/deep-links.md diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 00000000..3afce61e --- /dev/null +++ b/docs/deep-links.md @@ -0,0 +1,211 @@ +# Deep Links and PKM Integration + +Notable exposes a deep link API and JSON index for integration with external tools such as Emacs, Obsidian, and other personal knowledge management (PKM) systems. + +## Deep Link Scheme + +All deep links use the `notable://` scheme. On Android, these links are handled by the app's intent filter. + +### Navigation + +| Link | Description | +|------|-------------| +| `notable://page-{id}` | Open page by UUID | +| `notable://book-{id}` | Open notebook by UUID | + +### Creation (Boox only) + +These links create new items. Creation requires the Onyx Boox system API and is unavailable on other Android devices. + +| Link | Description | +|------|-------------| +| `notable://new-folder?name={name}&parent={parentPath}` | Create folder | +| `notable://new-book?name={name}&folder={folderPath}` | Create notebook | +| `notable://new-page/{uuid}?name={name}&folder={folderPath}` | Create quick page with specified UUID | +| `notable://book/{bookId}/new-page/{uuid}?name={name}` | Create page in notebook with specified UUID | + +Parameters: +- `name` (optional): Display name for the item +- `parent` / `folder` (optional): Folder path (e.g., "Work/Projects") +- `uuid`: Client-generated UUID for the new page (allows immediate link creation) + +### Export + +| Link | Description | +|------|-------------| +| `notable://export/page/{id}?format={format}` | Export page | +| `notable://export/book/{id}?format={format}` | Export notebook | + +Supported formats: `pdf`, `png`, `jpg`, `xopp` + +### Utility + +| Link | Description | +|------|-------------| +| `notable://sync-index` | Force index regeneration | + +## JSON Index + +Notable exports a lightweight JSON index for external tools to browse the notebook structure without accessing the database directly. + +### Location + +``` +Documents/notabledb/notable-index.json +``` + +### Structure + +```json +{ + "version": 2, + "exportFormats": ["pdf", "png", "jpg", "xopp"], + "folders": [ + { + "id": "uuid", + "name": "Folder Name", + "parentId": "parent-uuid or null", + "path": "Parent/Folder Name" + } + ], + "notebooks": [ + { + "id": "uuid", + "name": "Notebook Title", + "folderId": "folder-uuid or null", + "folderPath": "Folder/Path or null", + "pageIds": ["page-uuid-1", "page-uuid-2"], + "pageCount": 2 + } + ], + "pages": [ + { + "id": "uuid", + "name": "Page Name or null", + "notebookId": "notebook-uuid or null", + "folderId": "folder-uuid or null", + "folderPath": "Folder/Path or null", + "pageIndex": 0 + } + ] +} +``` + +### Index Updates + +The index is regenerated: +- On app startup +- When the app goes to background +- On `notable://sync-index` deep link + +Updates are debounced (2-second delay) to avoid excessive writes during rapid changes. + +## Export Directory Structure + +Exported files are stored under `Documents/notable/` with the following structure: + +``` +Documents/notable/ + FolderPath/ + BookTitle.pdf # Book export (PDF/XOPP) + BookTitle/ + BookTitle-p1.png # Book pages (PNG/JPG) + BookTitle-p2.png + BookTitle-p1.pdf # Individual page export + quickpage-2025-01-31_14-30.pdf # Quick page export +``` + +## Emacs Integration + +An Emacs package (`notable.el`) provides a transient-based interface for Notable integration. + +### Installation + +1. Copy `notable.el` to your Emacs load path +2. Add to your config: + ```elisp + (require 'notable) + ``` + +### Configuration + +```elisp +(defcustom notable-index-file + ;; Android: Documents/notabledb/notable-index.json + ;; Desktop: ~/Notes/notabledb/notable-index.json + ) + +(defcustom notable-export-directory + ;; Android: Documents/notable + ;; Desktop: ~/Notes/notable + ) +``` + +### Device Detection + +The package uses device detection variables to show appropriate features: + +| Variable | Description | +|----------|-------------| +| `IS-ANDROID` | Running on Android (Termux/Emacs Android port) | +| `IS-ONYX` | Running on Onyx Boox e-reader | + +Define these in your config before loading `notable.el`, or the package will use fallback detection. + +### Commands + +Invoke `M-x notable` to open the transient menu. + +**Navigation (Android only)** +- `o p` - Open page in Notable +- `o b` - Open notebook in Notable + +**Create (Boox only)** +- `c f` - Create folder +- `c b` - Create notebook +- `c p` - Create quick page (inserts org-mode link at point) +- `c P` - Create page in notebook + +**Export (Android only)** +- `e p` - Export page +- `e b` - Export notebook + +**View Exports (all devices)** +- `v p` - View page export (PDF, PNG, etc.) +- `v b` - View notebook export + +**Links (all devices)** +- `l p` - Insert page link +- `l b` - Insert notebook link + +**Utility (Android only)** +- `r` - Refresh index + +### Org-Mode Links + +The package registers `notable://` as an org-mode link type. Clicking a Notable link on Android opens the page/notebook in the app. + +```org +[[notable://page-abc123][My Page]] +[[notable://book-def456][My Notebook]] +``` + +## Implementation Files + +| File | Purpose | +|------|---------| +| `io/IndexExporter.kt` | JSON index generation | +| `ui/Router.kt` | Deep link routing and handlers | +| `data/db/Page.kt` | Page entity with `name` field | +| `data/db/Folder.kt` | Folder DAO with `getAll()`, `getByTitle()` | +| `data/db/Notebook.kt` | Notebook DAO with `getAll()` | +| `MainActivity.kt` | Index export on startup/pause | + +## Removed Features + +This implementation replaces the previous clipboard-based link copying system. The following settings have been removed from `AppSettings`: +- `linkCopyEnabled` +- `linkTemplate` +- `exportBaseDirectory` + +The JSON decoder uses `ignoreUnknownKeys = true` for backward compatibility with existing settings. diff --git a/docs/export-formats.md b/docs/export-formats.md index 7795709f..61d5cdab 100644 --- a/docs/export-formats.md +++ b/docs/export-formats.md @@ -11,7 +11,6 @@ Overview - Book(bookId): all pages of a notebook - Page(pageId): a single page - Options (ExportOptions): - - copyToClipboard: copy a convenience link for some formats/targets - targetFolderUri: destination directory (SAF or file://) - overwrite: best-effort replacement of existing files (currently **not working** - replaces all the files.) - fileName: base name override (extension added automatically) @@ -22,12 +21,11 @@ Core (Maintainer: @Ethran) - Book: multi-page; pagination via GlobalAppSettings.current.paginatePdf - Page: single page (or paginated) - Scales to A4 width; splits by A4 height if enabled - - Copies a link on single-page export - PNG - - Book: -p1.png, -p2.png, … - - Page: single PNG; copies a wiki-style link + - Book: -p1.png, -p2.png, ... + - Page: single PNG - JPEG - - Same as PNG, but no clipboard link + - Same as PNG - XOPP (Xournal++) - Book/Page: gzipped XML via XoppFile.writeToXoppStream - Includes tools, colors, pressure-derived widths; images embedded as base64 From 59adf48fe915e0190eb5de1f4c2a71313479e6e7 Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 11:30:01 +0545 Subject: [PATCH 6/9] Fix page deep link to open in notebook context When opening a page via notable://page-{id} deep link, check if the page belongs to a notebook. If so, navigate to the notebook route (books/{bookId}/pages/{pageId}) instead of the standalone page route. This enables forward/backward navigation between pages when opening a notebook page via deep link. Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/com/ethran/notable/ui/Router.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/ethran/notable/ui/Router.kt b/app/src/main/java/com/ethran/notable/ui/Router.kt index ffdf40c4..962a1b94 100644 --- a/app/src/main/java/com/ethran/notable/ui/Router.kt +++ b/app/src/main/java/com/ethran/notable/ui/Router.kt @@ -388,7 +388,15 @@ private suspend fun handleDeepLink(context: Context, navController: NavControlle path.startsWith("page-") -> { val pageId = path.removePrefix("page-") logRouter.d("Opening existing page: $pageId") - navController.navigate("pages/$pageId") + // Check if page belongs to a notebook for proper navigation + val repo = AppRepository(context) + val page = repo.pageRepository.getById(pageId) + if (page?.notebookId != null) { + // Open in notebook context for forward/backward navigation + navController.navigate("books/${page.notebookId}/pages/$pageId") + } else { + navController.navigate("pages/$pageId") + } } // Open existing book: notable://book-{uuid} From 8eda750ce5993f0437b0190ad8a725ad29ffa628 Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 11:43:31 +0545 Subject: [PATCH 7/9] Fix deep link URL parsing for multi-segment paths The previous implementation only used uri.host for the path, which broke URLs with multiple path segments like notable://export/page/{id}. Now correctly combines host and path to build the full path: - notable://export/page/abc123 -> "export/page/abc123" - notable://new-page/abc123 -> "new-page/abc123" This fixes: - Export page/book deep links - Create quick page deep links Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/com/ethran/notable/ui/Router.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/ui/Router.kt b/app/src/main/java/com/ethran/notable/ui/Router.kt index 962a1b94..19ef4452 100644 --- a/app/src/main/java/com/ethran/notable/ui/Router.kt +++ b/app/src/main/java/com/ethran/notable/ui/Router.kt @@ -325,8 +325,12 @@ private suspend fun handleDeepLink(context: Context, navController: NavControlle return } - // Get the path - handle both host-based and path-based formats - val path = uri.host ?: uri.path?.removePrefix("/") ?: return + // Build full path from host + path (notable://host/path/segments) + val path = buildString { + uri.host?.let { append(it) } + uri.path?.let { append(it) } + }.removePrefix("/") + if (path.isEmpty()) return logRouter.d("Handling deep link path: $path") when { From f05375dd8043755c5431a4947eefe67e35391c3b Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 11:46:02 +0545 Subject: [PATCH 8/9] Add page rename functionality - Add "Rename" option to PageMenu context menu (long-press on page) - Create PageRenameDialog with text input for page name - Page names are stored in the database and exported to JSON index This completes the page naming feature for PKM integration, allowing pages to be named via deep links or through the UI. Co-Authored-By: Claude Opus 4.5 --- .../com/ethran/notable/editor/ui/PageMenu.kt | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/app/src/main/java/com/ethran/notable/editor/ui/PageMenu.kt b/app/src/main/java/com/ethran/notable/editor/ui/PageMenu.kt index 85c7ab12..bfa4356a 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/PageMenu.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/PageMenu.kt @@ -2,19 +2,34 @@ package com.ethran.notable.editor.ui import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.ethran.notable.data.AppRepository @@ -33,6 +48,19 @@ fun PageMenu( ) { val context = LocalContext.current val appRepository = AppRepository(context) + var showRenameDialog by remember { mutableStateOf(false) } + + if (showRenameDialog) { + PageRenameDialog( + pageId = pageId, + appRepository = appRepository, + onClose = { + showRenameDialog = false + onClose() + } + ) + return + } Popup( alignment = Alignment.TopStart, onDismissRequest = { onClose() }, @@ -85,6 +113,15 @@ fun PageMenu( } } + Box( + Modifier + .padding(10.dp) + .noRippleClickable { + showRenameDialog = true + }) { + Text("Rename") + } + Box( Modifier .padding(10.dp) @@ -107,3 +144,84 @@ fun PageMenu( } } +@Composable +fun PageRenameDialog( + pageId: String, + appRepository: AppRepository, + onClose: () -> Unit +) { + val page = remember { appRepository.pageRepository.getById(pageId) } + var pageName by remember { mutableStateOf(page?.name ?: "") } + + Dialog(onDismissRequest = onClose) { + Column( + modifier = Modifier + .background(Color.White) + .border(1.dp, Color.Black, RectangleShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Rename Page", + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ) + + BasicTextField( + value = pageName, + onValueChange = { pageName = it }, + textStyle = TextStyle(fontSize = 16.sp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + page?.let { + appRepository.pageRepository.update(it.copy(name = pageName.ifBlank { null })) + } + onClose() + } + ), + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Color.Gray, RectangleShape) + .padding(12.dp), + decorationBox = { innerTextField -> + Box { + if (pageName.isEmpty()) { + Text("Page name", color = Color.Gray) + } + innerTextField() + } + } + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(top = 12.dp) + ) { + Box( + Modifier + .border(1.dp, Color.Black, RectangleShape) + .padding(horizontal = 16.dp, vertical = 8.dp) + .noRippleClickable { onClose() } + ) { + Text("Cancel") + } + Box( + Modifier + .border(1.dp, Color.Black, RectangleShape) + .padding(horizontal = 16.dp, vertical = 8.dp) + .noRippleClickable { + page?.let { + appRepository.pageRepository.update(it.copy(name = pageName.ifBlank { null })) + } + onClose() + } + ) { + Text("Save") + } + } + } + } +} + From 0499e09f6390961211f9f8788112ea6d686b8eaa Mon Sep 17 00:00:00 2001 From: alokehpdev Date: Sat, 31 Jan 2026 11:53:39 +0545 Subject: [PATCH 9/9] Display page name below quick page preview Show the page name below the preview thumbnail in the quick pages row, similar to how notebook titles are displayed. The name is only shown if the page has a name set (not null or blank). Co-Authored-By: Claude Opus 4.5 --- .../notable/ui/components/ShowPagesRow.kt | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/ui/components/ShowPagesRow.kt b/app/src/main/java/com/ethran/notable/ui/components/ShowPagesRow.kt index fa45eb13..cb4a4138 100644 --- a/app/src/main/java/com/ethran/notable/ui/components/ShowPagesRow.kt +++ b/app/src/main/java/com/ethran/notable/ui/components/ShowPagesRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -24,7 +25,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.ethran.notable.data.AppRepository import com.ethran.notable.data.datastore.GlobalAppSettings @@ -116,28 +120,42 @@ fun ShowPagesRow( items(singlePages.reversed()) { page -> val pageId = page.id var isPageSelected by remember { mutableStateOf(false) } - Box { - PagePreview( - modifier = Modifier - .combinedClickable( - onClick = { - onSelectPage(pageId) - }, - onLongClick = { - isPageSelected = true - }, - ) - .width(100.dp) - .aspectRatio(3f / 4f) - .border( - if (currentPageId == pageId) 4.dp else 1.dp, - Color.Black, - RectangleShape - ), - pageId = pageId - ) - if (isPageSelected) PageMenu( - pageId = pageId, canDelete = true, onClose = { isPageSelected = false }) + Column( + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + Box { + PagePreview( + modifier = Modifier + .combinedClickable( + onClick = { + onSelectPage(pageId) + }, + onLongClick = { + isPageSelected = true + }, + ) + .width(100.dp) + .aspectRatio(3f / 4f) + .border( + if (currentPageId == pageId) 4.dp else 1.dp, + Color.Black, + RectangleShape + ), + pageId = pageId + ) + if (isPageSelected) PageMenu( + pageId = pageId, canDelete = true, onClose = { isPageSelected = false }) + } + if (!page.name.isNullOrBlank()) { + Text( + text = page.name, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.width(100.dp) + ) + } } } }