Skip to content

Conversation

@dragarok
Copy link

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

alokehpdev and others added 5 commits January 31, 2026 10:09
- 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>
@dragarok dragarok changed the title Feat/deep links pkm integration Deep Links And PKM integration Jan 31, 2026
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>
@dragarok dragarok marked this pull request as draft January 31, 2026 05:57
alokehpdev and others added 3 commits January 31, 2026 11:43
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>
Copy link
Owner

@Ethran Ethran left a 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,
Copy link
Owner

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.

Copy link
Author

@dragarok dragarok Feb 2, 2026

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.

Copy link
Owner

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)**
Copy link
Owner

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)
Copy link
Owner

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) {
Copy link
Owner

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.

Copy link
Contributor

Copilot AI left a 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.

Comment on lines +16 to +26
### 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 |

Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +30
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)
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
if (isPageSelected) PageMenu(
pageId = pageId, canDelete = true, onClose = { isPageSelected = false })
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
Copy link

Copilot AI Jan 31, 2026

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.

Suggested change
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +49
@Query("SELECT * FROM folder WHERE title = :title LIMIT 1")
fun getByTitle(title: String): Folder?
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +85
LaunchedEffect(intentData) {
if (intentData == null || deepLinkHandled) return@LaunchedEffect
if (!hasFilePermission(context)) return@LaunchedEffect

deepLinkHandled = true
handleDeepLink(context, navController, intentData)
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +573 to +584
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
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +56
fun scheduleExport(context: Context) {
exportJob?.cancel()
exportJob = CoroutineScope(Dispatchers.IO).launch {
delay(DEBOUNCE_MS)
exportNow(context)
}
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +589 to 627
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
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +75
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).
*/
Copy link

Copilot AI Jan 31, 2026

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.

Suggested change
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).
*/

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +154
val page = remember { appRepository.pageRepository.getById(pageId) }
var pageName by remember { mutableStateOf(page?.name ?: "") }
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
@Ethran
Copy link
Owner

Ethran commented Jan 31, 2026

functions:
createNewBook, createNewFolder, createNewPageInFolderByName, createNewPageInBookIfNotExists, createNewPageIfNotExists
seem to be mostly extensions of already existing logic in HomeView.kt.
Namely: onCreateNew, HomeView.kt line 300; and getNextPageIdFromBookAndPageOrCreate in AppRepository line 26.

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.

@dragarok
Copy link
Author

dragarok commented Feb 2, 2026

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.

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.
Right now as well, The intention is this: Notable has all the awesome features to create hand written notes. If we expose some of the functionalities as deep link and simpler index instead of full database which can grow into unmanageable sizes when there are too many notebooks, too many strokes; then anyone can build the tool they want in their note taking of choice to use that index to navigate to the note they want, create new page, etc. without any other complex functionality on Notable's part. So, this was my first messy take on how we could do it. I can refine it based on your feedback too.

From my end, the summary is this:
I think any solution needs to expose a database like index so that any app can make use of it. And then deep links so that one can take action to and from Notable. I would love your recommendations on how we can make it effective. And then I will either create a new branch altogether with what we can do or make the suggested edits to reflect clarity.

@Ethran
Copy link
Owner

Ethran commented Feb 2, 2026

If we expose some of the functionalities as deep link and simpler index instead of full database which can grow into unmanageable sizes when there are too many notebooks, too many strokes;

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.

@Ethran
Copy link
Owner

Ethran commented Feb 3, 2026

@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:

  • Deep links for opening the notebooks, pages, etc, but not for creating, modifying existing structure.
  • separate PR implementing ContentProvider (or whatever is better for this) for modifications, querying and creations.
    I don't like the json index idea, if I understood it correctly. You meant this index to be updated overtime db changes, so other apps can discover new notebooks, pages, etc?

@dragarok
Copy link
Author

dragarok commented Feb 9, 2026

@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:

* Deep links for opening the notebooks, pages, etc, but **not** for creating, modifying existing structure.

* separate PR implementing ContentProvider (or whatever is better for this) for modifications, querying and creations.
  I don't like the json index idea, if I understood it correctly. You meant this index to be updated overtime db changes, so other apps can discover new notebooks, pages, etc?

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.
I too will research on how to use content provider or similar logic for modifications. The usefulness of index file comes from being able to use it in devices that don't have notable as well, very simple devices like a kindle.
I too can research on how Obsidian plugins work for note creation etc. as well.
I've been busy this week. I will share a screenshare of how I use it as well so that we can implement something meaningful with a proper structure too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants