-
-
Notifications
You must be signed in to change notification settings - Fork 22
Deep Links And PKM integration #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Ethran
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On first glance looks good. I didn't understand the intention for previous "copy link for obsydian", and it was laying unattached, in messy state. It will be good to replace it.
I added few comments, mostly to move one thing from router to separate file, and some questions. I will do deeper review later.
| 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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this the only think that changed in db, that requires migration?
I don't see exactly why its needed, where its used. For now, I mostly generated page names to contain "quick pages" or page number in notebook.
It might be useful to have names for separate pages in notebook, but for now I don't see a need for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick pages don't have names and can pile up without us understanding what's under the hood. Previous logic works quite well and this doesn't change anything for the end user. If they want to rename manually, then they can. Else, it is exactly the same. And doesn't disrupt older database either.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, seems ok.
| - `o p` - Open page in Notable | ||
| - `o b` - Open notebook in Notable | ||
|
|
||
| **Create (Boox only)** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why some features are boox only?
| 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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you fixed it? Don't remove comments not related to your pr (exceptions: very stupid comments.)
| * Utility: | ||
| * - notable://sync-index - Force refresh the JSON index | ||
| */ | ||
| private suspend fun handleDeepLink(context: Context, navController: NavController, intentData: String) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like it should be in separate file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds deep link integration and a JSON index export system to enable external Personal Knowledge Management (PKM) tools like Emacs, Obsidian, and Logseq to integrate with Notable. The implementation replaces the previous clipboard-based link copying approach with a more maintainable solution.
Changes:
- Implements a deep link API using the
notable://scheme for navigation, creation, export, and sync operations - Adds a JSON index exporter that generates metadata about folders, notebooks, and pages for external tool consumption
- Introduces page naming support with a UI for renaming pages and displaying page names in the library view
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/deep-links.md | Comprehensive documentation of the deep link API, JSON index structure, and Emacs integration |
| docs/export-formats.md | Removed references to clipboard-based link copying |
| app/src/main/java/com/ethran/notable/io/IndexExporter.kt | New JSON index exporter with debounced export scheduling |
| app/src/main/java/com/ethran/notable/ui/Router.kt | Deep link handler with navigation, creation, and export operations |
| app/src/main/java/com/ethran/notable/MainActivity.kt | Integration points for index export on startup and app pause |
| app/src/main/java/com/ethran/notable/io/ExportEngine.kt | Cleanup of clipboard-related code and ExportOptions simplification |
| app/src/main/java/com/ethran/notable/editor/ui/PageMenu.kt | Added page rename dialog UI |
| app/src/main/java/com/ethran/notable/ui/components/ShowPagesRow.kt | Display page names below thumbnails |
| app/src/main/java/com/ethran/notable/data/db/Page.kt | Added nullable name field and getAll() method |
| app/src/main/java/com/ethran/notable/data/db/Folder.kt | Added getAll() and getByTitle() methods |
| app/src/main/java/com/ethran/notable/data/db/Notebook.kt | Added getAll() method |
| app/src/main/java/com/ethran/notable/data/db/Db.kt | Database version bump to 35 with auto-migration |
| app/src/main/java/com/ethran/notable/data/db/Kv.kt | JSON decoder updated to ignore unknown keys for backward compatibility |
| app/schemas/com.ethran.notable.data.db.AppDatabase/35.json | New database schema with Page.name field |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ### 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 | | ||
|
|
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation states that creation links require "Onyx Boox system API" but does not explain what happens if these links are invoked on non-Boox devices. Based on the code in Router.kt, the creation operations will execute on any Android device, not just Boox. Either the code should check for Boox devices before allowing creation, or the documentation should be updated to clarify the actual behavior.
| 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) |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation mentions using folder paths like "Work/Projects" but the deep link handler uses simple folder name lookups without path resolution. This inconsistency between documentation and implementation will confuse users. Either implement hierarchical folder path resolution in the code or update the documentation to clarify that only folder names (not paths) are supported.
| if (isPageSelected) PageMenu( | ||
| pageId = pageId, canDelete = true, onClose = { isPageSelected = false }) | ||
| Column( | ||
| horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The alignment is hardcoded using the full package path instead of using a predefined constant or the imported Alignment. This is verbose and reduces readability. Consider using Alignment.CenterHorizontally directly since it's already imported.
| horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally | |
| horizontalAlignment = Alignment.CenterHorizontally |
| @Query("SELECT * FROM folder WHERE title = :title LIMIT 1") | ||
| fun getByTitle(title: String): Folder? |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getByTitle query only returns one folder when multiple folders with the same title can exist. This will cause unpredictable behavior when creating pages in folders via deep links, as the query will arbitrarily select one matching folder. Consider using a combination of folder name and parent path to uniquely identify folders, or document that folder names must be unique in the documentation.
| LaunchedEffect(intentData) { | ||
| if (intentData == null || deepLinkHandled) return@LaunchedEffect | ||
| if (!hasFilePermission(context)) return@LaunchedEffect | ||
|
|
||
| deepLinkHandled = true | ||
| handleDeepLink(context, navController, intentData) | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deep link handler processes intent data every time the Router is recomposed, even though deepLinkHandled is checked. However, if the intentData parameter changes (e.g., a new intent arrives while the app is running), the deepLinkHandled flag will prevent processing. Consider resetting deepLinkHandled when intentData changes, or using intentData itself as the LaunchedEffect key.
| 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 | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The folder name parameter lookup in createNewBook uses only title matching, which will fail or return an arbitrary folder if multiple folders have the same name. Consider using the full folder path or implementing hierarchical resolution for consistency with other folder operations.
| fun scheduleExport(context: Context) { | ||
| exportJob?.cancel() | ||
| exportJob = CoroutineScope(Dispatchers.IO).launch { | ||
| delay(DEBOUNCE_MS) | ||
| exportNow(context) | ||
| } | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scheduleExport method creates a new CoroutineScope for each call rather than using a shared scope. This could lead to resource leaks if many exports are scheduled rapidly. Consider using a single CoroutineScope tied to the application lifecycle or using the existing lifecycleScope from the calling context.
| 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 |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ExportEngine methods are called with context and perform database operations on the IO dispatcher, but there's no error handling or user feedback if the export fails. The export result is only logged. Consider propagating errors to provide user feedback or at least ensure the errors are visible in the logs.
| 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). | ||
| */ |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exportNow method creates a new CoroutineScope for each call, which is unnecessary and could cause resource leaks. Since this method is already being called from an IO context or doesn't need to be async, consider making it a regular suspend function and removing the CoroutineScope.launch wrapper.
| 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). | |
| */ | |
| suspend fun exportNow(context: Context) { | |
| 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). | |
| */ | |
| * Export the index synchronously (for use in deep link handlers). | |
| */ |
| val page = remember { appRepository.pageRepository.getById(pageId) } | ||
| var pageName by remember { mutableStateOf(page?.name ?: "") } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The page is fetched once during initialization but is not updated if the page is modified elsewhere. If the page is deleted or modified by another process before the user clicks Save, the update will fail silently or use stale data. Consider using a mutableState with a snapshot or re-fetching the page before updating.
|
functions: I would propose to make those functions just a methods in class AppRepository, and making them more generic, cleaning up logic for creation of new pages throughout repo. Disclaimer: I'm not entirely sure if this is good idea, maybe you will find a better/cleaner solution for it. |
Yeah that was what was added so that text-based note taking and drawing-based note taking tools could be worked together. I had a workflow where I had a deep link that I would create in obsidian and it would open notable to let me draw about that page and auto export it. And for when I worked on the note in notable itself and wanted to link it to some text-based note in obsidian, I could grab the link and copy paste it. That was the intention behind copy link for obsidian when I created it. From my end, the summary is this: |
For me, db isn't that big, around 500 MB after a year of use (I will check exact number later). Device has a lot of storage, so up to 5 GB of db size is in my opinion acceptable. One thing that could be reasonable to do, is adding 'workspace': adding ability to lunch app with different db. Other than that I don't see a point in splitting db in smaller pieces. I don't know if any better solution exists for what you want to do. I will do a quick search to see if it's the best way of doing it. |
|
@dragarok Ok, I checked what can be done. It seems that ContentProvider for creating, and querying the available notebooks would be better, but a little bit more complicated. For now I see it as follows:
|
Yeah in terms of size and storage, I am not saying that its big. And with text based tools like obsidian or logseq or any other for that matter, handling a sqlite database vs handling a json file, the plaintext file always wins. The main advantage is that the plaintext file doesn't need to be synced two way. Its just one way replay of database's current state during only certain actions. |
This is adding deep link integration and pkm integration so that one can create pages, navigate to exports, add page, add books, folders etc. from their pkm of choice: e.g. Obsidian, Logseq, Emacs etc.
Previously, I had added copy links which did some of that partially but needed very domain specific ideas. This just maintains simpler index using json which any system can read with ease and build workflow around it so that the integration just feels awesome.
Example of how I am using this in my emacs workflow on Boox Tab Ultra C as well as my other android devices:
https://github.com/dragarok/doom-emacs-private-config/blob/daa6302f692adf5d2abde1aa1f905980ce8da267/lisp/notable.el