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/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/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/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/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/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) } 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") + } + } + } + } +} + 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 -------------------- */ /** 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? +) 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..19ef4452 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,336 @@ 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 + } + + // 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 { + // 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") + // 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} + 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/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) + ) + } } } } 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, ) ) 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