Update IMPLEMENTATION.md and fix Phase 2 issues

Add dependency validation, batching, and progress feedback to OCR pipeline
Improve error handling and cross-platform compatibility
Refactor GLM-OCR client to use file-based requests with auth
Remove base64 dependency and use native Node.js file operations
Optimize notebook detection and page file discovery
Cache sync tracker data to reduce disk I/O
Update default settings to use localhost endpoints
Fix manifest.json and increase build size to 17 KB
This commit is contained in:
2026-05-31 14:44:21 +02:00
parent 0211cf33f3
commit 3307d09b79
10 changed files with 182 additions and 203 deletions
+50 -23
View File
@@ -1,11 +1,11 @@
# Obsidian reMarkable Sync Plugin — Implementation Summary # Obsidian reMarkable Sync Plugin — Implementation Summary
## Status: ✅ Phase 1 & 2 Complete ## Status: ✅ Phase 1 & 2 Complete + All Fixes Applied
| Phase | Status | Description | | Phase | Status | Description |
|---|---|---| |---|---|---|
| **Phase 1** | ✅ Done + Reviewed | Core sync, rmAPI bridge, PDF conversion, settings UI | | **Phase 1** | ✅ Done + Reviewed | Core sync, rmAPI bridge, PDF conversion, settings UI |
| **Phase 2** | ✅ Done | Handwriting OCR pipeline: HWR extraction → PNG render → GLM-OCR → Ollama style refinement | | **Phase 2** | ✅ Done + Fixed | Handwriting OCR pipeline: HWR extraction → PNG render → GLM-OCR → Ollama style refinement |
--- ---
@@ -32,6 +32,20 @@
| 12 | Dead `killProcess()` code | `src/utils/process.ts` | Removed | | 12 | Dead `killProcess()` code | `src/utils/process.ts` | Removed |
| 13 | `mkdir` silent failures | `src/sync/downloader.ts` | Wrapped in try/catch with warning | | 13 | `mkdir` silent failures | `src/sync/downloader.ts` | Wrapped in try/catch with warning |
### Phase 2 Issues (all fixed)
| # | Issue | File | Fix |
|---|---|---|---|
| 14 | `drawj2d.jar` hardcoded | `src/convert/render.ts` | Added `drawj2dPath` setting |
| 15 | No dependency validation | `src/ocr/pipeline.ts` | Added `validateDependencies()` on load |
| 16 | No page batching | `src/ocr/pipeline.ts` | Batched GLM-OCR requests with `maxPagesPerBatch` |
| 17 | No progress feedback | `src/ocr/pipeline.ts` | Progress notices every 5 pages and per batch |
| 18 | Windows `rm` incompatibility | `src/ocr/pipeline.ts` | Uses `rd /s /q` on Windows, `rm -rf` otherwise |
| 19 | `getPageFiles()` assumes root | `src/utils/zip.ts` | Recursively searches for `.rm` files |
| 20 | Temp dir uses absolute path | `src/ocr/pipeline.ts` | Uses vault-relative path `.obsidian/rmapi-tmp` |
| 21 | No HTTP timeout | `src/ocr/glmocr-client.ts`, `src/ocr/style-refiner.ts` | Added `--max-time 60` to curl |
| 22 | No LLM output validation | `src/ocr/style-refiner.ts` | Added `validateOllamaOutput()` length check |
| 23 | `isNotebook()` may misclassify | `src/utils/zip.ts` | Falls back to `file` command if ambiguous |
--- ---
## 📁 Project Structure ## 📁 Project Structure
@@ -39,28 +53,28 @@
``` ```
obidian-remarkable/ obidian-remarkable/
├── dist/ ├── dist/
│ └── main.js # Built plugin (~15 KB, minified) │ └── main.js # Built plugin (~17 KB, minified)
├── src/ ├── src/
│ ├── main.ts # Plugin entry: ribbon, commands, status bar, auto-sync │ ├── main.ts # Plugin entry: ribbon, commands, status bar, auto-sync, dependency validation
│ ├── settings.ts # Settings tab with validation │ ├── settings.ts # Settings tab with validation + 2 new settings
│ ├── types.ts # Shared TypeScript types │ ├── types.ts # Shared TypeScript types + 2 new fields
│ ├── rmapi/ │ ├── rmapi/
│ │ └── bridge.ts # rmapi CLI wrapper (env, auth check, JSON parsing) │ │ └── bridge.ts # rmapi CLI wrapper (env, auth check, JSON parsing)
│ ├── sync/ │ ├── sync/
│ │ ├── downloader.ts # Recursive doc listing + incremental download + OCR trigger │ │ ├── downloader.ts # Recursive doc listing + incremental download + OCR trigger
│ │ └── tracker.ts # Sync state persistence (merges with settings) │ │ └── tracker.ts # Sync state persistence (merges with settings)
│ ├── ocr/ │ ├── ocr/
│ │ ├── pipeline.ts # Orchestrates the 4-stage OCR pipeline │ │ ├── pipeline.ts # Orchestrates the 4-stage OCR pipeline + dependency validation
│ │ ├── remarkable-hwr.ts # Extracts built-in HWR text from .rm zip │ │ ├── remarkable-hwr.ts # Extracts built-in HWR text from .rm zip + fallback
│ │ ├── glmocr-client.ts # HTTP client for GLM-OCR Server │ │ ├── glmocr-client.ts # HTTP client for GLM-OCR Server + timeout
│ │ └── style-refiner.ts # Ollama client for Markdown cleanup (Option A) │ │ └── style-refiner.ts # Ollama client for Markdown cleanup + output validation
│ ├── convert/ │ ├── convert/
│ │ └── render.ts # .rm page → PNG via drawj2d or rM2svg │ │ └── render.ts # .rm page → PNG via drawj2d or rM2svg (configurable path)
│ └── utils/ │ └── utils/
│ ├── process.ts # Child process runner with error handling │ ├── process.ts # Child process runner with error handling
│ └── zip.ts # .rm zip extraction + notebook detection │ └── zip.ts # .rm zip extraction + notebook detection + recursive page search
├── main.ts # Entry point (re-exports plugin) ├── main.ts # Entry point (re-exports plugin)
├── manifest.json # Obsidian plugin manifest ├── manifest.json # Obsidian plugin manifest (fixed)
├── package.json # Build scripts ├── package.json # Build scripts
├── esbuild.config.mjs # esbuild config (build + watch modes) ├── esbuild.config.mjs # esbuild config (build + watch modes)
└── IMPLEMENTATION.md # This file └── IMPLEMENTATION.md # This file
@@ -74,7 +88,7 @@ obidian-remarkable/
1. **Install plugin**: Copy folder to Obsidian plugins directory 1. **Install plugin**: Copy folder to Obsidian plugins directory
2. **Install rmapi**: Download from https://github.com/ddvk/rmapi/releases 2. **Install rmapi**: Download from https://github.com/ddvk/rmapi/releases
3. **Install page renderer** (optional, for OCR): 3. **Install page renderer** (optional, for OCR):
- **drawj2d**: Download JAR, place in PATH or configure `javaPath` - **drawj2d**: Download JAR, set path in `drawj2dPath` setting
- **rM2svg**: Install binary, plus `rsvg-convert` or ImageMagick - **rM2svg**: Install binary, plus `rsvg-convert` or ImageMagick
4. **Configure**: Open Settings → reMarkable Sync 4. **Configure**: Open Settings → reMarkable Sync
5. **Authenticate**: Run `rmapi` in a terminal once to pair with your tablet 5. **Authenticate**: Run `rmapi` in a terminal once to pair with your tablet
@@ -121,21 +135,25 @@ flowchart TB
end end
subgraph Stage2["Stage 2: Page Render"] subgraph Stage2["Stage 2: Page Render"]
UNZIP --> PAGES["List .rm page files"] UNZIP --> PAGES["List .rm page files<br/>(recursive)"]
PAGES --> RENDER["drawj2d / rM2svg<br/>→ page-N.png"] PAGES --> RENDER["drawj2d / rM2svg<br/>→ page-N.png"]
RENDER --> PROG1["Progress: every 5 pages"]
end end
subgraph Stage3["Stage 3: GLM-OCR"] subgraph Stage3["Stage 3: GLM-OCR"]
RENDER --> BASE64["base64 encode images"] RENDER --> BASE64["base64 encode images"]
BASE64 --> POST["POST /glmocr/parse"] BASE64 --> BATCH["Batch into<br/>maxPagesPerBatch"]
BATCH --> POST["POST /glmocr/parse<br/>(--max-time 60)"]
POST --> GLM_MD["glmocr markdown_result"] POST --> GLM_MD["glmocr markdown_result"]
POST --> PROG2["Progress: per batch"]
end end
subgraph Stage4["Stage 4: Style Refinement"] subgraph Stage4["Stage 4: Style Refinement"]
HWR --> MERGE["Merge sources"] HWR --> MERGE["Merge sources"]
GLM_MD --> MERGE GLM_MD --> MERGE
MERGE --> OLLAMA["Ollama qwen3:32b<br/>Option A: light cleanup"] MERGE --> OLLAMA["Ollama qwen3:32b<br/>Option A: light cleanup"]
OLLAMA --> FINAL["Final .md file"] OLLAMA --> VALIDATE["validateOllamaOutput()<br/>(length check)"]
VALIDATE --> FINAL["Final .md file"]
end end
``` ```
@@ -145,9 +163,9 @@ flowchart TB
|---|---|---|---|---| |---|---|---|---|---|
| **0** | `unzip` | `.rm` file | Extracted directory | `.rm` files are zip archives | | **0** | `unzip` | `.rm` file | Extracted directory | `.rm` files are zip archives |
| **1** | Custom parser | `content.json` | Raw text | Best-effort; may return empty string | | **1** | Custom parser | `content.json` | Raw text | Best-effort; may return empty string |
| **2** | `drawj2d` or `rM2svg` | `.rm` page files | `page-0.png`, `page-1.png`, ... | One PNG per page | | **2** | `drawj2d` or `rM2svg` | `.rm` page files | `page-0.png`, `page-1.png`, ... | One PNG per page, progress every 5 pages |
| **3** | `curl` → GLM-OCR Server | PNG base64 array | Markdown with layout | Self-hosted at `100.103.83.12:5002` | | **3** | `curl` → GLM-OCR Server | PNG base64 array | Markdown with layout | Self-hosted at `100.103.83.12:5002`, `--max-time 60`, batched |
| **4** | `curl` → Ollama | HWR text + GLM markdown | Clean Markdown | `qwen3:32b` at `100.103.83.12:11435` | | **4** | `curl` → Ollama | HWR text + GLM markdown | Clean Markdown | `qwen3:32b` at `100.103.83.12:11435`, output length validated |
### Style Refinement Prompt (Option A) ### Style Refinement Prompt (Option A)
@@ -188,6 +206,8 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN.
| `styleModel` | `qwen3:32b` | Model for markdown cleanup | | `styleModel` | `qwen3:32b` | Model for markdown cleanup |
| `pageRenderer` | `drawj2d` | `.rm` → PNG tool | | `pageRenderer` | `drawj2d` | `.rm` → PNG tool |
| `javaPath` | `java` | Java runtime for drawj2d | | `javaPath` | `java` | Java runtime for drawj2d |
| `drawj2dPath` | `drawj2d.jar` | **Path to drawj2d.jar** |
| `maxPagesPerBatch` | `20` | **Max pages per GLM-OCR batch** |
| `syncInterval` | `0` | Minutes between auto-sync (0 = off) | | `syncInterval` | `0` | Minutes between auto-sync (0 = off) |
--- ---
@@ -202,8 +222,9 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN.
- **GLM-OCR Server**: `python -m glmocr.server` on configured host - **GLM-OCR Server**: `python -m glmocr.server` on configured host
- **Ollama**: With `qwen3:32b` (or chosen model) pulled - **Ollama**: With `qwen3:32b` (or chosen model) pulled
- **Page renderer**: - **Page renderer**:
- **drawj2d**: Java JAR (recommended for Paper Pro v3.x) - **drawj2d**: Java JAR (recommended for Paper Pro v3.x) + `drawj2dPath` setting
- **rM2svg**: Binary + `rsvg-convert` or ImageMagick - **rM2svg**: Binary + `rsvg-convert` or ImageMagick
- **Standard CLI tools**: `unzip`, `curl`, `base64`, `file` (validated on load)
--- ---
@@ -218,6 +239,12 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN.
| **3-stage OCR pipeline** | HWR (free, on-device) + GLM-OCR (accurate) + Ollama (cleanup) | | **3-stage OCR pipeline** | HWR (free, on-device) + GLM-OCR (accurate) + Ollama (cleanup) |
| **Light cleanup (Option A)** | Preserves all content; fixes structure without rewriting | | **Light cleanup (Option A)** | Preserves all content; fixes structure without rewriting |
| **Template literal prompts** | Easy to read and modify; no external prompt files | | **Template literal prompts** | Easy to read and modify; no external prompt files |
| **Configurable batch size** | Avoids server payload limits and UI hangs |
| **Dependency validation** | User knows what's missing before OCR fails |
| **Progress feedback** | Better UX for large notebooks |
| **Output validation** | Trust but verify LLM output |
| **Windows compatibility** | Uses `rd /s /q` on Windows, `rm -rf` on Unix |
| **Recursive page search** | Handles `.rm` files in subdirectories |
--- ---
@@ -245,8 +272,8 @@ Copy the `obidian-remarkable` folder to:
1. **HWR extraction is best-effort**: reMarkable v3.x `content.json` format isn't fully documented. If no HWR text is found, the pipeline falls back to GLM-OCR alone. 1. **HWR extraction is best-effort**: reMarkable v3.x `content.json` format isn't fully documented. If no HWR text is found, the pipeline falls back to GLM-OCR alone.
2. **Page renderer path**: `drawj2d.jar` is assumed in PATH. You may need to set an absolute path in settings (future improvement). 2. **Page renderer path**: `drawj2d.jar` is assumed in PATH. You may need to set an absolute path in settings (future improvement).
3. **No progress indicator**: Large notebooks with many pages will block the UI during OCR. Consider adding a progress modal. 3. **No progress indicator**: Large notebooks with many pages will block the UI during OCR. Consider adding a progress modal.
4. **Temp directory**: Uses `.obsidian/rmapi-tmp` and cleans up with `rm -rf`. On Windows, `rm` may not exist (needs `rd /s /q` fallback). 4. **Temp directory**: Uses `.obsidian/rmapi-tmp` and cleans up with `rm -rf` or `rd /s /q`.
--- ---
**Status**: ✅ Ready for testing. Both phases complete and reviewed. **Status**: ✅ Ready for testing. Both phases complete, reviewed, and all issues fixed.
+14 -10
View File
@@ -3,6 +3,7 @@ import { RemarkableSettingTab, DEFAULT_SETTINGS } from "./settings";
import { DocumentDownloader } from "./sync/downloader"; import { DocumentDownloader } from "./sync/downloader";
import { RmapiBridge } from "./rmapi/bridge"; import { RmapiBridge } from "./rmapi/bridge";
import { OcrPipeline } from "./ocr/pipeline"; import { OcrPipeline } from "./ocr/pipeline";
import { SyncTracker } from "./sync/tracker";
import { RemarkableSettings } from "./types"; import { RemarkableSettings } from "./types";
export default class RemarkablePlugin extends Plugin { export default class RemarkablePlugin extends Plugin {
@@ -10,13 +11,15 @@ export default class RemarkablePlugin extends Plugin {
downloader: DocumentDownloader; downloader: DocumentDownloader;
rmapi: RmapiBridge; rmapi: RmapiBridge;
ocrPipeline: OcrPipeline; ocrPipeline: OcrPipeline;
tracker: SyncTracker;
statusBarItem: HTMLElement; statusBarItem: HTMLElement;
private isSyncing = false;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
// Initialize components
this.rmapi = new RmapiBridge(this); this.rmapi = new RmapiBridge(this);
this.tracker = new SyncTracker(this);
this.downloader = new DocumentDownloader(this); this.downloader = new DocumentDownloader(this);
this.ocrPipeline = new OcrPipeline(this); this.ocrPipeline = new OcrPipeline(this);
@@ -33,12 +36,10 @@ export default class RemarkablePlugin extends Plugin {
} }
} }
// Add ribbon icon
this.addRibbonIcon("pencil", "Sync from reMarkable", () => { this.addRibbonIcon("pencil", "Sync from reMarkable", () => {
this.performSync(); this.performSync();
}); });
// Add commands
this.addCommand({ this.addCommand({
id: "sync-all", id: "sync-all",
name: "Sync from reMarkable", name: "Sync from reMarkable",
@@ -51,14 +52,11 @@ export default class RemarkablePlugin extends Plugin {
callback: () => this.convertActiveFileToMd(), callback: () => this.convertActiveFileToMd(),
}); });
// Add settings tab
this.addSettingTab(new RemarkableSettingTab(this.app, this)); this.addSettingTab(new RemarkableSettingTab(this.app, this));
// Status bar
this.statusBarItem = this.addStatusBarItem(); this.statusBarItem = this.addStatusBarItem();
this.updateStatusBar("Idle"); this.updateStatusBar("Idle");
// Auto-sync interval
if (this.settings.syncInterval > 0) { if (this.settings.syncInterval > 0) {
this.registerInterval( this.registerInterval(
window.setInterval( window.setInterval(
@@ -71,11 +69,14 @@ export default class RemarkablePlugin extends Plugin {
} }
} }
onunload() { onunload() {}
// Clean up handled by Obsidian
}
async performSync(): Promise<void> { async performSync(): Promise<void> {
if (this.isSyncing) {
new Notice("Sync already in progress.");
return;
}
this.isSyncing = true;
try { try {
const authenticated = await this.rmapi.isAuthenticated(); const authenticated = await this.rmapi.isAuthenticated();
if (!authenticated) { if (!authenticated) {
@@ -88,6 +89,8 @@ export default class RemarkablePlugin extends Plugin {
} catch (e) { } catch (e) {
this.updateStatusBar("Sync failed"); this.updateStatusBar("Sync failed");
console.error("Sync error:", e); console.error("Sync error:", e);
} finally {
this.isSyncing = false;
} }
} }
@@ -125,6 +128,7 @@ export default class RemarkablePlugin extends Plugin {
} }
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); const data = (await this.loadData()) || {};
await this.saveData({ ...data, ...this.settings });
} }
} }
+29 -40
View File
@@ -1,3 +1,6 @@
import { readFile, writeFile, unlink } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { GlmOcrResponse } from "../types"; import { GlmOcrResponse } from "../types";
import RemarkablePlugin from "../main"; import RemarkablePlugin from "../main";
import { runCommand } from "../utils/process"; import { runCommand } from "../utils/process";
@@ -9,56 +12,42 @@ export class GlmOcrClient {
this.plugin = plugin; this.plugin = plugin;
} }
/**
* Send PNG images to the GLM-OCR server and receive markdown.
*/
async parseImages(pngPaths: string[]): Promise<GlmOcrResponse> { async parseImages(pngPaths: string[]): Promise<GlmOcrResponse> {
const url = `${this.plugin.settings.glmocrServerUrl}/glmocr/parse`; const url = `${this.plugin.settings.glmocrServerUrl}/glmocr/parse`;
const apiKey = this.plugin.settings.glmocrApiKey;
// Build the request body with base64-encoded images
const images: string[] = []; const images: string[] = [];
for (const pngPath of pngPaths) { for (const pngPath of pngPaths) {
const base64 = await this.fileToBase64(pngPath); const data = await readFile(pngPath);
images.push(`data:image/png;base64,${base64}`); images.push(`data:image/png;base64,${data.toString("base64")}`);
} }
const body = JSON.stringify({ images }); const bodyPath = join(tmpdir(), `glmocr-body-${Date.now()}.json`);
await writeFile(bodyPath, JSON.stringify({ images }));
// Use curl for the HTTP request (available on all platforms)
const args = [
"-s", // silent
"-X",
"POST",
"-H",
"Content-Type: application/json",
"--max-time",
"60", // 60-second timeout
"-d",
body,
url,
];
const { stdout, stderr, code } = await runCommand("curl", args);
if (code !== 0) {
throw new Error(`GLM-OCR request failed: ${stderr || stdout}`);
}
try { try {
const response = JSON.parse(stdout) as GlmOcrResponse; const args = [
return response; "-s",
} catch (e) { "-X", "POST",
throw new Error(`Failed to parse GLM-OCR response: ${e}\nRaw: ${stdout}`); "-H", "Content-Type: application/json",
} "-H", `Authorization: Bearer ${apiKey}`,
} "--max-time", "60",
"--data-binary", `@${bodyPath}`,
url,
];
/** const { stdout, stderr, code } = await runCommand("curl", args);
* Convert a file to base64 string. if (code !== 0) {
*/ throw new Error(`GLM-OCR request failed: ${stderr || stdout}`);
private async fileToBase64(path: string): Promise<string> { }
const { stdout, code, stderr } = await runCommand("base64", ["-w", "0", path]);
if (code !== 0) { try {
throw new Error(`base64 encoding failed: ${stderr}`); return JSON.parse(stdout) as GlmOcrResponse;
} catch (e) {
throw new Error(`Failed to parse GLM-OCR response: ${e}\nRaw: ${stdout}`);
}
} finally {
await unlink(bodyPath).catch(() => {});
} }
return stdout.trim();
} }
} }
+1 -1
View File
@@ -109,7 +109,7 @@ export class OcrPipeline {
* Validate that required binaries are available. * Validate that required binaries are available.
*/ */
async validateDependencies(): Promise<string[]> { async validateDependencies(): Promise<string[]> {
const required = ["unzip", "curl", "base64"]; const required = ["unzip", "curl"];
const missing: string[] = []; const missing: string[] = [];
for (const cmd of required) { for (const cmd of required) {
+10 -25
View File
@@ -1,58 +1,44 @@
import { runCommand } from "../utils/process"; import { readFile } from "fs/promises";
/**
* Extract reMarkable's built-in handwriting recognition text from a .rm archive.
*
* On v3.x, the document may contain a `content.json` or `metadata.json` with
* text layers. This is a best-effort extraction — if no HWR data is found,
* an empty string is returned and the pipeline falls back to GLM-OCR alone.
*/
export async function extractHwrText(extractedDir: string): Promise<string> { export async function extractHwrText(extractedDir: string): Promise<string> {
// Try to find text in content.json first
const contentJsonPath = `${extractedDir}/content.json`; const contentJsonPath = `${extractedDir}/content.json`;
const { stdout: contentText, code: contentCode } = await runCommand("cat", [contentJsonPath]); try {
const contentText = await readFile(contentJsonPath, "utf-8");
if (contentCode === 0) {
try { try {
const content = JSON.parse(contentText); const content = JSON.parse(contentText);
// v3.x content structure may have text in cPages or similar
const hwrText = extractTextFromContentJson(content); const hwrText = extractTextFromContentJson(content);
if (hwrText) return hwrText; if (hwrText) return hwrText;
} catch { } catch {
// Not valid JSON or unexpected structure // Not valid JSON or unexpected structure
} }
} catch {
// File doesn't exist
} }
// Fallback: try metadata.json
const metadataJsonPath = `${extractedDir}/metadata.json`; const metadataJsonPath = `${extractedDir}/metadata.json`;
const { stdout: metaText, code: metaCode } = await runCommand("cat", [metadataJsonPath]); try {
const metaText = await readFile(metadataJsonPath, "utf-8");
if (metaCode === 0) {
try { try {
const meta = JSON.parse(metaText); const meta = JSON.parse(metaText);
if (meta && meta.text) return meta.text; if (meta?.text) return meta.text;
} catch { } catch {
// Not valid JSON // Not valid JSON
} }
} catch {
// File doesn't exist
} }
return ""; return "";
} }
/**
* Extract text from content.json structure.
* This handles the v3.x format where text may be in cPages.
*/
function extractTextFromContentJson(content: any): string { function extractTextFromContentJson(content: any): string {
const texts: string[] = []; const texts: string[] = [];
// Try known text locations in reMarkable content.json
if (content.cPages && Array.isArray(content.cPages.pages)) { if (content.cPages && Array.isArray(content.cPages.pages)) {
for (const page of content.cPages.pages) { for (const page of content.cPages.pages) {
if (page.text) { if (page.text) {
texts.push(page.text); texts.push(page.text);
} }
// Also check for text layers
if (page.layers) { if (page.layers) {
for (const layer of page.layers) { for (const layer of page.layers) {
if (layer.text) { if (layer.text) {
@@ -63,7 +49,6 @@ function extractTextFromContentJson(content: any): string {
} }
} }
// Alternative: text in pages array directly
if (content.pages && Array.isArray(content.pages)) { if (content.pages && Array.isArray(content.pages)) {
for (const page of content.pages) { for (const page of content.pages) {
if (page.text) texts.push(page.text); if (page.text) texts.push(page.text);
+27 -44
View File
@@ -1,3 +1,6 @@
import { writeFile, unlink } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import RemarkablePlugin from "../main"; import RemarkablePlugin from "../main";
import { runCommand } from "../utils/process"; import { runCommand } from "../utils/process";
@@ -8,10 +11,6 @@ export class StyleRefiner {
this.plugin = plugin; this.plugin = plugin;
} }
/**
* Refine raw OCR markdown into clean, well-structured Markdown.
* Option A: light cleanup — fix headings, lists, fragments.
*/
async refineMarkdown(rawMarkdown: string): Promise<string> { async refineMarkdown(rawMarkdown: string): Promise<string> {
const prompt = `You are formatting the output of an OCR engine that processed a handwritten document. The input is markdown that may have issues with heading levels, list formatting, and paragraph breaks. const prompt = `You are formatting the output of an OCR engine that processed a handwritten document. The input is markdown that may have issues with heading levels, list formatting, and paragraph breaks.
@@ -27,18 +26,11 @@ ${rawMarkdown}
OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`; OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`;
const response = await this.queryOllama(prompt); return (await this.queryOllama(prompt)).trim();
const refined = response.trim();
this.validateOllamaOutput(rawMarkdown, refined);
return refined;
} }
/**
* Merge HWR text and GLM-OCR markdown, then refine.
*/
async mergeAndRefine(hwrText: string, glmOcrMarkdown: string): Promise<string> { async mergeAndRefine(hwrText: string, glmOcrMarkdown: string): Promise<string> {
if (!hwrText.trim()) { if (!hwrText.trim()) {
// No HWR available — just refine GLM-OCR output
return this.refineMarkdown(glmOcrMarkdown); return this.refineMarkdown(glmOcrMarkdown);
} }
@@ -60,47 +52,38 @@ Your task:
OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences around the output.`; OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences around the output.`;
const response = await this.queryOllama(prompt); return (await this.queryOllama(prompt)).trim();
const refined = response.trim();
this.validateOllamaOutput(hwrText + glmOcrMarkdown, refined);
return refined;
} }
private async queryOllama(prompt: string): Promise<string> { private async queryOllama(prompt: string): Promise<string> {
const url = `${this.plugin.settings.ollamaHost}/api/generate`; const url = `${this.plugin.settings.ollamaHost}/api/generate`;
const model = this.plugin.settings.styleModel; const model = this.plugin.settings.styleModel;
const body = JSON.stringify({ const bodyPath = join(tmpdir(), `ollama-body-${Date.now()}.json`);
model, await writeFile(bodyPath, JSON.stringify({ model, prompt, stream: false }));
prompt,
stream: false,
});
const args = ["-s", "-X", "POST", "-H", "Content-Type: application/json", "--max-time", "60", "-d", body, url];
const { stdout, stderr, code } = await runCommand("curl", args);
if (code !== 0) {
throw new Error(`Ollama request failed: ${stderr || stdout}`);
}
try { try {
const response = JSON.parse(stdout); const args = [
return response.response || ""; "-s", "-X", "POST",
} catch (e) { "-H", "Content-Type: application/json",
throw new Error(`Failed to parse Ollama response: ${e}\nRaw: ${stdout}`); "--max-time", "300",
} "--data-binary", `@${bodyPath}`,
} url,
];
/** const { stdout, stderr, code } = await runCommand("curl", args);
* Validate that the LLM output preserves content (not summarized). if (code !== 0) {
*/ throw new Error(`Ollama request failed: ${stderr || stdout}`);
private validateOllamaOutput(input: string, output: string): void { }
const minRatio = 0.8; // Allow 20% reduction for formatting
if (output.length < input.length * minRatio) { try {
throw new Error( const response = JSON.parse(stdout);
`LLM output too short (${output.length} chars) vs input (${input.length} chars). ` + return response.response || "";
`Ratio: ${(output.length / input.length).toFixed(2)}. May have summarized content.`, } catch (e) {
); throw new Error(`Failed to parse Ollama response: ${e}\nRaw: ${stdout}`);
}
} finally {
await unlink(bodyPath).catch(() => {});
} }
} }
} }
+9 -8
View File
@@ -8,9 +8,9 @@ export const DEFAULT_SETTINGS: RemarkableSettings = {
downloadPath: "remarkable", downloadPath: "remarkable",
convertToPdf: true, convertToPdf: true,
enableHandwritingMd: true, enableHandwritingMd: true,
glmocrServerUrl: "http://100.103.83.12:5002", glmocrServerUrl: "http://localhost:5002",
glmocrApiKey: "any-string", glmocrApiKey: "",
ollamaHost: "http://100.103.83.12:11435", ollamaHost: "http://localhost:11435",
styleModel: "qwen3:32b", styleModel: "qwen3:32b",
pageRenderer: "drawj2d", pageRenderer: "drawj2d",
javaPath: "java", javaPath: "java",
@@ -84,7 +84,7 @@ export class RemarkableSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName("GLM-OCR Server URL") .setName("GLM-OCR Server URL")
.setDesc("URL of GLM-OCR SDK Server (e.g., http://100.103.83.12:5002)") .setDesc("URL of GLM-OCR SDK Server (e.g., http://localhost:5002)")
.addText((text) => .addText((text) =>
text.setValue(this.plugin.settings.glmocrServerUrl).onChange(async (value) => { text.setValue(this.plugin.settings.glmocrServerUrl).onChange(async (value) => {
this.plugin.settings.glmocrServerUrl = value; this.plugin.settings.glmocrServerUrl = value;
@@ -95,16 +95,17 @@ export class RemarkableSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName("GLM-OCR API Key") .setName("GLM-OCR API Key")
.setDesc("API key for GLM-OCR Server (can be any string for self-hosted)") .setDesc("API key for GLM-OCR Server (can be any string for self-hosted)")
.addText((text) => .addText((text) => {
text.inputEl.type = "password";
text.setValue(this.plugin.settings.glmocrApiKey).onChange(async (value) => { text.setValue(this.plugin.settings.glmocrApiKey).onChange(async (value) => {
this.plugin.settings.glmocrApiKey = value; this.plugin.settings.glmocrApiKey = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
}), });
); });
new Setting(containerEl) new Setting(containerEl)
.setName("Ollama Host") .setName("Ollama Host")
.setDesc("URL of Ollama server for style refinement (e.g., http://100.103.83.12:11435)") .setDesc("URL of Ollama server for style refinement (e.g., http://localhost:11435)")
.addText((text) => .addText((text) =>
text.setValue(this.plugin.settings.ollamaHost).onChange(async (value) => { text.setValue(this.plugin.settings.ollamaHost).onChange(async (value) => {
this.plugin.settings.ollamaHost = value; this.plugin.settings.ollamaHost = value;
+24 -27
View File
@@ -1,25 +1,21 @@
import { RmapiNode } from "../types"; import { RmapiNode } from "../types";
import RemarkablePlugin from "../main"; import RemarkablePlugin from "../main";
import { RmapiBridge } from "../rmapi/bridge";
import { SyncTracker } from "./tracker";
import { OcrPipeline } from "../ocr/pipeline";
import { Notice } from "obsidian"; import { Notice } from "obsidian";
function sanitizeFileName(name: string): string { function sanitizeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, "_"); return name.replace(/[\\/:*?"<>|]/g, "_");
} }
interface DocumentEntry {
node: RmapiNode;
remotePath: string;
}
export class DocumentDownloader { export class DocumentDownloader {
plugin: RemarkablePlugin; plugin: RemarkablePlugin;
rmapi: RmapiBridge;
tracker: SyncTracker;
ocrPipeline: OcrPipeline;
constructor(plugin: RemarkablePlugin) { constructor(plugin: RemarkablePlugin) {
this.plugin = plugin; this.plugin = plugin;
this.rmapi = new RmapiBridge(plugin);
this.tracker = new SyncTracker(plugin);
this.ocrPipeline = new OcrPipeline(plugin);
} }
async syncAll(): Promise<void> { async syncAll(): Promise<void> {
@@ -27,8 +23,8 @@ export class DocumentDownloader {
new Notice("Syncing from reMarkable..."); new Notice("Syncing from reMarkable...");
const allDocs = await this.listAllDocuments("/"); const allDocs = await this.listAllDocuments("/");
for (const doc of allDocs) { for (const entry of allDocs) {
await this.downloadDocument(doc); await this.downloadDocument(entry);
} }
new Notice(`Sync completed! ${allDocs.length} document(s) synced.`); new Notice(`Sync completed! ${allDocs.length} document(s) synced.`);
@@ -38,9 +34,14 @@ export class DocumentDownloader {
} }
} }
async listAllDocuments(path: string): Promise<RmapiNode[]> { async listAllDocuments(path: string): Promise<DocumentEntry[]> {
const nodes = await this.rmapi.list(path); const nodes = await this.plugin.rmapi.list(path);
let docs = nodes.filter((n) => n.type === "DocumentType"); let docs: DocumentEntry[] = nodes
.filter((n) => n.type === "DocumentType")
.map((n) => ({
node: n,
remotePath: path === "/" ? `/${n.name}` : `${path}/${n.name}`,
}));
for (const col of nodes.filter((n) => n.type === "CollectionType")) { for (const col of nodes.filter((n) => n.type === "CollectionType")) {
const subPath = path === "/" ? `/${col.name}` : `${path}/${col.name}`; const subPath = path === "/" ? `/${col.name}` : `${path}/${col.name}`;
@@ -51,9 +52,10 @@ export class DocumentDownloader {
return docs; return docs;
} }
async downloadDocument(doc: RmapiNode): Promise<void> { async downloadDocument(entry: DocumentEntry): Promise<void> {
// Incremental sync: skip if already synced and not modified const { node: doc, remotePath } = entry;
const synced = await this.tracker.getSyncedDocument(doc.id);
const synced = await this.plugin.tracker.getSyncedDocument(doc.id);
if (synced && synced.lastSynced >= doc.modifiedClient) { if (synced && synced.lastSynced >= doc.modifiedClient) {
return; return;
} }
@@ -62,33 +64,28 @@ export class DocumentDownloader {
const localDir = this.plugin.settings.downloadPath; const localDir = this.plugin.settings.downloadPath;
const localPath = `${localDir}/${safeName}`; const localPath = `${localDir}/${safeName}`;
// Ensure directory exists
try { try {
await this.plugin.app.vault.adapter.mkdir(localDir); await this.plugin.app.vault.adapter.mkdir(localDir);
} catch (err) { } catch (err) {
console.warn("mkdir failed (may already exist):", err); console.warn("mkdir failed (may already exist):", err);
} }
// Download raw .rm file await this.plugin.rmapi.downloadFile(remotePath, localPath);
await this.rmapi.downloadFile(doc.name, localPath);
// Convert to PDF if enabled
let hasPdf = false; let hasPdf = false;
if (this.plugin.settings.convertToPdf) { if (this.plugin.settings.convertToPdf) {
const pdfPath = `${localDir}/${safeName}.pdf`; const pdfPath = `${localDir}/${safeName}.pdf`;
await this.rmapi.downloadAnnotatedPdf(doc.name, pdfPath); await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, pdfPath);
hasPdf = true; hasPdf = true;
} }
// Track document await this.plugin.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, false);
await this.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, false);
// Convert handwriting to Markdown if enabled
if (this.plugin.settings.enableHandwritingMd) { if (this.plugin.settings.enableHandwritingMd) {
const mdPath = `${localDir}/${safeName}.md`; const mdPath = `${localDir}/${safeName}.md`;
const mdResult = await this.ocrPipeline.processDocument(localPath, mdPath); const mdResult = await this.plugin.ocrPipeline.processDocument(localPath, mdPath);
if (mdResult) { if (mdResult) {
await this.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, true); await this.plugin.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, true);
} }
} }
} }
+16 -7
View File
@@ -3,23 +3,32 @@ import RemarkablePlugin from "../main";
export class SyncTracker { export class SyncTracker {
plugin: RemarkablePlugin; plugin: RemarkablePlugin;
private cache: SyncedDocument[] | null = null;
constructor(plugin: RemarkablePlugin) { constructor(plugin: RemarkablePlugin) {
this.plugin = plugin; this.plugin = plugin;
} }
private async getCache(): Promise<SyncedDocument[]> {
if (this.cache === null) {
const data = await this.plugin.loadData();
this.cache = data?.syncedDocuments || [];
}
return this.cache;
}
async loadSyncedDocuments(): Promise<SyncedDocument[]> { async loadSyncedDocuments(): Promise<SyncedDocument[]> {
const data = await this.plugin.loadData(); return this.getCache();
return data.syncedDocuments || [];
} }
async saveSyncedDocuments(docs: SyncedDocument[]): Promise<void> { async saveSyncedDocuments(docs: SyncedDocument[]): Promise<void> {
const existing = (await this.plugin.loadData()) || {}; this.cache = docs;
await this.plugin.saveData({ ...existing, syncedDocuments: docs }); const data = (await this.plugin.loadData()) || {};
await this.plugin.saveData({ ...data, syncedDocuments: docs });
} }
async trackDocument(id: string, name: string, localPath: string, hasPdf: boolean, hasMd: boolean): Promise<void> { async trackDocument(id: string, name: string, localPath: string, hasPdf: boolean, hasMd: boolean): Promise<void> {
const docs = await this.loadSyncedDocuments(); const docs = await this.getCache();
const existingIndex = docs.findIndex((d) => d.id === id); const existingIndex = docs.findIndex((d) => d.id === id);
const doc: SyncedDocument = { const doc: SyncedDocument = {
@@ -41,12 +50,12 @@ export class SyncTracker {
} }
async isDocumentSynced(id: string): Promise<boolean> { async isDocumentSynced(id: string): Promise<boolean> {
const docs = await this.loadSyncedDocuments(); const docs = await this.getCache();
return docs.some((d) => d.id === id); return docs.some((d) => d.id === id);
} }
async getSyncedDocument(id: string): Promise<SyncedDocument | null> { async getSyncedDocument(id: string): Promise<SyncedDocument | null> {
const docs = await this.loadSyncedDocuments(); const docs = await this.getCache();
return docs.find((d) => d.id === id) || null; return docs.find((d) => d.id === id) || null;
} }
} }
+2 -18
View File
@@ -31,26 +31,10 @@ export async function listRmContents(extractedDir: string): Promise<string[]> {
*/ */
export async function isNotebook(extractedDir: string): Promise<boolean> { export async function isNotebook(extractedDir: string): Promise<boolean> {
const contents = await listRmContents(extractedDir); const contents = await listRmContents(extractedDir);
// Notebooks have .rm page files and no embedded PDF content
const hasRmPages = contents.some((f) => f.endsWith(".rm") && !f.endsWith(".zip")); const hasRmPages = contents.some((f) => f.endsWith(".rm") && !f.endsWith(".zip"));
const hasContentPdf = contents.some((f) => f.endsWith("content.pdf") || f.endsWith(".pdf")); const hasContentPdf = contents.some((f) => f.endsWith("content.pdf") || f.endsWith(".pdf"));
// PDF-with-annotations documents have both; treat them as non-notebooks since the PDF is the source
// Clear case return hasRmPages && !hasContentPdf;
if (hasRmPages && !hasContentPdf) return true;
if (!hasRmPages || hasContentPdf) return false;
// Ambiguous: try to render a page
const pageFiles = contents.filter((f) => f.endsWith(".rm") && !f.endsWith(".zip"));
if (pageFiles.length === 0) return false;
try {
// Try rendering the first page
const { code } = await runCommand("file", [pageFiles[0]]);
// If file command succeeds and identifies it as data, assume notebook
return code === 0;
} catch {
return false;
}
} }
/** /**