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:
@@ -97,6 +97,8 @@ obidian-remarkable/
|
||||
|
||||
### Daily Use
|
||||
- **Sync all**: Click ribbon pencil icon or run command "Sync from reMarkable"
|
||||
- **Browse files**: Click ribbon folder icon or run command "Browse reMarkable"
|
||||
- **Per-file actions**: In the browser, click "Import" or "Handwriting to Markdown" for each document
|
||||
- **Convert single file**: Open a `.rm` file in Obsidian, run "Convert handwriting to Markdown"
|
||||
|
||||
---
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+15
@@ -5,6 +5,7 @@ import { RmapiBridge } from "./rmapi/bridge";
|
||||
import { OcrPipeline } from "./ocr/pipeline";
|
||||
import { SyncTracker } from "./sync/tracker";
|
||||
import { RemarkableSettings } from "./types";
|
||||
import { RemarkableBrowserModal } from "./ui/browser-modal";
|
||||
|
||||
export default class RemarkablePlugin extends Plugin {
|
||||
settings: RemarkableSettings;
|
||||
@@ -40,12 +41,22 @@ export default class RemarkablePlugin extends Plugin {
|
||||
this.performSync();
|
||||
});
|
||||
|
||||
this.addRibbonIcon("folder-open", "Browse reMarkable", () => {
|
||||
this.openRemarkableBrowser();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "sync-all",
|
||||
name: "Sync from reMarkable",
|
||||
callback: () => this.performSync(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "browse-remarkable",
|
||||
name: "Browse reMarkable",
|
||||
callback: () => this.openRemarkableBrowser(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "convert-handwriting-md",
|
||||
name: "Convert handwriting to Markdown",
|
||||
@@ -119,6 +130,10 @@ export default class RemarkablePlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
openRemarkableBrowser(): void {
|
||||
new RemarkableBrowserModal(this).open();
|
||||
}
|
||||
|
||||
updateStatusBar(text: string): void {
|
||||
this.statusBarItem.setText(`reMarkable: ${text}`);
|
||||
}
|
||||
|
||||
+48
-15
@@ -11,11 +11,24 @@ function sanitizeRemotePath(remotePath: string): string[] {
|
||||
return remotePath.split("/").filter(Boolean).map(sanitizeFileName);
|
||||
}
|
||||
|
||||
interface DocumentEntry {
|
||||
export interface DocumentEntry {
|
||||
node: RmapiNode;
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
export interface DownloadDocumentOptions {
|
||||
convertToPdf?: boolean;
|
||||
convertToMd?: boolean;
|
||||
forceDownload?: boolean;
|
||||
}
|
||||
|
||||
interface LocalDocumentPaths {
|
||||
localDir: string;
|
||||
localPath: string;
|
||||
mdPath: string;
|
||||
pdfPath: string;
|
||||
}
|
||||
|
||||
export class DocumentDownloader {
|
||||
plugin: RemarkablePlugin;
|
||||
|
||||
@@ -58,38 +71,58 @@ export class DocumentDownloader {
|
||||
return docs;
|
||||
}
|
||||
|
||||
async downloadDocument(entry: DocumentEntry): Promise<void> {
|
||||
buildLocalPaths(remotePath: string): LocalDocumentPaths {
|
||||
const pathSegments = sanitizeRemotePath(remotePath);
|
||||
const safeName = pathSegments.pop() || "Untitled";
|
||||
const localDir = vaultRelativePath(this.plugin.settings.downloadPath, ...pathSegments);
|
||||
|
||||
return {
|
||||
localDir,
|
||||
localPath: vaultRelativePath(localDir, `${safeName}.rm`),
|
||||
mdPath: vaultRelativePath(localDir, `${safeName}.md`),
|
||||
pdfPath: vaultRelativePath(localDir, `${safeName}.pdf`),
|
||||
};
|
||||
}
|
||||
|
||||
async downloadDocument(entry: DocumentEntry, options: DownloadDocumentOptions = {}): Promise<void> {
|
||||
const { node: doc, remotePath } = entry;
|
||||
|
||||
const convertToPdf = options.convertToPdf ?? this.plugin.settings.convertToPdf;
|
||||
const convertToMd = options.convertToMd ?? this.plugin.settings.enableHandwritingMd;
|
||||
const synced = await this.plugin.tracker.getSyncedDocument(doc.id);
|
||||
if (synced && synced.remoteVersion === doc.version && synced.remoteModified === doc.modifiedClient) {
|
||||
const paths = this.buildLocalPaths(remotePath);
|
||||
const isUnchanged = synced?.remoteVersion === doc.version && synced?.remoteModified === doc.modifiedClient;
|
||||
const localExists = await this.plugin.app.vault.adapter.exists(paths.localPath);
|
||||
const needsDownload = options.forceDownload || !isUnchanged || !localExists;
|
||||
const needsMd = convertToMd && (!isUnchanged || !synced?.hasMd || !(await this.plugin.app.vault.adapter.exists(paths.mdPath)));
|
||||
|
||||
if (!needsDownload && !needsMd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathSegments = sanitizeRemotePath(remotePath);
|
||||
const safeName = pathSegments.pop() || sanitizeFileName(doc.name);
|
||||
const localDir = vaultRelativePath(this.plugin.settings.downloadPath, ...pathSegments);
|
||||
const localPath = vaultRelativePath(localDir, `${safeName}.rm`);
|
||||
const { localDir, localPath, mdPath, pdfPath } = paths;
|
||||
const absoluteLocalPath = vaultPathToAbsolute(this.plugin, localPath);
|
||||
|
||||
await this.ensureVaultFolder(localDir);
|
||||
|
||||
await this.plugin.rmapi.downloadFile(remotePath, absoluteLocalPath);
|
||||
if (needsDownload) {
|
||||
await this.plugin.rmapi.downloadFile(remotePath, absoluteLocalPath);
|
||||
}
|
||||
|
||||
let hasPdf = false;
|
||||
if (this.plugin.settings.convertToPdf) {
|
||||
const pdfPath = vaultRelativePath(localDir, `${safeName}.pdf`);
|
||||
let hasPdf = synced?.hasPdf ?? false;
|
||||
if (convertToPdf && needsDownload) {
|
||||
await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, vaultPathToAbsolute(this.plugin, pdfPath));
|
||||
hasPdf = true;
|
||||
}
|
||||
|
||||
await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, false);
|
||||
let hasMd = synced?.hasMd ?? false;
|
||||
await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, hasMd);
|
||||
|
||||
if (this.plugin.settings.enableHandwritingMd) {
|
||||
const mdPath = vaultRelativePath(localDir, `${safeName}.md`);
|
||||
if (needsMd) {
|
||||
const mdResult = await this.plugin.ocrPipeline.processDocument(localPath, mdPath);
|
||||
if (mdResult) {
|
||||
await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, true);
|
||||
hasMd = true;
|
||||
await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, hasMd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ButtonComponent, Modal, Notice } from "obsidian";
|
||||
import RemarkablePlugin from "../main";
|
||||
import { RmapiNode } from "../types";
|
||||
|
||||
export class RemarkableBrowserModal extends Modal {
|
||||
private plugin: RemarkablePlugin;
|
||||
private currentPath = "/";
|
||||
|
||||
constructor(plugin: RemarkablePlugin) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
private async render(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl("h2", { text: "Browse reMarkable" });
|
||||
|
||||
const toolbar = contentEl.createDiv({ cls: "remarkable-browser-toolbar" });
|
||||
toolbar.createSpan({ text: this.currentPath });
|
||||
|
||||
if (this.currentPath !== "/") {
|
||||
new ButtonComponent(toolbar)
|
||||
.setButtonText("Up")
|
||||
.onClick(() => {
|
||||
this.currentPath = this.parentPath(this.currentPath);
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
new ButtonComponent(toolbar)
|
||||
.setButtonText("Refresh")
|
||||
.onClick(() => this.render());
|
||||
|
||||
const listEl = contentEl.createDiv({ cls: "remarkable-browser-list" });
|
||||
listEl.createEl("p", { text: "Loading..." });
|
||||
|
||||
try {
|
||||
const nodes = await this.plugin.rmapi.list(this.currentPath);
|
||||
this.renderNodes(listEl, nodes);
|
||||
} catch (error) {
|
||||
listEl.empty();
|
||||
listEl.createEl("p", { text: `Failed to load reMarkable files: ${error}` });
|
||||
new Notice(`Failed to load reMarkable files: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderNodes(container: HTMLElement, nodes: RmapiNode[]): void {
|
||||
container.empty();
|
||||
|
||||
if (nodes.length === 0) {
|
||||
container.createEl("p", { text: "No documents found." });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const row = container.createDiv({ cls: "remarkable-browser-row" });
|
||||
const isCollection = node.type === "CollectionType";
|
||||
const label = isCollection ? `Folder: ${node.name}` : node.name;
|
||||
row.createSpan({ text: label });
|
||||
|
||||
if (isCollection) {
|
||||
new ButtonComponent(row)
|
||||
.setButtonText("Open")
|
||||
.onClick(() => {
|
||||
this.currentPath = this.childPath(this.currentPath, node.name);
|
||||
this.render();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type !== "DocumentType") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remotePath = this.childPath(this.currentPath, node.name);
|
||||
|
||||
new ButtonComponent(row)
|
||||
.setButtonText("Import")
|
||||
.onClick(() => this.importDocument(node, remotePath, false));
|
||||
|
||||
new ButtonComponent(row)
|
||||
.setButtonText("Handwriting to Markdown")
|
||||
.onClick(() => this.importDocument(node, remotePath, true));
|
||||
}
|
||||
}
|
||||
|
||||
private async importDocument(node: RmapiNode, remotePath: string, convertToMd: boolean): Promise<void> {
|
||||
try {
|
||||
new Notice(convertToMd ? `Importing and converting ${node.name}...` : `Importing ${node.name}...`);
|
||||
await this.plugin.downloader.downloadDocument(
|
||||
{ node, remotePath },
|
||||
{
|
||||
convertToMd,
|
||||
forceDownload: true,
|
||||
},
|
||||
);
|
||||
new Notice(convertToMd ? `Markdown created for ${node.name}` : `Imported ${node.name}`);
|
||||
} catch (error) {
|
||||
new Notice(`Import failed: ${error}`);
|
||||
console.error("reMarkable import failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private childPath(parent: string, child: string): string {
|
||||
return parent === "/" ? `/${child}` : `${parent}/${child}`;
|
||||
}
|
||||
|
||||
private parentPath(path: string): string {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
}
|
||||
}
|
||||
@@ -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 }],
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user