Add reMarkable browser modal with import and conversion options

The browser modal allows users to navigate their reMarkable file structure, view documents and folders, and perform
actions on individual files. Users can either import documents directly or convert handwriting to Markdown during
import.

The implementation includes:
- New ribbon icon and command to open the browser
- Modal UI with navigation controls (up/refresh)
- Document listing with folder traversal
- Per-file actions: Import and Handwriting to Markdown
- Proper error handling and user feedback
- Comprehensive tests for the new functionality

The downloader was also enhanced to support the new conversion options through a more flexible interface.
This commit is contained in:
2026-05-31 17:41:40 +02:00
parent 2f14e8c1fa
commit 07f4c27d58
9 changed files with 372 additions and 29 deletions
+60
View File
@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ButtonComponent } from "obsidian";
import { RemarkableBrowserModal } from "../src/ui/browser-modal";
import { RmapiNode } from "../src/types";
function createDocument(name: string): RmapiNode {
return {
id: `id-${name}`,
name,
type: "DocumentType",
version: 1,
modifiedClient: "2026-05-31T10:00:00Z",
currentPage: 0,
parent: "",
tags: [],
starred: false,
};
}
function createPlugin(nodes: RmapiNode[]) {
const calls: any[] = [];
const plugin: any = {
app: {},
rmapi: {
list: async () => nodes,
},
downloader: {
downloadDocument: async (...args: any[]) => {
calls.push(args);
},
},
};
return { calls, plugin };
}
test("RemarkableBrowserModal creates import and handwriting buttons for documents", async () => {
ButtonComponent.instances = [];
const doc = createDocument("Meeting Notes");
const { calls, plugin } = createPlugin([doc]);
const modal = new RemarkableBrowserModal(plugin);
modal.onOpen();
await new Promise((resolve) => setImmediate(resolve));
const importButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Import");
const markdownButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Handwriting to Markdown");
assert.ok(importButton);
assert.ok(markdownButton);
await importButton.click();
await markdownButton.click();
assert.deepEqual(calls, [
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: false, forceDownload: true }],
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: true, forceDownload: true }],
]);
});
+36
View File
@@ -105,6 +105,7 @@ test("downloadDocument skips documents with matching remote version and modified
id: doc.id,
remoteVersion: doc.version,
remoteModified: doc.modifiedClient,
hasMd: true,
},
});
const downloader = new DocumentDownloader(plugin);
@@ -118,6 +119,41 @@ test("downloadDocument skips documents with matching remote version and modified
assert.deepEqual(calls.trackDocument, []);
});
test("downloadDocument import action can skip handwriting OCR", async () => {
const { calls, plugin } = await createPluginFixture();
plugin.settings.enableHandwritingMd = true;
const downloader = new DocumentDownloader(plugin);
await downloader.downloadDocument(
{
node: createDocument(),
remotePath: "/Folder A/Note?1",
},
{ convertToMd: false },
);
assert.equal(calls.downloadFile.length, 1);
assert.deepEqual(calls.processDocument, []);
assert.deepEqual(calls.trackDocument.at(-1), [createDocument(), "remarkable/Folder A/Note_1.rm", true, false]);
});
test("downloadDocument markdown action runs OCR even when automatic OCR setting is disabled", async () => {
const { calls, plugin } = await createPluginFixture();
plugin.settings.enableHandwritingMd = false;
const downloader = new DocumentDownloader(plugin);
await downloader.downloadDocument(
{
node: createDocument(),
remotePath: "/Folder A/Note?1",
},
{ convertToMd: true },
);
assert.deepEqual(calls.processDocument, [["remarkable/Folder A/Note_1.rm", "remarkable/Folder A/Note_1.md"]]);
assert.deepEqual(calls.trackDocument.at(-1), [createDocument(), "remarkable/Folder A/Note_1.rm", true, true]);
});
test("syncAll propagates download failures after showing a failure notice", async () => {
const { plugin } = await createPluginFixture();
const downloader = new DocumentDownloader(plugin);
+75
View File
@@ -42,6 +42,50 @@ export class PluginSettingTab {
}
}
export class Modal {
app: any;
contentEl = createElement();
constructor(app: any) {
this.app = app;
}
open(): void {
this.onOpen();
}
close(): void {
this.onClose();
}
onOpen(): void {}
onClose(): void {}
}
export class ButtonComponent {
static instances: ButtonComponent[] = [];
buttonEl = createElement();
private callback: (() => void | Promise<void>) | null = null;
constructor(_containerEl: any) {
ButtonComponent.instances.push(this);
}
setButtonText(text: string): this {
this.buttonEl.text = text;
return this;
}
onClick(callback: () => void | Promise<void>): this {
this.callback = callback;
return this;
}
click(): void | Promise<void> {
return this.callback?.();
}
}
export class Setting {
constructor(_containerEl: any) {}
@@ -72,6 +116,37 @@ export class Setting {
}
}
function createElement(): any {
const element: any = {
children: [],
text: "",
empty: () => {
element.children = [];
},
createEl: (_tag: string, options: any = {}) => {
const child = createElement();
child.text = options.text || "";
child.cls = options.cls || "";
element.children.push(child);
return child;
},
createDiv: (options: any = {}) => {
const child = createElement();
child.cls = options.cls || "";
element.children.push(child);
return child;
},
createSpan: (options: any = {}) => {
const child = createElement();
child.text = options.text || "";
child.cls = options.cls || "";
element.children.push(child);
return child;
},
};
return element;
}
function createControl(): any {
const control: any = {
inputEl: {},