diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 7ab0203..5d5e03a 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -39,12 +39,12 @@ | 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 | +| 18 | Windows `rm` incompatibility | `src/ocr/pipeline.ts` | Uses Node filesystem APIs for temp cleanup | | 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` | +| 20 | Temp dir path handling | `src/ocr/pipeline.ts` | Uses an absolute filesystem path under `.obsidian/rmapi-tmp` for shell tools | | 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 | +| 23 | Unix-only recursive listing | `src/utils/zip.ts` | Uses Node recursive directory traversal instead of `find` | --- @@ -52,8 +52,9 @@ ``` obidian-remarkable/ +├── main.js # Built plugin entry loaded by Obsidian ├── dist/ -│ └── main.js # Built plugin (~17 KB, minified) +│ └── main.js # Secondary built artifact ├── src/ │ ├── main.ts # Plugin entry: ribbon, commands, status bar, auto-sync, dependency validation │ ├── settings.ts # Settings tab with validation + 2 new settings @@ -72,6 +73,7 @@ obidian-remarkable/ │ │ └── render.ts # .rm page → PNG via drawj2d or rM2svg (configurable path) │ └── utils/ │ ├── process.ts # Child process runner with error handling +│ ├── paths.ts # Vault-relative and absolute path helpers │ └── zip.ts # .rm zip extraction + notebook detection + recursive page search ├── main.ts # Entry point (re-exports plugin) ├── manifest.json # Obsidian plugin manifest (fixed) @@ -108,7 +110,7 @@ User clicks ribbon icon / auto-sync interval → ls --json / (root) → For each CollectionType: recurse into subfolder → For each DocumentType: - → Skip if lastSynced >= modifiedClient (incremental) + → Skip if tracked remoteVersion and remoteModified match (incremental) → sanitizeFileName() for safe filesystem names → mkdir downloadPath (vault-relative) → rmapi get → download .rm file @@ -164,8 +166,8 @@ flowchart TB | **0** | `unzip` | `.rm` file | Extracted directory | `.rm` files are zip archives | | **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, progress every 5 pages | -| **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`, output length validated | +| **3** | `curl` → GLM-OCR Server | PNG base64 array | Markdown with layout | Self-hosted at configured server URL, `--max-time 60`, batched | +| **4** | `curl` → Ollama | HWR text + GLM markdown | Clean Markdown | Configured Ollama host/model, output length validated | ### Style Refinement Prompt (Option A) @@ -200,9 +202,9 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN. | `downloadPath` | `remarkable/` | Vault folder for downloads | | `convertToPdf` | `true` | Auto-download annotated PDFs | | `enableHandwritingMd` | `true` | **Trigger OCR pipeline after sync** | -| `glmocrServerUrl` | `http://100.103.83.12:5002` | GLM-OCR SDK Server | -| `glmocrApiKey` | `any-string` | Dummy key for self-hosted | -| `ollamaHost` | `http://100.103.83.12:11435` | Ollama server | +| `glmocrServerUrl` | `http://localhost:5002` | GLM-OCR SDK Server | +| `glmocrApiKey` | empty | API key for GLM-OCR Server | +| `ollamaHost` | `http://localhost:11435` | Ollama server | | `styleModel` | `qwen3:32b` | Model for markdown cleanup | | `pageRenderer` | `drawj2d` | `.rm` → PNG tool | | `javaPath` | `java` | Java runtime for drawj2d | @@ -224,7 +226,7 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN. - **Page renderer**: - **drawj2d**: Java JAR (recommended for Paper Pro v3.x) + `drawj2dPath` setting - **rM2svg**: Binary + `rsvg-convert` or ImageMagick -- **Standard CLI tools**: `unzip`, `curl`, `base64`, `file` (validated on load) +- **Standard CLI tools**: `unzip`, `curl` (validated on load) --- @@ -243,7 +245,7 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN. | **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 | +| **Windows compatibility** | Uses Node filesystem APIs for temp cleanup | | **Recursive page search** | Handles `.rm` files in subdirectories | --- @@ -259,7 +261,7 @@ npm run build npm run dev # Install in Obsidian -Copy the `obidian-remarkable` folder to: +Run `npm run build`, then copy the plugin folder containing `manifest.json` and root `main.js` to: - Linux: ~/.config/obsidian/plugins/ - macOS: ~/Library/Application Support/obsidian/plugins/ - Windows: %APPDATA%\obsidian\plugins\ @@ -270,9 +272,9 @@ Copy the `obidian-remarkable` folder to: ## 🐛 Known Limitations 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). -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` or `rd /s /q`. +2. **OCR runtime dependencies**: GLM-OCR, Ollama, and a page renderer must be running/installed outside Obsidian. +3. **Progress UX**: Large notebooks use notices for progress, but there is no cancellable progress modal yet. +4. **Temp directory**: Uses an absolute filesystem path at `/.obsidian/rmapi-tmp` and cleans it up with Node filesystem APIs. --- diff --git a/dist/main.js b/dist/main.js index 765cd69..8835cce 100644 --- a/dist/main.js +++ b/dist/main.js @@ -1,8 +1,8 @@ -var M=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var W=Object.prototype.hasOwnProperty;var V=(i,t)=>{for(var e in t)M(i,e,{get:t[e],enumerable:!0})},z=(i,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of U(t))!W.call(i,a)&&a!==e&&M(i,a,{get:()=>t[a],enumerable:!(n=G(t,a))||n.enumerable});return i};var J=i=>z(M({},"__esModule",{value:!0}),i);var Y={};V(Y,{default:()=>_});module.exports=J(Y);var u=require("obsidian");var m=require("obsidian"),N={remarkableHost:"https://10.11.99.1",rmapiBinaryPath:"rmapi",downloadPath:"remarkable",convertToPdf:!0,enableHandwritingMd:!0,glmocrServerUrl:"http://100.103.83.12:5002",glmocrApiKey:"any-string",ollamaHost:"http://100.103.83.12:11435",styleModel:"qwen3:32b",pageRenderer:"drawj2d",javaPath:"java",drawj2dPath:"drawj2d.jar",maxPagesPerBatch:20,syncInterval:0},v=class extends m.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"reMarkable Sync Settings"}),new m.Setting(t).setName("reMarkable Host").setDesc("URL of your reMarkable tablet (e.g., https://10.11.99.1)").addText(e=>e.setValue(this.plugin.settings.remarkableHost).onChange(async n=>{this.plugin.settings.remarkableHost=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("rmapi Binary Path").setDesc("Path to rmapi binary (e.g., rmapi or /usr/local/bin/rmapi)").addText(e=>e.setValue(this.plugin.settings.rmapiBinaryPath).onChange(async n=>{this.plugin.settings.rmapiBinaryPath=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Download Path").setDesc("Folder in vault for downloaded documents").addText(e=>e.setValue(this.plugin.settings.downloadPath).onChange(async n=>{this.plugin.settings.downloadPath=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Convert to PDF").setDesc("Automatically convert documents to annotated PDF").addToggle(e=>e.setValue(this.plugin.settings.convertToPdf).onChange(async n=>{this.plugin.settings.convertToPdf=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Enable Handwriting to Markdown").setDesc("Convert handwritten notes to styled Markdown").addToggle(e=>e.setValue(this.plugin.settings.enableHandwritingMd).onChange(async n=>{this.plugin.settings.enableHandwritingMd=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("GLM-OCR Server URL").setDesc("URL of GLM-OCR SDK Server (e.g., http://100.103.83.12:5002)").addText(e=>e.setValue(this.plugin.settings.glmocrServerUrl).onChange(async n=>{this.plugin.settings.glmocrServerUrl=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("GLM-OCR API Key").setDesc("API key for GLM-OCR Server (can be any string for self-hosted)").addText(e=>e.setValue(this.plugin.settings.glmocrApiKey).onChange(async n=>{this.plugin.settings.glmocrApiKey=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Ollama Host").setDesc("URL of Ollama server for style refinement (e.g., http://100.103.83.12:11435)").addText(e=>e.setValue(this.plugin.settings.ollamaHost).onChange(async n=>{this.plugin.settings.ollamaHost=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Style Model").setDesc("Ollama model for Markdown style refinement").addText(e=>e.setValue(this.plugin.settings.styleModel).onChange(async n=>{this.plugin.settings.styleModel=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Page Renderer").setDesc("Tool to render .rm pages as PNG").addDropdown(e=>e.addOption("drawj2d","drawj2d (recommended)").addOption("rM2svg","rM2svg (lightweight)").setValue(this.plugin.settings.pageRenderer).onChange(async n=>{this.plugin.settings.pageRenderer=n,await this.plugin.saveSettings()})),new m.Setting(t).setName("Java Path").setDesc("Path to Java runtime (for drawj2d)").addText(e=>{e.setValue(this.plugin.settings.javaPath).onChange(async n=>{this.plugin.settings.javaPath=n,await this.plugin.saveSettings()})}),new m.Setting(t).setName("drawj2d Path").setDesc("Path to drawj2d.jar (e.g., /opt/drawj2d.jar)").addText(e=>{e.setValue(this.plugin.settings.drawj2dPath).onChange(async n=>{this.plugin.settings.drawj2dPath=n,await this.plugin.saveSettings()})}),new m.Setting(t).setName("Max Pages per Batch").setDesc("Max pages per GLM-OCR request (to avoid timeouts)").addText(e=>{e.setValue(String(this.plugin.settings.maxPagesPerBatch)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.maxPagesPerBatch=isNaN(a)||a<1?20:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="1"}),new m.Setting(t).setName("Sync Interval (minutes)").setDesc("0 = manual only, >0 = auto-sync every N minutes").addText(e=>{e.setValue(String(this.plugin.settings.syncInterval)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.syncInterval=isNaN(a)||a<0?0:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="0"})}};var $=require("child_process");async function l(i,t=[],e={},n){return new Promise(a=>{let s=(0,$.spawn)(i,t,{env:{...process.env,...e},cwd:n}),r="",o="",c=!1,d=(p,h)=>{c||(c=!0,a({stdout:r,stderr:h?`${o} -${h.message}`:o,code:p}))};s.stdout.on("data",p=>{r+=p.toString()}),s.stderr.on("data",p=>{o+=p.toString()}),s.on("error",p=>{d(1,p)}),s.on("close",p=>{d(p??0)})})}var w=class{plugin;constructor(t){this.plugin=t}getEnv(){return{RMAPI_HOST:this.plugin.settings.remarkableHost,RMAPI_CONFIG:this.plugin.app.vault.adapter.getBasePath()+"/.obsidian/rmapi"}}async runRmapi(t){return l(this.plugin.settings.rmapiBinaryPath,t,this.getEnv())}async list(t="/"){let{stdout:e,stderr:n,code:a}=await this.runRmapi(["ls","--json",t]);if(a!==0)throw new Error(`rmapi ls failed: ${n||e}`);try{return JSON.parse(e)}catch(s){throw new Error(`Failed to parse rmapi output: ${s}`)}}async downloadFile(t,e){let{stdout:n,stderr:a,code:s}=await this.runRmapi(["get",t,"-o",e]);if(s!==0)throw new Error(`rmapi get failed: ${a||n}`)}async downloadAnnotatedPdf(t,e){let{stdout:n,stderr:a,code:s}=await this.runRmapi(["geta",t,"-o",e]);if(s!==0)throw new Error(`rmapi geta failed: ${a||n}`)}async isAuthenticated(){let{code:t,stderr:e}=await this.runRmapi(["ls","--json","/"]);return t===0&&!e.includes("auth")&&!e.includes("register")}};var b=class{plugin;constructor(t){this.plugin=t}async loadSyncedDocuments(){return(await this.plugin.loadData()).syncedDocuments||[]}async saveSyncedDocuments(t){let e=await this.plugin.loadData()||{};await this.plugin.saveData({...e,syncedDocuments:t})}async trackDocument(t,e,n,a,s){let r=await this.loadSyncedDocuments(),o=r.findIndex(d=>d.id===t),c={id:t,name:e,lastSynced:new Date().toISOString(),localPath:n,hasPdf:a,hasMd:s};o>=0?r[o]=c:r.push(c),await this.saveSyncedDocuments(r)}async isDocumentSynced(t){return(await this.loadSyncedDocuments()).some(n=>n.id===t)}async getSyncedDocument(t){return(await this.loadSyncedDocuments()).find(n=>n.id===t)||null}};var R=class{plugin;constructor(t){this.plugin=t}async renderPageToPng(t,e){this.plugin.settings.pageRenderer==="drawj2d"?await this.renderWithDrawj2d(t,e):await this.renderWithRm2svg(t,e)}async renderWithDrawj2d(t,e){let n=["-jar",this.plugin.settings.drawj2dPath,"-Trm",t,e],{code:a,stderr:s,stdout:r}=await l(this.plugin.settings.javaPath,n);if(a!==0)throw new Error(`drawj2d failed: ${s||r}`)}async renderWithRm2svg(t,e){let n=e.replace(".png",".svg"),{code:a,stderr:s,stdout:r}=await l("rM2svg",[t,n]);if(a!==0)throw new Error(`rM2svg failed: ${s||r}`);let{code:o}=await l("rsvg-convert",["-o",e,n]);if(o!==0){let{code:c,stderr:d,stdout:p}=await l("convert",[n,e]);if(c!==0)throw new Error(`SVG to PNG conversion failed: ${d||p}`)}}};async function j(i){let t=`${i}/content.json`,{stdout:e,code:n}=await l("cat",[t]);if(n===0)try{let o=JSON.parse(e),c=q(o);if(c)return c}catch{}let a=`${i}/metadata.json`,{stdout:s,code:r}=await l("cat",[a]);if(r===0)try{let o=JSON.parse(s);if(o&&o.text)return o.text}catch{}return""}function q(i){let t=[];if(i.cPages&&Array.isArray(i.cPages.pages)){for(let e of i.cPages.pages)if(e.text&&t.push(e.text),e.layers)for(let n of e.layers)n.text&&t.push(n.text)}if(i.pages&&Array.isArray(i.pages))for(let e of i.pages)e.text&&t.push(e.text);return t.join(` +var I=Object.defineProperty;var st=Object.getOwnPropertyDescriptor;var ot=Object.getOwnPropertyNames;var lt=Object.prototype.hasOwnProperty;var ct=(r,t)=>{for(var e in t)I(r,e,{get:t[e],enumerable:!0})},dt=(r,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of ot(t))!lt.call(r,a)&&a!==e&&I(r,a,{get:()=>t[a],enumerable:!(n=st(t,a))||n.enumerable});return r};var mt=r=>dt(I({},"__esModule",{value:!0}),r);var ht={};ct(ht,{default:()=>pt});module.exports=mt(ht);var h=require("obsidian");var g=require("obsidian"),V={remarkableHost:"https://10.11.99.1",rmapiBinaryPath:"rmapi",downloadPath:"remarkable",convertToPdf:!0,enableHandwritingMd:!0,glmocrServerUrl:"http://localhost:5002",glmocrApiKey:"",ollamaHost:"http://localhost:11435",styleModel:"qwen3:32b",pageRenderer:"drawj2d",javaPath:"java",drawj2dPath:"drawj2d.jar",maxPagesPerBatch:20,syncInterval:0},R=class extends g.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"reMarkable Sync Settings"}),new g.Setting(t).setName("reMarkable Host").setDesc("URL of your reMarkable tablet (e.g., https://10.11.99.1)").addText(e=>e.setValue(this.plugin.settings.remarkableHost).onChange(async n=>{this.plugin.settings.remarkableHost=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("rmapi Binary Path").setDesc("Path to rmapi binary (e.g., rmapi or /usr/local/bin/rmapi)").addText(e=>e.setValue(this.plugin.settings.rmapiBinaryPath).onChange(async n=>{this.plugin.settings.rmapiBinaryPath=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Download Path").setDesc("Folder in vault for downloaded documents").addText(e=>e.setValue(this.plugin.settings.downloadPath).onChange(async n=>{this.plugin.settings.downloadPath=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Convert to PDF").setDesc("Automatically convert documents to annotated PDF").addToggle(e=>e.setValue(this.plugin.settings.convertToPdf).onChange(async n=>{this.plugin.settings.convertToPdf=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Enable Handwriting to Markdown").setDesc("Convert handwritten notes to styled Markdown").addToggle(e=>e.setValue(this.plugin.settings.enableHandwritingMd).onChange(async n=>{this.plugin.settings.enableHandwritingMd=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("GLM-OCR Server URL").setDesc("URL of GLM-OCR SDK Server (e.g., http://localhost:5002)").addText(e=>e.setValue(this.plugin.settings.glmocrServerUrl).onChange(async n=>{this.plugin.settings.glmocrServerUrl=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("GLM-OCR API Key").setDesc("API key for GLM-OCR Server (can be any string for self-hosted)").addText(e=>{e.inputEl.type="password",e.setValue(this.plugin.settings.glmocrApiKey).onChange(async n=>{this.plugin.settings.glmocrApiKey=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("Ollama Host").setDesc("URL of Ollama server for style refinement (e.g., http://localhost:11435)").addText(e=>e.setValue(this.plugin.settings.ollamaHost).onChange(async n=>{this.plugin.settings.ollamaHost=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Style Model").setDesc("Ollama model for Markdown style refinement").addText(e=>e.setValue(this.plugin.settings.styleModel).onChange(async n=>{this.plugin.settings.styleModel=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Page Renderer").setDesc("Tool to render .rm pages as PNG").addDropdown(e=>e.addOption("drawj2d","drawj2d (recommended)").addOption("rM2svg","rM2svg (lightweight)").setValue(this.plugin.settings.pageRenderer).onChange(async n=>{this.plugin.settings.pageRenderer=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Java Path").setDesc("Path to Java runtime (for drawj2d)").addText(e=>{e.setValue(this.plugin.settings.javaPath).onChange(async n=>{this.plugin.settings.javaPath=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("drawj2d Path").setDesc("Path to drawj2d.jar (e.g., /opt/drawj2d.jar)").addText(e=>{e.setValue(this.plugin.settings.drawj2dPath).onChange(async n=>{this.plugin.settings.drawj2dPath=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("Max Pages per Batch").setDesc("Max pages per GLM-OCR request (to avoid timeouts)").addText(e=>{e.setValue(String(this.plugin.settings.maxPagesPerBatch)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.maxPagesPerBatch=isNaN(a)||a<1?20:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="1"}),new g.Setting(t).setName("Sync Interval (minutes)").setDesc("0 = manual only, >0 = auto-sync every N minutes").addText(e=>{e.setValue(String(this.plugin.settings.syncInterval)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.syncInterval=isNaN(a)||a<0?0:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="0"})}};var k=require("obsidian");var B=require("obsidian"),G=require("path");function v(r){return r.app.vault.adapter.getBasePath()}function w(...r){let t=r.join("/").split("/").filter(e=>e&&e!=="."&&e!=="..").join("/");return(0,B.normalizePath)(t)}function b(r,t){return(0,G.join)(v(r),...(0,B.normalizePath)(t).split("/"))}function U(r){return r.replace(/[\\/:*?"<>|]/g,"_")}function gt(r){return r.split("/").filter(Boolean).map(U)}var D=class{plugin;constructor(t){this.plugin=t}async syncAll(){try{new k.Notice("Syncing from reMarkable...");let t=await this.listAllDocuments("/");for(let e of t)await this.downloadDocument(e);new k.Notice(`Sync completed! ${t.length} document(s) synced.`)}catch(t){throw new k.Notice(`Sync failed: ${t}`),console.error("Sync error:",t),t}}async listAllDocuments(t){let e=await this.plugin.rmapi.list(t),n=e.filter(a=>a.type==="DocumentType").map(a=>({node:a,remotePath:t==="/"?`/${a.name}`:`${t}/${a.name}`}));for(let a of e.filter(i=>i.type==="CollectionType")){let i=t==="/"?`/${a.name}`:`${t}/${a.name}`,s=await this.listAllDocuments(i);n=n.concat(s)}return n}async downloadDocument(t){let{node:e,remotePath:n}=t,a=await this.plugin.tracker.getSyncedDocument(e.id);if(a&&a.remoteVersion===e.version&&a.remoteModified===e.modifiedClient)return;let i=gt(n),s=i.pop()||U(e.name),o=w(this.plugin.settings.downloadPath,...i),c=w(o,`${s}.rm`),u=b(this.plugin,c);await this.ensureVaultFolder(o),await this.plugin.rmapi.downloadFile(n,u);let l=!1;if(this.plugin.settings.convertToPdf){let p=w(o,`${s}.pdf`);await this.plugin.rmapi.downloadAnnotatedPdf(n,b(this.plugin,p)),l=!0}if(await this.plugin.tracker.trackDocument(e,c,l,!1),this.plugin.settings.enableHandwritingMd){let p=w(o,`${s}.md`);await this.plugin.ocrPipeline.processDocument(c,p)&&await this.plugin.tracker.trackDocument(e,c,l,!0)}}async ensureVaultFolder(t){let e=t.split("/").filter(Boolean),n="";for(let a of e){n=n?w(n,a):a;try{await this.plugin.app.vault.adapter.mkdir(n)}catch(i){if(!await this.plugin.app.vault.adapter.exists(n))throw i}}}};var z=require("child_process");async function d(r,t=[],e={},n){return new Promise(a=>{let i=(0,z.spawn)(r,t,{env:{...process.env,...e},cwd:n}),s="",o="",c=!1,u=(l,p)=>{c||(c=!0,a({stdout:s,stderr:p?`${o} +${p.message}`:o,code:l}))};i.stdout.on("data",l=>{s+=l.toString()}),i.stderr.on("data",l=>{o+=l.toString()}),i.on("error",l=>{u(1,l)}),i.on("close",l=>{u(l??0)})})}var W=require("path");var x=class{plugin;constructor(t){this.plugin=t}getEnv(){return{RMAPI_HOST:this.plugin.settings.remarkableHost,RMAPI_CONFIG:(0,W.join)(v(this.plugin),".obsidian","rmapi")}}async runRmapi(t){return d(this.plugin.settings.rmapiBinaryPath,t,this.getEnv())}async list(t="/"){let{stdout:e,stderr:n,code:a}=await this.runRmapi(["ls","--json",t]);if(a!==0)throw new Error(`rmapi ls failed: ${n||e}`);try{return JSON.parse(e)}catch(i){throw new Error(`Failed to parse rmapi output: ${i}`)}}async downloadFile(t,e){let{stdout:n,stderr:a,code:i}=await this.runRmapi(["get",t,"-o",e]);if(i!==0)throw new Error(`rmapi get failed: ${a||n}`)}async downloadAnnotatedPdf(t,e){let{stdout:n,stderr:a,code:i}=await this.runRmapi(["geta",t,"-o",e]);if(i!==0)throw new Error(`rmapi geta failed: ${a||n}`)}async isAuthenticated(){let{code:t,stderr:e}=await this.runRmapi(["ls","--json","/"]);return t===0&&!e.includes("auth")&&!e.includes("register")}};var C=class{plugin;constructor(t){this.plugin=t}async renderPageToPng(t,e){this.plugin.settings.pageRenderer==="drawj2d"?await this.renderWithDrawj2d(t,e):await this.renderWithRm2svg(t,e)}async renderWithDrawj2d(t,e){let n=["-jar",this.plugin.settings.drawj2dPath,"-Trm",t,e],{code:a,stderr:i,stdout:s}=await d(this.plugin.settings.javaPath,n);if(a!==0)throw new Error(`drawj2d failed: ${i||s}`)}async renderWithRm2svg(t,e){let n=e.replace(".png",".svg"),{code:a,stderr:i,stdout:s}=await d("rM2svg",[t,n]);if(a!==0)throw new Error(`rM2svg failed: ${i||s}`);let{code:o}=await d("rsvg-convert",["-o",e,n]);if(o!==0){let{code:c,stderr:u,stdout:l}=await d("convert",[n,e]);if(c!==0)throw new Error(`SVG to PNG conversion failed: ${u||l}`)}}};var H=require("fs/promises");async function J(r){let t=`${r}/content.json`;try{let n=await(0,H.readFile)(t,"utf-8");try{let a=JSON.parse(n),i=ut(a);if(i)return i}catch{}}catch{}let e=`${r}/metadata.json`;try{let n=await(0,H.readFile)(e,"utf-8");try{let a=JSON.parse(n);if(a?.text)return a.text}catch{}}catch{}return""}function ut(r){let t=[];if(r.cPages&&Array.isArray(r.cPages.pages)){for(let e of r.cPages.pages)if(e.text&&t.push(e.text),e.layers)for(let n of e.layers)n.text&&t.push(n.text)}if(r.pages&&Array.isArray(r.pages))for(let e of r.pages)e.text&&t.push(e.text);return t.join(` -`)}var S=class{plugin;constructor(t){this.plugin=t}async parseImages(t){let e=`${this.plugin.settings.glmocrServerUrl}/glmocr/parse`,n=[];for(let d of t){let p=await this.fileToBase64(d);n.push(`data:image/png;base64,${p}`)}let s=["-s","-X","POST","-H","Content-Type: application/json","--max-time","60","-d",JSON.stringify({images:n}),e],{stdout:r,stderr:o,code:c}=await l("curl",s);if(c!==0)throw new Error(`GLM-OCR request failed: ${o||r}`);try{return JSON.parse(r)}catch(d){throw new Error(`Failed to parse GLM-OCR response: ${d} -Raw: ${r}`)}}async fileToBase64(t){let{stdout:e,code:n,stderr:a}=await l("base64",["-w","0",t]);if(n!==0)throw new Error(`base64 encoding failed: ${a}`);return e.trim()}};var k=class{plugin;constructor(t){this.plugin=t}async refineMarkdown(t){let e=`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. +`)}var f=require("fs/promises"),K=require("path"),q=require("os");var O=class{plugin;constructor(t){this.plugin=t}async parseImages(t){let e=`${this.plugin.settings.glmocrServerUrl}/glmocr/parse`,n=this.plugin.settings.glmocrApiKey,a=[];for(let s of t){let o=await(0,f.readFile)(s);a.push(`data:image/png;base64,${o.toString("base64")}`)}let i=(0,K.join)((0,q.tmpdir)(),`glmocr-body-${Date.now()}.json`);await(0,f.writeFile)(i,JSON.stringify({images:a}));try{let s=["-s","-X","POST","-H","Content-Type: application/json","-H",`Authorization: Bearer ${n}`,"--max-time","60","--data-binary",`@${i}`,e],{stdout:o,stderr:c,code:u}=await d("curl",s);if(u!==0)throw new Error(`GLM-OCR request failed: ${c||o}`);try{return JSON.parse(o)}catch(l){throw new Error(`Failed to parse GLM-OCR response: ${l} +Raw: ${o}`)}}finally{await(0,f.unlink)(i).catch(()=>{})}}};var T=require("fs/promises"),_=require("path"),Y=require("os");var M=class{plugin;constructor(t){this.plugin=t}async refineMarkdown(t){let e=`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. Analyze the document's structure and: 1. Fix heading hierarchy (h1 \u2192 h2 \u2192 h3 logically) @@ -14,7 +14,7 @@ Analyze the document's structure and: INPUT MARKDOWN: ${t} -OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`,a=(await this.queryOllama(e)).trim();return this.validateOllamaOutput(t,a),a}async mergeAndRefine(t,e){if(!t.trim())return this.refineMarkdown(e);let n=`You have two sources of text from the same handwritten document: +OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`,n=(await this.queryOllama(e)).trim();return this.validateOllamaOutput(t,n),n}async mergeAndRefine(t,e){if(!t.trim())return this.refineMarkdown(e);let n=`You have two sources of text from the same handwritten document: SOURCE 1 \u2014 ReMarkable built-in handwriting recognition (raw text, no formatting): ${t} @@ -30,8 +30,8 @@ Your task: 5. Identify emphasized/underlined text and apply bold/italic 6. Preserve all content \u2014 do not summarize or remove anything -OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences around the output.`,s=(await this.queryOllama(n)).trim();return this.validateOllamaOutput(t+e,s),s}async queryOllama(t){let e=`${this.plugin.settings.ollamaHost}/api/generate`,n=this.plugin.settings.styleModel,s=["-s","-X","POST","-H","Content-Type: application/json","--max-time","60","-d",JSON.stringify({model:n,prompt:t,stream:!1}),e],{stdout:r,stderr:o,code:c}=await l("curl",s);if(c!==0)throw new Error(`Ollama request failed: ${o||r}`);try{return JSON.parse(r).response||""}catch(d){throw new Error(`Failed to parse Ollama response: ${d} -Raw: ${r}`)}}validateOllamaOutput(t,e){if(e.lengths.endsWith(".rm")&&!s.endsWith(".zip")),n=t.some(s=>s.endsWith("content.pdf")||s.endsWith(".pdf"));if(e&&!n)return!0;if(!e||n)return!1;let a=t.filter(s=>s.endsWith(".rm")&&!s.endsWith(".zip"));if(a.length===0)return!1;try{let{code:s}=await l("file",[a[0]]);return s===0}catch{return!1}}async function F(i){return(await A(i)).filter(e=>e.endsWith(".rm")&&!e.endsWith(".zip")).sort()}var f=require("obsidian"),y=class{plugin;renderer;glmOcr;refiner;constructor(t){this.plugin=t,this.renderer=new R(t),this.glmOcr=new S(t),this.refiner=new k(t)}async processDocument(t,e){let n=`${this.plugin.app.vault.adapter.getBasePath()}/.obsidian/rmapi-tmp`;try{new f.Notice("Converting handwriting to Markdown..."),await this.ensureDir(n);let a=`${n}/extracted`;if(await this.ensureDir(a),await E(t,a),!await I(a))return new f.Notice("Document is not a handwritten notebook \u2014 skipping OCR."),"";let r=await j(a),o=await F(a),c=[],d=o.length;for(let g=0;g{})}}validateOllamaOutput(t,e){let n=t.trim().length,a=e.trim().length;if(a===0)throw new Error("Ollama returned empty Markdown");if(n>500&&aa.endsWith(".rm")&&!a.endsWith(".zip")),n=t.some(a=>a.endsWith("content.pdf")||a.endsWith(".pdf"));return e&&!n}async function nt(r){return(await tt(r)).filter(e=>e.endsWith(".rm")&&!e.endsWith(".zip")).sort()}var y=require("obsidian"),j=require("fs/promises"),$=require("path");var N=class{plugin;renderer;glmOcr;refiner;constructor(t){this.plugin=t,this.renderer=new C(t),this.glmOcr=new O(t),this.refiner=new M(t)}async processDocument(t,e){let n=(0,$.join)(v(this.plugin),".obsidian","rmapi-tmp"),a=b(this.plugin,t);try{new y.Notice("Converting handwriting to Markdown..."),await this.ensureDir(n);let i=(0,$.join)(n,"extracted");if(await this.ensureDir(i),await Z(a,i),!await et(i))return new y.Notice("Document is not a handwritten notebook \u2014 skipping OCR."),"";let o=await J(i),c=await nt(i),u=[],l=c.length;for(let m=0;m|]/g,"_")}var D=class{plugin;rmapi;tracker;ocrPipeline;constructor(t){this.plugin=t,this.rmapi=new w(t),this.tracker=new b(t),this.ocrPipeline=new y(t)}async syncAll(){try{new x.Notice("Syncing from reMarkable...");let t=await this.listAllDocuments("/");for(let e of t)await this.downloadDocument(e);new x.Notice(`Sync completed! ${t.length} document(s) synced.`)}catch(t){new x.Notice(`Sync failed: ${t}`),console.error("Sync error:",t)}}async listAllDocuments(t){let e=await this.rmapi.list(t),n=e.filter(a=>a.type==="DocumentType");for(let a of e.filter(s=>s.type==="CollectionType")){let s=t==="/"?`/${a.name}`:`${t}/${a.name}`,r=await this.listAllDocuments(s);n=n.concat(r)}return n}async downloadDocument(t){let e=await this.tracker.getSyncedDocument(t.id);if(e&&e.lastSynced>=t.modifiedClient)return;let n=K(t.name),a=this.plugin.settings.downloadPath,s=`${a}/${n}`;try{await this.plugin.app.vault.adapter.mkdir(a)}catch(o){console.warn("mkdir failed (may already exist):",o)}await this.rmapi.downloadFile(t.name,s);let r=!1;if(this.plugin.settings.convertToPdf){let o=`${a}/${n}.pdf`;await this.rmapi.downloadAnnotatedPdf(t.name,o),r=!0}if(await this.tracker.trackDocument(t.id,t.name,s,r,!1),this.plugin.settings.enableHandwritingMd){let o=`${a}/${n}.md`;await this.ocrPipeline.processDocument(s,o)&&await this.tracker.trackDocument(t.id,t.name,s,r,!0)}}};var P=class extends u.Plugin{settings;downloader;rmapi;ocrPipeline;statusBarItem;async onload(){if(await this.loadSettings(),this.rmapi=new w(this),this.downloader=new D(this),this.ocrPipeline=new y(this),this.settings.enableHandwritingMd){let t=await this.ocrPipeline.validateDependencies();t.length>0&&(new u.Notice(`OCR dependencies missing: ${t.join(", ")}. Disable "Enable Handwriting to Markdown" in settings or install required tools.`,15e3),this.settings.enableHandwritingMd=!1,await this.saveSettings())}this.addRibbonIcon("pencil","Sync from reMarkable",()=>{this.performSync()}),this.addCommand({id:"sync-all",name:"Sync from reMarkable",callback:()=>this.performSync()}),this.addCommand({id:"convert-handwriting-md",name:"Convert handwriting to Markdown",callback:()=>this.convertActiveFileToMd()}),this.addSettingTab(new v(this.app,this)),this.statusBarItem=this.addStatusBarItem(),this.updateStatusBar("Idle"),this.settings.syncInterval>0&&this.registerInterval(window.setInterval(()=>{this.performSync()},this.settings.syncInterval*60*1e3))}onunload(){}async performSync(){try{if(!await this.rmapi.isAuthenticated()){new u.Notice("reMarkable not authenticated. Run 'rmapi' in a terminal to pair your device.",1e4);return}this.updateStatusBar("Syncing..."),await this.downloader.syncAll(),this.updateStatusBar(`Last sync: ${new Date().toLocaleTimeString()}`)}catch(t){this.updateStatusBar("Sync failed"),console.error("Sync error:",t)}}async convertActiveFileToMd(){let t=this.app.workspace.getActiveFile();if(!t){new u.Notice("No active file selected");return}let e=t.path;if(!e.endsWith(".rm")){new u.Notice("Active file is not a .rm reMarkable document");return}let n=e.replace(/\.rm$/,".md");try{this.updateStatusBar("Converting to Markdown..."),await this.ocrPipeline.processDocument(e,n),this.updateStatusBar(`Converted: ${t.name}`)}catch(a){this.updateStatusBar("Conversion failed"),new u.Notice(`Conversion failed: ${a}`),console.error("OCR error:",a)}}updateStatusBar(t){this.statusBarItem.setText(`reMarkable: ${t}`)}async loadSettings(){this.settings=Object.assign({},N,await this.loadData())}async saveSettings(){await this.saveData(this.settings)}};var _=P; +`,new y.Notice(`OCR processing... ${m+1}/${P.length}`)}let at=L.trim(),it=await this.refiner.mergeAndRefine(o,at);return await this.plugin.app.vault.adapter.write(e,it),new y.Notice("Handwriting conversion complete!"),e}finally{await this.cleanup(n)}}async ensureDir(t){await(0,j.mkdir)(t,{recursive:!0})}async validateDependencies(){let t=["unzip","curl"],e=[];for(let n of t)try{let{code:a}=await d(n,["--version"]);a!==0&&e.push(n)}catch{e.push(n)}if(this.plugin.settings.pageRenderer==="drawj2d")try{let{code:n}=await d(this.plugin.settings.javaPath,["-jar",this.plugin.settings.drawj2dPath,"--help"]);n!==0&&e.push("drawj2d")}catch{e.push("drawj2d")}else{try{let{code:n}=await d("rM2svg",["--help"]);n!==0&&e.push("rM2svg")}catch{e.push("rM2svg")}try{let{code:n}=await d("rsvg-convert",["--version"]);if(n!==0)throw new Error("rsvg-convert failed")}catch{try{let{code:n}=await d("convert",["--version"]);if(n!==0)throw new Error("convert failed")}catch{e.push("rsvg-convert or convert")}}}return e}async cleanup(t){try{await(0,j.rm)(t,{recursive:!0,force:!0})}catch{console.warn("Failed to cleanup temp directory:",t)}}};var E=class{plugin;cache=null;constructor(t){this.plugin=t}async getCache(){if(this.cache===null){let t=await this.plugin.loadData();this.cache=t?.syncedDocuments||[]}return this.cache}async loadSyncedDocuments(){return this.getCache()}async saveSyncedDocuments(t){this.cache=t;let e=await this.plugin.loadData()||{};await this.plugin.saveData({...e,syncedDocuments:t})}async trackDocument(t,e,n,a){let i=await this.getCache(),s=i.findIndex(c=>c.id===t.id),o={id:t.id,name:t.name,lastSynced:new Date().toISOString(),remoteModified:t.modifiedClient,remoteVersion:t.version,localPath:e,hasPdf:n,hasMd:a};s>=0?i[s]=o:i.push(o),await this.saveSyncedDocuments(i)}async isDocumentSynced(t){return(await this.getCache()).some(n=>n.id===t)}async getSyncedDocument(t){return(await this.getCache()).find(n=>n.id===t)||null}};var S=class extends h.Plugin{settings;downloader;rmapi;ocrPipeline;tracker;statusBarItem;isSyncing=!1;async onload(){if(await this.loadSettings(),this.rmapi=new x(this),this.tracker=new E(this),this.downloader=new D(this),this.ocrPipeline=new N(this),this.settings.enableHandwritingMd){let t=await this.ocrPipeline.validateDependencies();t.length>0&&(new h.Notice(`OCR dependencies missing: ${t.join(", ")}. Disable "Enable Handwriting to Markdown" in settings or install required tools.`,15e3),this.settings.enableHandwritingMd=!1,await this.saveSettings())}this.addRibbonIcon("pencil","Sync from reMarkable",()=>{this.performSync()}),this.addCommand({id:"sync-all",name:"Sync from reMarkable",callback:()=>this.performSync()}),this.addCommand({id:"convert-handwriting-md",name:"Convert handwriting to Markdown",callback:()=>this.convertActiveFileToMd()}),this.addSettingTab(new R(this.app,this)),this.statusBarItem=this.addStatusBarItem(),this.updateStatusBar("Idle"),this.settings.syncInterval>0&&this.registerInterval(window.setInterval(()=>{this.performSync()},this.settings.syncInterval*60*1e3))}onunload(){}async performSync(){if(this.isSyncing){new h.Notice("Sync already in progress.");return}this.isSyncing=!0;try{if(!await this.rmapi.isAuthenticated()){new h.Notice("reMarkable not authenticated. Run 'rmapi' in a terminal to pair your device.",1e4);return}this.updateStatusBar("Syncing..."),await this.downloader.syncAll(),this.updateStatusBar(`Last sync: ${new Date().toLocaleTimeString()}`)}catch(t){this.updateStatusBar("Sync failed"),console.error("Sync error:",t)}finally{this.isSyncing=!1}}async convertActiveFileToMd(){let t=this.app.workspace.getActiveFile();if(!t){new h.Notice("No active file selected");return}let e=t.path;if(!e.endsWith(".rm")){new h.Notice("Active file is not a .rm reMarkable document");return}let n=e.replace(/\.rm$/,".md");try{this.updateStatusBar("Converting to Markdown..."),await this.ocrPipeline.processDocument(e,n),this.updateStatusBar(`Converted: ${t.name}`)}catch(a){this.updateStatusBar("Conversion failed"),new h.Notice(`Conversion failed: ${a}`),console.error("OCR error:",a)}}updateStatusBar(t){this.statusBarItem.setText(`reMarkable: ${t}`)}async loadSettings(){this.settings=Object.assign({},V,await this.loadData())}async saveSettings(){let t=await this.loadData()||{};await this.saveData({...t,...this.settings})}};var pt=S; diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 853073a..e1fc199 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -6,16 +6,19 @@ const buildOptions = { entryPoints: ["main.ts"], bundle: true, platform: "node", - outfile: "dist/main.js", format: "cjs", minify: true, external: ["obsidian", "child_process"], }; +const outputFiles = ["main.js", "dist/main.js"]; + if (isWatch) { - const ctx = await esbuild.context(buildOptions); - await ctx.watch(); + const contexts = await Promise.all( + outputFiles.map((outfile) => esbuild.context({ ...buildOptions, outfile })), + ); + await Promise.all(contexts.map((ctx) => ctx.watch())); console.log("Watching for changes..."); } else { - await esbuild.build(buildOptions); + await Promise.all(outputFiles.map((outfile) => esbuild.build({ ...buildOptions, outfile }))); } diff --git a/main.js b/main.js new file mode 100644 index 0000000..8835cce --- /dev/null +++ b/main.js @@ -0,0 +1,37 @@ +var I=Object.defineProperty;var st=Object.getOwnPropertyDescriptor;var ot=Object.getOwnPropertyNames;var lt=Object.prototype.hasOwnProperty;var ct=(r,t)=>{for(var e in t)I(r,e,{get:t[e],enumerable:!0})},dt=(r,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of ot(t))!lt.call(r,a)&&a!==e&&I(r,a,{get:()=>t[a],enumerable:!(n=st(t,a))||n.enumerable});return r};var mt=r=>dt(I({},"__esModule",{value:!0}),r);var ht={};ct(ht,{default:()=>pt});module.exports=mt(ht);var h=require("obsidian");var g=require("obsidian"),V={remarkableHost:"https://10.11.99.1",rmapiBinaryPath:"rmapi",downloadPath:"remarkable",convertToPdf:!0,enableHandwritingMd:!0,glmocrServerUrl:"http://localhost:5002",glmocrApiKey:"",ollamaHost:"http://localhost:11435",styleModel:"qwen3:32b",pageRenderer:"drawj2d",javaPath:"java",drawj2dPath:"drawj2d.jar",maxPagesPerBatch:20,syncInterval:0},R=class extends g.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"reMarkable Sync Settings"}),new g.Setting(t).setName("reMarkable Host").setDesc("URL of your reMarkable tablet (e.g., https://10.11.99.1)").addText(e=>e.setValue(this.plugin.settings.remarkableHost).onChange(async n=>{this.plugin.settings.remarkableHost=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("rmapi Binary Path").setDesc("Path to rmapi binary (e.g., rmapi or /usr/local/bin/rmapi)").addText(e=>e.setValue(this.plugin.settings.rmapiBinaryPath).onChange(async n=>{this.plugin.settings.rmapiBinaryPath=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Download Path").setDesc("Folder in vault for downloaded documents").addText(e=>e.setValue(this.plugin.settings.downloadPath).onChange(async n=>{this.plugin.settings.downloadPath=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Convert to PDF").setDesc("Automatically convert documents to annotated PDF").addToggle(e=>e.setValue(this.plugin.settings.convertToPdf).onChange(async n=>{this.plugin.settings.convertToPdf=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Enable Handwriting to Markdown").setDesc("Convert handwritten notes to styled Markdown").addToggle(e=>e.setValue(this.plugin.settings.enableHandwritingMd).onChange(async n=>{this.plugin.settings.enableHandwritingMd=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("GLM-OCR Server URL").setDesc("URL of GLM-OCR SDK Server (e.g., http://localhost:5002)").addText(e=>e.setValue(this.plugin.settings.glmocrServerUrl).onChange(async n=>{this.plugin.settings.glmocrServerUrl=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("GLM-OCR API Key").setDesc("API key for GLM-OCR Server (can be any string for self-hosted)").addText(e=>{e.inputEl.type="password",e.setValue(this.plugin.settings.glmocrApiKey).onChange(async n=>{this.plugin.settings.glmocrApiKey=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("Ollama Host").setDesc("URL of Ollama server for style refinement (e.g., http://localhost:11435)").addText(e=>e.setValue(this.plugin.settings.ollamaHost).onChange(async n=>{this.plugin.settings.ollamaHost=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Style Model").setDesc("Ollama model for Markdown style refinement").addText(e=>e.setValue(this.plugin.settings.styleModel).onChange(async n=>{this.plugin.settings.styleModel=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Page Renderer").setDesc("Tool to render .rm pages as PNG").addDropdown(e=>e.addOption("drawj2d","drawj2d (recommended)").addOption("rM2svg","rM2svg (lightweight)").setValue(this.plugin.settings.pageRenderer).onChange(async n=>{this.plugin.settings.pageRenderer=n,await this.plugin.saveSettings()})),new g.Setting(t).setName("Java Path").setDesc("Path to Java runtime (for drawj2d)").addText(e=>{e.setValue(this.plugin.settings.javaPath).onChange(async n=>{this.plugin.settings.javaPath=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("drawj2d Path").setDesc("Path to drawj2d.jar (e.g., /opt/drawj2d.jar)").addText(e=>{e.setValue(this.plugin.settings.drawj2dPath).onChange(async n=>{this.plugin.settings.drawj2dPath=n,await this.plugin.saveSettings()})}),new g.Setting(t).setName("Max Pages per Batch").setDesc("Max pages per GLM-OCR request (to avoid timeouts)").addText(e=>{e.setValue(String(this.plugin.settings.maxPagesPerBatch)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.maxPagesPerBatch=isNaN(a)||a<1?20:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="1"}),new g.Setting(t).setName("Sync Interval (minutes)").setDesc("0 = manual only, >0 = auto-sync every N minutes").addText(e=>{e.setValue(String(this.plugin.settings.syncInterval)).onChange(async n=>{let a=parseInt(n);this.plugin.settings.syncInterval=isNaN(a)||a<0?0:a,await this.plugin.saveSettings()}),e.inputEl.type="number",e.inputEl.min="0"})}};var k=require("obsidian");var B=require("obsidian"),G=require("path");function v(r){return r.app.vault.adapter.getBasePath()}function w(...r){let t=r.join("/").split("/").filter(e=>e&&e!=="."&&e!=="..").join("/");return(0,B.normalizePath)(t)}function b(r,t){return(0,G.join)(v(r),...(0,B.normalizePath)(t).split("/"))}function U(r){return r.replace(/[\\/:*?"<>|]/g,"_")}function gt(r){return r.split("/").filter(Boolean).map(U)}var D=class{plugin;constructor(t){this.plugin=t}async syncAll(){try{new k.Notice("Syncing from reMarkable...");let t=await this.listAllDocuments("/");for(let e of t)await this.downloadDocument(e);new k.Notice(`Sync completed! ${t.length} document(s) synced.`)}catch(t){throw new k.Notice(`Sync failed: ${t}`),console.error("Sync error:",t),t}}async listAllDocuments(t){let e=await this.plugin.rmapi.list(t),n=e.filter(a=>a.type==="DocumentType").map(a=>({node:a,remotePath:t==="/"?`/${a.name}`:`${t}/${a.name}`}));for(let a of e.filter(i=>i.type==="CollectionType")){let i=t==="/"?`/${a.name}`:`${t}/${a.name}`,s=await this.listAllDocuments(i);n=n.concat(s)}return n}async downloadDocument(t){let{node:e,remotePath:n}=t,a=await this.plugin.tracker.getSyncedDocument(e.id);if(a&&a.remoteVersion===e.version&&a.remoteModified===e.modifiedClient)return;let i=gt(n),s=i.pop()||U(e.name),o=w(this.plugin.settings.downloadPath,...i),c=w(o,`${s}.rm`),u=b(this.plugin,c);await this.ensureVaultFolder(o),await this.plugin.rmapi.downloadFile(n,u);let l=!1;if(this.plugin.settings.convertToPdf){let p=w(o,`${s}.pdf`);await this.plugin.rmapi.downloadAnnotatedPdf(n,b(this.plugin,p)),l=!0}if(await this.plugin.tracker.trackDocument(e,c,l,!1),this.plugin.settings.enableHandwritingMd){let p=w(o,`${s}.md`);await this.plugin.ocrPipeline.processDocument(c,p)&&await this.plugin.tracker.trackDocument(e,c,l,!0)}}async ensureVaultFolder(t){let e=t.split("/").filter(Boolean),n="";for(let a of e){n=n?w(n,a):a;try{await this.plugin.app.vault.adapter.mkdir(n)}catch(i){if(!await this.plugin.app.vault.adapter.exists(n))throw i}}}};var z=require("child_process");async function d(r,t=[],e={},n){return new Promise(a=>{let i=(0,z.spawn)(r,t,{env:{...process.env,...e},cwd:n}),s="",o="",c=!1,u=(l,p)=>{c||(c=!0,a({stdout:s,stderr:p?`${o} +${p.message}`:o,code:l}))};i.stdout.on("data",l=>{s+=l.toString()}),i.stderr.on("data",l=>{o+=l.toString()}),i.on("error",l=>{u(1,l)}),i.on("close",l=>{u(l??0)})})}var W=require("path");var x=class{plugin;constructor(t){this.plugin=t}getEnv(){return{RMAPI_HOST:this.plugin.settings.remarkableHost,RMAPI_CONFIG:(0,W.join)(v(this.plugin),".obsidian","rmapi")}}async runRmapi(t){return d(this.plugin.settings.rmapiBinaryPath,t,this.getEnv())}async list(t="/"){let{stdout:e,stderr:n,code:a}=await this.runRmapi(["ls","--json",t]);if(a!==0)throw new Error(`rmapi ls failed: ${n||e}`);try{return JSON.parse(e)}catch(i){throw new Error(`Failed to parse rmapi output: ${i}`)}}async downloadFile(t,e){let{stdout:n,stderr:a,code:i}=await this.runRmapi(["get",t,"-o",e]);if(i!==0)throw new Error(`rmapi get failed: ${a||n}`)}async downloadAnnotatedPdf(t,e){let{stdout:n,stderr:a,code:i}=await this.runRmapi(["geta",t,"-o",e]);if(i!==0)throw new Error(`rmapi geta failed: ${a||n}`)}async isAuthenticated(){let{code:t,stderr:e}=await this.runRmapi(["ls","--json","/"]);return t===0&&!e.includes("auth")&&!e.includes("register")}};var C=class{plugin;constructor(t){this.plugin=t}async renderPageToPng(t,e){this.plugin.settings.pageRenderer==="drawj2d"?await this.renderWithDrawj2d(t,e):await this.renderWithRm2svg(t,e)}async renderWithDrawj2d(t,e){let n=["-jar",this.plugin.settings.drawj2dPath,"-Trm",t,e],{code:a,stderr:i,stdout:s}=await d(this.plugin.settings.javaPath,n);if(a!==0)throw new Error(`drawj2d failed: ${i||s}`)}async renderWithRm2svg(t,e){let n=e.replace(".png",".svg"),{code:a,stderr:i,stdout:s}=await d("rM2svg",[t,n]);if(a!==0)throw new Error(`rM2svg failed: ${i||s}`);let{code:o}=await d("rsvg-convert",["-o",e,n]);if(o!==0){let{code:c,stderr:u,stdout:l}=await d("convert",[n,e]);if(c!==0)throw new Error(`SVG to PNG conversion failed: ${u||l}`)}}};var H=require("fs/promises");async function J(r){let t=`${r}/content.json`;try{let n=await(0,H.readFile)(t,"utf-8");try{let a=JSON.parse(n),i=ut(a);if(i)return i}catch{}}catch{}let e=`${r}/metadata.json`;try{let n=await(0,H.readFile)(e,"utf-8");try{let a=JSON.parse(n);if(a?.text)return a.text}catch{}}catch{}return""}function ut(r){let t=[];if(r.cPages&&Array.isArray(r.cPages.pages)){for(let e of r.cPages.pages)if(e.text&&t.push(e.text),e.layers)for(let n of e.layers)n.text&&t.push(n.text)}if(r.pages&&Array.isArray(r.pages))for(let e of r.pages)e.text&&t.push(e.text);return t.join(` + +`)}var f=require("fs/promises"),K=require("path"),q=require("os");var O=class{plugin;constructor(t){this.plugin=t}async parseImages(t){let e=`${this.plugin.settings.glmocrServerUrl}/glmocr/parse`,n=this.plugin.settings.glmocrApiKey,a=[];for(let s of t){let o=await(0,f.readFile)(s);a.push(`data:image/png;base64,${o.toString("base64")}`)}let i=(0,K.join)((0,q.tmpdir)(),`glmocr-body-${Date.now()}.json`);await(0,f.writeFile)(i,JSON.stringify({images:a}));try{let s=["-s","-X","POST","-H","Content-Type: application/json","-H",`Authorization: Bearer ${n}`,"--max-time","60","--data-binary",`@${i}`,e],{stdout:o,stderr:c,code:u}=await d("curl",s);if(u!==0)throw new Error(`GLM-OCR request failed: ${c||o}`);try{return JSON.parse(o)}catch(l){throw new Error(`Failed to parse GLM-OCR response: ${l} +Raw: ${o}`)}}finally{await(0,f.unlink)(i).catch(()=>{})}}};var T=require("fs/promises"),_=require("path"),Y=require("os");var M=class{plugin;constructor(t){this.plugin=t}async refineMarkdown(t){let e=`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. + +Analyze the document's structure and: +1. Fix heading hierarchy (h1 \u2192 h2 \u2192 h3 logically) +2. Consolidate fragmented paragraphs +3. Detect and format lists, checkboxes, tables +4. Identify emphasized/underlined text and apply bold/italic +5. Preserve all content \u2014 do not summarize or remove anything + +INPUT MARKDOWN: +${t} + +OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`,n=(await this.queryOllama(e)).trim();return this.validateOllamaOutput(t,n),n}async mergeAndRefine(t,e){if(!t.trim())return this.refineMarkdown(e);let n=`You have two sources of text from the same handwritten document: + +SOURCE 1 \u2014 ReMarkable built-in handwriting recognition (raw text, no formatting): +${t} + +SOURCE 2 \u2014 GLM-OCR engine output (markdown with structure but possible errors): +${e} + +Your task: +1. Merge both sources, preferring SOURCE 2 for structure and SOURCE 1 for text accuracy +2. Fix heading hierarchy (h1 \u2192 h2 \u2192 h3 logically) +3. Consolidate fragmented paragraphs +4. Detect and format lists, checkboxes, tables +5. Identify emphasized/underlined text and apply bold/italic +6. Preserve all content \u2014 do not summarize or remove anything + +OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences around the output.`,a=(await this.queryOllama(n)).trim();return this.validateOllamaOutput(`${t} +${e}`,a),a}async queryOllama(t){let e=`${this.plugin.settings.ollamaHost}/api/generate`,n=this.plugin.settings.styleModel,a=(0,_.join)((0,Y.tmpdir)(),`ollama-body-${Date.now()}.json`);await(0,T.writeFile)(a,JSON.stringify({model:n,prompt:t,stream:!1}));try{let i=["-s","-X","POST","-H","Content-Type: application/json","--max-time","300","--data-binary",`@${a}`,e],{stdout:s,stderr:o,code:c}=await d("curl",i);if(c!==0)throw new Error(`Ollama request failed: ${o||s}`);try{return JSON.parse(s).response||""}catch(u){throw new Error(`Failed to parse Ollama response: ${u} +Raw: ${s}`)}}finally{await(0,T.unlink)(a).catch(()=>{})}}validateOllamaOutput(t,e){let n=t.trim().length,a=e.trim().length;if(a===0)throw new Error("Ollama returned empty Markdown");if(n>500&&aa.endsWith(".rm")&&!a.endsWith(".zip")),n=t.some(a=>a.endsWith("content.pdf")||a.endsWith(".pdf"));return e&&!n}async function nt(r){return(await tt(r)).filter(e=>e.endsWith(".rm")&&!e.endsWith(".zip")).sort()}var y=require("obsidian"),j=require("fs/promises"),$=require("path");var N=class{plugin;renderer;glmOcr;refiner;constructor(t){this.plugin=t,this.renderer=new C(t),this.glmOcr=new O(t),this.refiner=new M(t)}async processDocument(t,e){let n=(0,$.join)(v(this.plugin),".obsidian","rmapi-tmp"),a=b(this.plugin,t);try{new y.Notice("Converting handwriting to Markdown..."),await this.ensureDir(n);let i=(0,$.join)(n,"extracted");if(await this.ensureDir(i),await Z(a,i),!await et(i))return new y.Notice("Document is not a handwritten notebook \u2014 skipping OCR."),"";let o=await J(i),c=await nt(i),u=[],l=c.length;for(let m=0;mc.id===t.id),o={id:t.id,name:t.name,lastSynced:new Date().toISOString(),remoteModified:t.modifiedClient,remoteVersion:t.version,localPath:e,hasPdf:n,hasMd:a};s>=0?i[s]=o:i.push(o),await this.saveSyncedDocuments(i)}async isDocumentSynced(t){return(await this.getCache()).some(n=>n.id===t)}async getSyncedDocument(t){return(await this.getCache()).find(n=>n.id===t)||null}};var S=class extends h.Plugin{settings;downloader;rmapi;ocrPipeline;tracker;statusBarItem;isSyncing=!1;async onload(){if(await this.loadSettings(),this.rmapi=new x(this),this.tracker=new E(this),this.downloader=new D(this),this.ocrPipeline=new N(this),this.settings.enableHandwritingMd){let t=await this.ocrPipeline.validateDependencies();t.length>0&&(new h.Notice(`OCR dependencies missing: ${t.join(", ")}. Disable "Enable Handwriting to Markdown" in settings or install required tools.`,15e3),this.settings.enableHandwritingMd=!1,await this.saveSettings())}this.addRibbonIcon("pencil","Sync from reMarkable",()=>{this.performSync()}),this.addCommand({id:"sync-all",name:"Sync from reMarkable",callback:()=>this.performSync()}),this.addCommand({id:"convert-handwriting-md",name:"Convert handwriting to Markdown",callback:()=>this.convertActiveFileToMd()}),this.addSettingTab(new R(this.app,this)),this.statusBarItem=this.addStatusBarItem(),this.updateStatusBar("Idle"),this.settings.syncInterval>0&&this.registerInterval(window.setInterval(()=>{this.performSync()},this.settings.syncInterval*60*1e3))}onunload(){}async performSync(){if(this.isSyncing){new h.Notice("Sync already in progress.");return}this.isSyncing=!0;try{if(!await this.rmapi.isAuthenticated()){new h.Notice("reMarkable not authenticated. Run 'rmapi' in a terminal to pair your device.",1e4);return}this.updateStatusBar("Syncing..."),await this.downloader.syncAll(),this.updateStatusBar(`Last sync: ${new Date().toLocaleTimeString()}`)}catch(t){this.updateStatusBar("Sync failed"),console.error("Sync error:",t)}finally{this.isSyncing=!1}}async convertActiveFileToMd(){let t=this.app.workspace.getActiveFile();if(!t){new h.Notice("No active file selected");return}let e=t.path;if(!e.endsWith(".rm")){new h.Notice("Active file is not a .rm reMarkable document");return}let n=e.replace(/\.rm$/,".md");try{this.updateStatusBar("Converting to Markdown..."),await this.ocrPipeline.processDocument(e,n),this.updateStatusBar(`Converted: ${t.name}`)}catch(a){this.updateStatusBar("Conversion failed"),new h.Notice(`Conversion failed: ${a}`),console.error("OCR error:",a)}}updateStatusBar(t){this.statusBarItem.setText(`reMarkable: ${t}`)}async loadSettings(){this.settings=Object.assign({},V,await this.loadData())}async saveSettings(){let t=await this.loadData()||{};await this.saveData({...t,...this.settings})}};var pt=S; diff --git a/package.json b/package.json index 75b9e87..7d9c2c9 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "obsidian-remarkable", "version": "0.1.0", "description": "Obsidian plugin for reMarkable tablet sync and handwriting OCR", - "main": "main.ts", + "main": "main.js", "scripts": { "build": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs --watch" diff --git a/src/ocr/pipeline.ts b/src/ocr/pipeline.ts index 2bc2987..a37cb19 100644 --- a/src/ocr/pipeline.ts +++ b/src/ocr/pipeline.ts @@ -6,6 +6,9 @@ import { StyleRefiner } from "./style-refiner"; import { extractRmFile, isNotebook, getPageFiles } from "../utils/zip"; import { runCommand } from "../utils/process"; import { Notice } from "obsidian"; +import { mkdir, rm } from "fs/promises"; +import { join } from "path"; +import { getVaultBasePath, vaultPathToAbsolute } from "../utils/paths"; export class OcrPipeline { plugin: RemarkablePlugin; @@ -25,16 +28,17 @@ export class OcrPipeline { * Returns the path to the generated .md file. */ async processDocument(rmPath: string, outputMdPath: string): Promise { - const tmpDir = `${this.plugin.app.vault.adapter.getBasePath()}/.obsidian/rmapi-tmp`; + const tmpDir = join(getVaultBasePath(this.plugin), ".obsidian", "rmapi-tmp"); + const absoluteRmPath = vaultPathToAbsolute(this.plugin, rmPath); try { new Notice("Converting handwriting to Markdown..."); // Step 0: Extract the .rm archive await this.ensureDir(tmpDir); - const extractedDir = `${tmpDir}/extracted`; + const extractedDir = join(tmpDir, "extracted"); await this.ensureDir(extractedDir); - await extractRmFile(rmPath, extractedDir); + await extractRmFile(absoluteRmPath, extractedDir); // Only process notebooks (handwritten documents) const notebook = await isNotebook(extractedDir); @@ -53,7 +57,7 @@ export class OcrPipeline { for (let i = 0; i < pageFiles.length; i++) { const pageFile = pageFiles[i]; - const pngPath = `${tmpDir}/page-${i}.png`; + const pngPath = join(tmpDir, `page-${i}.png`); await this.renderer.renderPageToPng(pageFile, pngPath); pngPaths.push(pngPath); @@ -98,11 +102,7 @@ export class OcrPipeline { } private async ensureDir(path: string): Promise { - try { - await this.plugin.app.vault.adapter.mkdir(path); - } catch { - // May already exist - } + await mkdir(path, { recursive: true }); } /** @@ -143,10 +143,12 @@ export class OcrPipeline { } // SVG to PNG converter try { - await runCommand("rsvg-convert", ["--version"]); + const { code } = await runCommand("rsvg-convert", ["--version"]); + if (code !== 0) throw new Error("rsvg-convert failed"); } catch { try { - await runCommand("convert", ["--version"]); + const { code } = await runCommand("convert", ["--version"]); + if (code !== 0) throw new Error("convert failed"); } catch { missing.push("rsvg-convert or convert"); } @@ -157,9 +159,9 @@ export class OcrPipeline { } private async cleanup(tmpDir: string): Promise { - const isWindows = process.platform === "win32"; - const { code } = await runCommand(isWindows ? "rd" : "rm", isWindows ? ["/s", "/q", tmpDir] : ["-rf", tmpDir]); - if (code !== 0) { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { console.warn("Failed to cleanup temp directory:", tmpDir); } } diff --git a/src/ocr/style-refiner.ts b/src/ocr/style-refiner.ts index 03f239e..8edcd10 100644 --- a/src/ocr/style-refiner.ts +++ b/src/ocr/style-refiner.ts @@ -26,7 +26,9 @@ ${rawMarkdown} OUTPUT ONLY THE REFINED MARKDOWN. No explanations, no markdown code fences around the output.`; - return (await this.queryOllama(prompt)).trim(); + const refined = (await this.queryOllama(prompt)).trim(); + this.validateOllamaOutput(rawMarkdown, refined); + return refined; } async mergeAndRefine(hwrText: string, glmOcrMarkdown: string): Promise { @@ -52,7 +54,9 @@ Your task: OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences around the output.`; - return (await this.queryOllama(prompt)).trim(); + const refined = (await this.queryOllama(prompt)).trim(); + this.validateOllamaOutput(`${hwrText}\n${glmOcrMarkdown}`, refined); + return refined; } private async queryOllama(prompt: string): Promise { @@ -86,4 +90,19 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences await unlink(bodyPath).catch(() => {}); } } + + private validateOllamaOutput(input: string, output: string): void { + const inputLength = input.trim().length; + const outputLength = output.trim().length; + + if (outputLength === 0) { + throw new Error("Ollama returned empty Markdown"); + } + + if (inputLength > 500 && outputLength < inputLength * 0.5) { + throw new Error( + `Ollama output is unexpectedly short (${outputLength} chars vs ${inputLength} input chars)`, + ); + } + } } diff --git a/src/rmapi/bridge.ts b/src/rmapi/bridge.ts index fb4f26b..f9670a6 100644 --- a/src/rmapi/bridge.ts +++ b/src/rmapi/bridge.ts @@ -1,6 +1,8 @@ import { runCommand } from "../utils/process"; import { RmapiNode } from "../types"; import RemarkablePlugin from "../main"; +import { join } from "path"; +import { getVaultBasePath } from "../utils/paths"; export class RmapiBridge { plugin: RemarkablePlugin; @@ -12,7 +14,7 @@ export class RmapiBridge { private getEnv(): Record { return { RMAPI_HOST: this.plugin.settings.remarkableHost, - RMAPI_CONFIG: this.plugin.app.vault.adapter.getBasePath() + "/.obsidian/rmapi", + RMAPI_CONFIG: join(getVaultBasePath(this.plugin), ".obsidian", "rmapi"), }; } diff --git a/src/sync/downloader.ts b/src/sync/downloader.ts index 515ca88..1abb668 100644 --- a/src/sync/downloader.ts +++ b/src/sync/downloader.ts @@ -1,11 +1,16 @@ import { RmapiNode } from "../types"; import RemarkablePlugin from "../main"; import { Notice } from "obsidian"; +import { vaultPathToAbsolute, vaultRelativePath } from "../utils/paths"; function sanitizeFileName(name: string): string { return name.replace(/[\\/:*?"<>|]/g, "_"); } +function sanitizeRemotePath(remotePath: string): string[] { + return remotePath.split("/").filter(Boolean).map(sanitizeFileName); +} + interface DocumentEntry { node: RmapiNode; remotePath: string; @@ -31,6 +36,7 @@ export class DocumentDownloader { } catch (e) { new Notice(`Sync failed: ${e}`); console.error("Sync error:", e); + throw e; } } @@ -56,36 +62,49 @@ export class DocumentDownloader { const { node: doc, remotePath } = entry; const synced = await this.plugin.tracker.getSyncedDocument(doc.id); - if (synced && synced.lastSynced >= doc.modifiedClient) { + if (synced && synced.remoteVersion === doc.version && synced.remoteModified === doc.modifiedClient) { return; } - const safeName = sanitizeFileName(doc.name); - const localDir = this.plugin.settings.downloadPath; - const localPath = `${localDir}/${safeName}`; + 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 absoluteLocalPath = vaultPathToAbsolute(this.plugin, localPath); - try { - await this.plugin.app.vault.adapter.mkdir(localDir); - } catch (err) { - console.warn("mkdir failed (may already exist):", err); - } + await this.ensureVaultFolder(localDir); - await this.plugin.rmapi.downloadFile(remotePath, localPath); + await this.plugin.rmapi.downloadFile(remotePath, absoluteLocalPath); let hasPdf = false; if (this.plugin.settings.convertToPdf) { - const pdfPath = `${localDir}/${safeName}.pdf`; - await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, pdfPath); + const pdfPath = vaultRelativePath(localDir, `${safeName}.pdf`); + await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, vaultPathToAbsolute(this.plugin, pdfPath)); hasPdf = true; } - await this.plugin.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, false); + await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, false); if (this.plugin.settings.enableHandwritingMd) { - const mdPath = `${localDir}/${safeName}.md`; + const mdPath = vaultRelativePath(localDir, `${safeName}.md`); const mdResult = await this.plugin.ocrPipeline.processDocument(localPath, mdPath); if (mdResult) { - await this.plugin.tracker.trackDocument(doc.id, doc.name, localPath, hasPdf, true); + await this.plugin.tracker.trackDocument(doc, localPath, hasPdf, true); + } + } + } + + private async ensureVaultFolder(path: string): Promise { + const segments = path.split("/").filter(Boolean); + let current = ""; + for (const segment of segments) { + current = current ? vaultRelativePath(current, segment) : segment; + try { + await this.plugin.app.vault.adapter.mkdir(current); + } catch (err) { + if (!(await this.plugin.app.vault.adapter.exists(current))) { + throw err; + } } } } diff --git a/src/sync/tracker.ts b/src/sync/tracker.ts index a55f926..721562d 100644 --- a/src/sync/tracker.ts +++ b/src/sync/tracker.ts @@ -1,4 +1,4 @@ -import { SyncedDocument } from "../types"; +import { RmapiNode, SyncedDocument } from "../types"; import RemarkablePlugin from "../main"; export class SyncTracker { @@ -27,14 +27,16 @@ export class SyncTracker { await this.plugin.saveData({ ...data, syncedDocuments: docs }); } - async trackDocument(id: string, name: string, localPath: string, hasPdf: boolean, hasMd: boolean): Promise { + async trackDocument(docNode: RmapiNode, localPath: string, hasPdf: boolean, hasMd: boolean): Promise { const docs = await this.getCache(); - const existingIndex = docs.findIndex((d) => d.id === id); + const existingIndex = docs.findIndex((d) => d.id === docNode.id); const doc: SyncedDocument = { - id, - name, + id: docNode.id, + name: docNode.name, lastSynced: new Date().toISOString(), + remoteModified: docNode.modifiedClient, + remoteVersion: docNode.version, localPath, hasPdf, hasMd, diff --git a/src/types.ts b/src/types.ts index ad188c9..abdcfc1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,8 @@ export interface SyncedDocument { id: string; name: string; lastSynced: string; // ISO timestamp + remoteModified?: string; + remoteVersion?: number; localPath: string; hasPdf: boolean; hasMd: boolean; diff --git a/src/utils/paths.ts b/src/utils/paths.ts new file mode 100644 index 0000000..8e0a713 --- /dev/null +++ b/src/utils/paths.ts @@ -0,0 +1,19 @@ +import { normalizePath, Plugin } from "obsidian"; +import { join } from "path"; + +export function getVaultBasePath(plugin: Plugin): string { + return plugin.app.vault.adapter.getBasePath(); +} + +export function vaultRelativePath(...parts: string[]): string { + const joined = parts + .join("/") + .split("/") + .filter((part) => part && part !== "." && part !== "..") + .join("/"); + return normalizePath(joined); +} + +export function vaultPathToAbsolute(plugin: Plugin, vaultPath: string): string { + return join(getVaultBasePath(plugin), ...normalizePath(vaultPath).split("/")); +} diff --git a/src/utils/zip.ts b/src/utils/zip.ts index 082ce11..c0d57c7 100644 --- a/src/utils/zip.ts +++ b/src/utils/zip.ts @@ -1,4 +1,6 @@ import { runCommand } from "./process"; +import { readdir } from "fs/promises"; +import { join } from "path"; /** * Extract a .rm file (which is a zip archive) to a temporary directory. @@ -17,11 +19,22 @@ export async function extractRmFile(rmPath: string, outputDir: string): Promise< * List the contents of the extracted .rm archive. */ export async function listRmContents(extractedDir: string): Promise { - const { stdout, code, stderr } = await runCommand("find", [extractedDir, "-type", "f"]); - if (code !== 0) { - throw new Error(`Failed to list .rm contents: ${stderr}`); + const files: string[] = []; + + async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(entryPath); + } else if (entry.isFile()) { + files.push(entryPath); + } + } } - return stdout.trim().split("\n").filter(Boolean); + + await walk(extractedDir); + return files; } /**