Update build system and path handling
- Use Node filesystem APIs instead of shell commands for temp cleanup - Add absolute path helpers for cross-platform compatibility - Generate both main.js and dist/main.js artifacts - Improve error handling in document downloader - Track additional document metadata in sync tracker - Replace Unix-specific `find` with recursive directory traversal The build system now generates two output files (main.js and dist/main.js) for better compatibility with Obsidian's plugin loading. Path handling has been centralized in new utilities to ensure cross-platform behavior, replacing shell commands that had Windows compatibility issues.
This commit is contained in:
+18
-16
@@ -39,12 +39,12 @@
|
|||||||
| 15 | No dependency validation | `src/ocr/pipeline.ts` | Added `validateDependencies()` on load |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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/
|
obidian-remarkable/
|
||||||
|
├── main.js # Built plugin entry loaded by Obsidian
|
||||||
├── dist/
|
├── dist/
|
||||||
│ └── main.js # Built plugin (~17 KB, minified)
|
│ └── main.js # Secondary built artifact
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.ts # Plugin entry: ribbon, commands, status bar, auto-sync, dependency validation
|
│ ├── main.ts # Plugin entry: ribbon, commands, status bar, auto-sync, dependency validation
|
||||||
│ ├── settings.ts # Settings tab with validation + 2 new settings
|
│ ├── 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)
|
│ │ └── 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
|
||||||
|
│ ├── paths.ts # Vault-relative and absolute path helpers
|
||||||
│ └── zip.ts # .rm zip extraction + notebook detection + recursive page search
|
│ └── 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 (fixed)
|
├── manifest.json # Obsidian plugin manifest (fixed)
|
||||||
@@ -108,7 +110,7 @@ User clicks ribbon icon / auto-sync interval
|
|||||||
→ ls --json / (root)
|
→ ls --json / (root)
|
||||||
→ For each CollectionType: recurse into subfolder
|
→ For each CollectionType: recurse into subfolder
|
||||||
→ For each DocumentType:
|
→ For each DocumentType:
|
||||||
→ Skip if lastSynced >= modifiedClient (incremental)
|
→ Skip if tracked remoteVersion and remoteModified match (incremental)
|
||||||
→ sanitizeFileName() for safe filesystem names
|
→ sanitizeFileName() for safe filesystem names
|
||||||
→ mkdir downloadPath (vault-relative)
|
→ mkdir downloadPath (vault-relative)
|
||||||
→ rmapi get → download .rm file
|
→ rmapi get → download .rm file
|
||||||
@@ -164,8 +166,8 @@ 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, progress every 5 pages |
|
| **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 |
|
| **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 | `qwen3:32b` at `100.103.83.12:11435`, output length validated |
|
| **4** | `curl` → Ollama | HWR text + GLM markdown | Clean Markdown | Configured Ollama host/model, output length validated |
|
||||||
|
|
||||||
### Style Refinement Prompt (Option A)
|
### Style Refinement Prompt (Option A)
|
||||||
|
|
||||||
@@ -200,9 +202,9 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN.
|
|||||||
| `downloadPath` | `remarkable/` | Vault folder for downloads |
|
| `downloadPath` | `remarkable/` | Vault folder for downloads |
|
||||||
| `convertToPdf` | `true` | Auto-download annotated PDFs |
|
| `convertToPdf` | `true` | Auto-download annotated PDFs |
|
||||||
| `enableHandwritingMd` | `true` | **Trigger OCR pipeline after sync** |
|
| `enableHandwritingMd` | `true` | **Trigger OCR pipeline after sync** |
|
||||||
| `glmocrServerUrl` | `http://100.103.83.12:5002` | GLM-OCR SDK Server |
|
| `glmocrServerUrl` | `http://localhost:5002` | GLM-OCR SDK Server |
|
||||||
| `glmocrApiKey` | `any-string` | Dummy key for self-hosted |
|
| `glmocrApiKey` | empty | API key for GLM-OCR Server |
|
||||||
| `ollamaHost` | `http://100.103.83.12:11435` | Ollama server |
|
| `ollamaHost` | `http://localhost:11435` | Ollama server |
|
||||||
| `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 |
|
||||||
@@ -224,7 +226,7 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN.
|
|||||||
- **Page renderer**:
|
- **Page renderer**:
|
||||||
- **drawj2d**: Java JAR (recommended for Paper Pro v3.x) + `drawj2dPath` setting
|
- **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)
|
- **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 |
|
| **Dependency validation** | User knows what's missing before OCR fails |
|
||||||
| **Progress feedback** | Better UX for large notebooks |
|
| **Progress feedback** | Better UX for large notebooks |
|
||||||
| **Output validation** | Trust but verify LLM output |
|
| **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 |
|
| **Recursive page search** | Handles `.rm` files in subdirectories |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -259,7 +261,7 @@ npm run build
|
|||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Install in Obsidian
|
# 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/
|
- Linux: ~/.config/obsidian/plugins/
|
||||||
- macOS: ~/Library/Application Support/obsidian/plugins/
|
- macOS: ~/Library/Application Support/obsidian/plugins/
|
||||||
- Windows: %APPDATA%\obsidian\plugins\
|
- Windows: %APPDATA%\obsidian\plugins\
|
||||||
@@ -270,9 +272,9 @@ Copy the `obidian-remarkable` folder to:
|
|||||||
## 🐛 Known Limitations
|
## 🐛 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.
|
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. **OCR runtime dependencies**: GLM-OCR, Ollama, and a page renderer must be running/installed outside Obsidian.
|
||||||
3. **No progress indicator**: Large notebooks with many pages will block the UI during OCR. Consider adding a progress modal.
|
3. **Progress UX**: Large notebooks use notices for progress, but there is no cancellable progress modal yet.
|
||||||
4. **Temp directory**: Uses `.obsidian/rmapi-tmp` and cleans up with `rm -rf` or `rd /s /q`.
|
4. **Temp directory**: Uses an absolute filesystem path at `<vault>/.obsidian/rmapi-tmp` and cleans it up with Node filesystem APIs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Vendored
+9
-9
File diff suppressed because one or more lines are too long
+7
-4
@@ -6,16 +6,19 @@ const buildOptions = {
|
|||||||
entryPoints: ["main.ts"],
|
entryPoints: ["main.ts"],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
platform: "node",
|
platform: "node",
|
||||||
outfile: "dist/main.js",
|
|
||||||
format: "cjs",
|
format: "cjs",
|
||||||
minify: true,
|
minify: true,
|
||||||
external: ["obsidian", "child_process"],
|
external: ["obsidian", "child_process"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const outputFiles = ["main.js", "dist/main.js"];
|
||||||
|
|
||||||
if (isWatch) {
|
if (isWatch) {
|
||||||
const ctx = await esbuild.context(buildOptions);
|
const contexts = await Promise.all(
|
||||||
await ctx.watch();
|
outputFiles.map((outfile) => esbuild.context({ ...buildOptions, outfile })),
|
||||||
|
);
|
||||||
|
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||||
console.log("Watching for changes...");
|
console.log("Watching for changes...");
|
||||||
} else {
|
} else {
|
||||||
await esbuild.build(buildOptions);
|
await Promise.all(outputFiles.map((outfile) => esbuild.build({ ...buildOptions, outfile })));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "obsidian-remarkable",
|
"name": "obsidian-remarkable",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Obsidian plugin for reMarkable tablet sync and handwriting OCR",
|
"description": "Obsidian plugin for reMarkable tablet sync and handwriting OCR",
|
||||||
"main": "main.ts",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node esbuild.config.mjs",
|
"build": "node esbuild.config.mjs",
|
||||||
"dev": "node esbuild.config.mjs --watch"
|
"dev": "node esbuild.config.mjs --watch"
|
||||||
|
|||||||
+16
-14
@@ -6,6 +6,9 @@ import { StyleRefiner } from "./style-refiner";
|
|||||||
import { extractRmFile, isNotebook, getPageFiles } from "../utils/zip";
|
import { extractRmFile, isNotebook, getPageFiles } from "../utils/zip";
|
||||||
import { runCommand } from "../utils/process";
|
import { runCommand } from "../utils/process";
|
||||||
import { Notice } from "obsidian";
|
import { Notice } from "obsidian";
|
||||||
|
import { mkdir, rm } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getVaultBasePath, vaultPathToAbsolute } from "../utils/paths";
|
||||||
|
|
||||||
export class OcrPipeline {
|
export class OcrPipeline {
|
||||||
plugin: RemarkablePlugin;
|
plugin: RemarkablePlugin;
|
||||||
@@ -25,16 +28,17 @@ export class OcrPipeline {
|
|||||||
* Returns the path to the generated .md file.
|
* Returns the path to the generated .md file.
|
||||||
*/
|
*/
|
||||||
async processDocument(rmPath: string, outputMdPath: string): Promise<string> {
|
async processDocument(rmPath: string, outputMdPath: string): Promise<string> {
|
||||||
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 {
|
try {
|
||||||
new Notice("Converting handwriting to Markdown...");
|
new Notice("Converting handwriting to Markdown...");
|
||||||
|
|
||||||
// Step 0: Extract the .rm archive
|
// Step 0: Extract the .rm archive
|
||||||
await this.ensureDir(tmpDir);
|
await this.ensureDir(tmpDir);
|
||||||
const extractedDir = `${tmpDir}/extracted`;
|
const extractedDir = join(tmpDir, "extracted");
|
||||||
await this.ensureDir(extractedDir);
|
await this.ensureDir(extractedDir);
|
||||||
await extractRmFile(rmPath, extractedDir);
|
await extractRmFile(absoluteRmPath, extractedDir);
|
||||||
|
|
||||||
// Only process notebooks (handwritten documents)
|
// Only process notebooks (handwritten documents)
|
||||||
const notebook = await isNotebook(extractedDir);
|
const notebook = await isNotebook(extractedDir);
|
||||||
@@ -53,7 +57,7 @@ export class OcrPipeline {
|
|||||||
|
|
||||||
for (let i = 0; i < pageFiles.length; i++) {
|
for (let i = 0; i < pageFiles.length; i++) {
|
||||||
const pageFile = pageFiles[i];
|
const pageFile = pageFiles[i];
|
||||||
const pngPath = `${tmpDir}/page-${i}.png`;
|
const pngPath = join(tmpDir, `page-${i}.png`);
|
||||||
await this.renderer.renderPageToPng(pageFile, pngPath);
|
await this.renderer.renderPageToPng(pageFile, pngPath);
|
||||||
pngPaths.push(pngPath);
|
pngPaths.push(pngPath);
|
||||||
|
|
||||||
@@ -98,11 +102,7 @@ export class OcrPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureDir(path: string): Promise<void> {
|
private async ensureDir(path: string): Promise<void> {
|
||||||
try {
|
await mkdir(path, { recursive: true });
|
||||||
await this.plugin.app.vault.adapter.mkdir(path);
|
|
||||||
} catch {
|
|
||||||
// May already exist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,10 +143,12 @@ export class OcrPipeline {
|
|||||||
}
|
}
|
||||||
// SVG to PNG converter
|
// SVG to PNG converter
|
||||||
try {
|
try {
|
||||||
await runCommand("rsvg-convert", ["--version"]);
|
const { code } = await runCommand("rsvg-convert", ["--version"]);
|
||||||
|
if (code !== 0) throw new Error("rsvg-convert failed");
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await runCommand("convert", ["--version"]);
|
const { code } = await runCommand("convert", ["--version"]);
|
||||||
|
if (code !== 0) throw new Error("convert failed");
|
||||||
} catch {
|
} catch {
|
||||||
missing.push("rsvg-convert or convert");
|
missing.push("rsvg-convert or convert");
|
||||||
}
|
}
|
||||||
@@ -157,9 +159,9 @@ export class OcrPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cleanup(tmpDir: string): Promise<void> {
|
private async cleanup(tmpDir: string): Promise<void> {
|
||||||
const isWindows = process.platform === "win32";
|
try {
|
||||||
const { code } = await runCommand(isWindows ? "rd" : "rm", isWindows ? ["/s", "/q", tmpDir] : ["-rf", tmpDir]);
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
if (code !== 0) {
|
} catch {
|
||||||
console.warn("Failed to cleanup temp directory:", tmpDir);
|
console.warn("Failed to cleanup temp directory:", tmpDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ ${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.`;
|
||||||
|
|
||||||
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<string> {
|
async mergeAndRefine(hwrText: string, glmOcrMarkdown: string): Promise<string> {
|
||||||
@@ -52,7 +54,9 @@ 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.`;
|
||||||
|
|
||||||
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<string> {
|
private async queryOllama(prompt: string): Promise<string> {
|
||||||
@@ -86,4 +90,19 @@ OUTPUT ONLY THE FINAL REFINED MARKDOWN. No explanations, no markdown code fences
|
|||||||
await unlink(bodyPath).catch(() => {});
|
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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
import { runCommand } from "../utils/process";
|
import { runCommand } from "../utils/process";
|
||||||
import { RmapiNode } from "../types";
|
import { RmapiNode } from "../types";
|
||||||
import RemarkablePlugin from "../main";
|
import RemarkablePlugin from "../main";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getVaultBasePath } from "../utils/paths";
|
||||||
|
|
||||||
export class RmapiBridge {
|
export class RmapiBridge {
|
||||||
plugin: RemarkablePlugin;
|
plugin: RemarkablePlugin;
|
||||||
@@ -12,7 +14,7 @@ export class RmapiBridge {
|
|||||||
private getEnv(): Record<string, string> {
|
private getEnv(): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
RMAPI_HOST: this.plugin.settings.remarkableHost,
|
RMAPI_HOST: this.plugin.settings.remarkableHost,
|
||||||
RMAPI_CONFIG: this.plugin.app.vault.adapter.getBasePath() + "/.obsidian/rmapi",
|
RMAPI_CONFIG: join(getVaultBasePath(this.plugin), ".obsidian", "rmapi"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-15
@@ -1,11 +1,16 @@
|
|||||||
import { RmapiNode } from "../types";
|
import { RmapiNode } from "../types";
|
||||||
import RemarkablePlugin from "../main";
|
import RemarkablePlugin from "../main";
|
||||||
import { Notice } from "obsidian";
|
import { Notice } from "obsidian";
|
||||||
|
import { vaultPathToAbsolute, vaultRelativePath } from "../utils/paths";
|
||||||
|
|
||||||
function sanitizeFileName(name: string): string {
|
function sanitizeFileName(name: string): string {
|
||||||
return name.replace(/[\\/:*?"<>|]/g, "_");
|
return name.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeRemotePath(remotePath: string): string[] {
|
||||||
|
return remotePath.split("/").filter(Boolean).map(sanitizeFileName);
|
||||||
|
}
|
||||||
|
|
||||||
interface DocumentEntry {
|
interface DocumentEntry {
|
||||||
node: RmapiNode;
|
node: RmapiNode;
|
||||||
remotePath: string;
|
remotePath: string;
|
||||||
@@ -31,6 +36,7 @@ export class DocumentDownloader {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
new Notice(`Sync failed: ${e}`);
|
new Notice(`Sync failed: ${e}`);
|
||||||
console.error("Sync error:", e);
|
console.error("Sync error:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,36 +62,49 @@ export class DocumentDownloader {
|
|||||||
const { node: doc, remotePath } = entry;
|
const { node: doc, remotePath } = entry;
|
||||||
|
|
||||||
const synced = await this.plugin.tracker.getSyncedDocument(doc.id);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = sanitizeFileName(doc.name);
|
const pathSegments = sanitizeRemotePath(remotePath);
|
||||||
const localDir = this.plugin.settings.downloadPath;
|
const safeName = pathSegments.pop() || sanitizeFileName(doc.name);
|
||||||
const localPath = `${localDir}/${safeName}`;
|
const localDir = vaultRelativePath(this.plugin.settings.downloadPath, ...pathSegments);
|
||||||
|
const localPath = vaultRelativePath(localDir, `${safeName}.rm`);
|
||||||
|
const absoluteLocalPath = vaultPathToAbsolute(this.plugin, localPath);
|
||||||
|
|
||||||
try {
|
await this.ensureVaultFolder(localDir);
|
||||||
await this.plugin.app.vault.adapter.mkdir(localDir);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("mkdir failed (may already exist):", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.plugin.rmapi.downloadFile(remotePath, localPath);
|
await this.plugin.rmapi.downloadFile(remotePath, absoluteLocalPath);
|
||||||
|
|
||||||
let hasPdf = false;
|
let hasPdf = false;
|
||||||
if (this.plugin.settings.convertToPdf) {
|
if (this.plugin.settings.convertToPdf) {
|
||||||
const pdfPath = `${localDir}/${safeName}.pdf`;
|
const pdfPath = vaultRelativePath(localDir, `${safeName}.pdf`);
|
||||||
await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, pdfPath);
|
await this.plugin.rmapi.downloadAnnotatedPdf(remotePath, vaultPathToAbsolute(this.plugin, pdfPath));
|
||||||
hasPdf = true;
|
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) {
|
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);
|
const mdResult = await this.plugin.ocrPipeline.processDocument(localPath, mdPath);
|
||||||
if (mdResult) {
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -1,4 +1,4 @@
|
|||||||
import { SyncedDocument } from "../types";
|
import { RmapiNode, SyncedDocument } from "../types";
|
||||||
import RemarkablePlugin from "../main";
|
import RemarkablePlugin from "../main";
|
||||||
|
|
||||||
export class SyncTracker {
|
export class SyncTracker {
|
||||||
@@ -27,14 +27,16 @@ export class SyncTracker {
|
|||||||
await this.plugin.saveData({ ...data, syncedDocuments: docs });
|
await this.plugin.saveData({ ...data, syncedDocuments: docs });
|
||||||
}
|
}
|
||||||
|
|
||||||
async trackDocument(id: string, name: string, localPath: string, hasPdf: boolean, hasMd: boolean): Promise<void> {
|
async trackDocument(docNode: RmapiNode, localPath: string, hasPdf: boolean, hasMd: boolean): Promise<void> {
|
||||||
const docs = await this.getCache();
|
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 = {
|
const doc: SyncedDocument = {
|
||||||
id,
|
id: docNode.id,
|
||||||
name,
|
name: docNode.name,
|
||||||
lastSynced: new Date().toISOString(),
|
lastSynced: new Date().toISOString(),
|
||||||
|
remoteModified: docNode.modifiedClient,
|
||||||
|
remoteVersion: docNode.version,
|
||||||
localPath,
|
localPath,
|
||||||
hasPdf,
|
hasPdf,
|
||||||
hasMd,
|
hasMd,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface SyncedDocument {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
lastSynced: string; // ISO timestamp
|
lastSynced: string; // ISO timestamp
|
||||||
|
remoteModified?: string;
|
||||||
|
remoteVersion?: number;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
hasMd: boolean;
|
hasMd: boolean;
|
||||||
|
|||||||
@@ -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("/"));
|
||||||
|
}
|
||||||
+17
-4
@@ -1,4 +1,6 @@
|
|||||||
import { runCommand } from "./process";
|
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.
|
* 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.
|
* List the contents of the extracted .rm archive.
|
||||||
*/
|
*/
|
||||||
export async function listRmContents(extractedDir: string): Promise<string[]> {
|
export async function listRmContents(extractedDir: string): Promise<string[]> {
|
||||||
const { stdout, code, stderr } = await runCommand("find", [extractedDir, "-type", "f"]);
|
const files: string[] = [];
|
||||||
if (code !== 0) {
|
|
||||||
throw new Error(`Failed to list .rm contents: ${stderr}`);
|
async function walk(dir: string): Promise<void> {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user