Skip to content

Commit 121398c

Browse files
papertray3claude
andcommitted
Add rendered text and JSON output support
Implement DOM-based extraction of rendered Obsidian content with support for both plain text and structured JSON output formats. Features: - rendered-text content type: Plain text extraction from rendered DOM - rendered-json content type: Structured JSON with typed content blocks - Cache system for fast repeated access - Full Dataview/DataviewJS rendering support - Settings UI for cache configuration - Automatic cache invalidation on file changes Content Types: - application/vnd.olrapi.note+rendered-text (plain text) - application/vnd.olrapi.note+rendered-json (structured JSON) JSON Structure: - metadata: source path, render timestamp, format version - frontmatter: complete YAML frontmatter as object - content: array of typed blocks (heading, table, list, paragraph, code, callout) Table blocks include headers and rows as 2D arrays, making them easy to parse and analyze programmatically. Implementation: - RenderCacheManager: handles rendering and caching - StructuredExtractor: converts DOM to typed JSON blocks - Cache stored in .obsidian/render-cache/ with hash-based keys - 2-second content settlement wait for async plugin rendering - Restores original user view after extraction Benefits for AI Clients: - Tables as parseable 2D arrays - Document structure preserved with type tags - Frontmatter metadata accessible - Content queryable by type - No parsing ambiguity Testing: - Verified with complex Dataview dashboards - Tables extract correctly with headers and rows - All frontmatter fields preserved - Bundle size: 2.4mb (optimized, no PDF dependencies) 🤖 Generated with Claude Code https://claude.com/claude-code Co-Authored-By: Claude <[email protected]>
1 parent 615b3bc commit 121398c

File tree

8 files changed

+2381
-1267
lines changed

8 files changed

+2381
-1267
lines changed

package-lock.json

Lines changed: 1527 additions & 1224 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@types/mime-types": "^2.1.1",
2525
"@types/node": "^16.11.6",
2626
"@types/node-forge": "^1.0.0",
27+
"@types/pdf-parse": "^1.1.4",
2728
"@types/supertest": "^2.0.11",
2829
"@types/uuid": "^8.3.4",
2930
"@typescript-eslint/eslint-plugin": "^5.29.0",
@@ -51,6 +52,7 @@
5152
"node-forge": "^1.2.1",
5253
"obsidian-daily-notes-interface": "^0.9.4",
5354
"obsidian-dataview": "^0.5.47",
55+
"pdfjs-dist": "^3.11.174",
5456
"query-string": "^7.1.1",
5557
"response-time": "^2.3.2",
5658
"uuid": "^8.3.2"

src/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ export const DEFAULT_SETTINGS: LocalRestApiSettings = {
88
enableInsecureServer: false,
99
};
1010

11+
export const DEFAULT_RENDER_CACHE_SETTINGS = {
12+
renderCacheDirectory: ".obsidian/render-cache",
13+
renderCacheMaxSizeMB: 100,
14+
renderCacheAutoCleanup: true,
15+
renderCacheTimeoutMs: 30000,
16+
};
17+
18+
1119
export const ERROR_CODE_MESSAGES: Record<ErrorCode, string> = {
1220
[ErrorCode.ApiKeyAuthorizationRequired]:
1321
"Authorization required. Find your API Key in the 'Local REST API' section of your Obsidian settings.",
@@ -51,6 +59,9 @@ export enum ContentTypes {
5159
json = "application/json",
5260
markdown = "text/markdown",
5361
olrapiNoteJson = "application/vnd.olrapi.note+json",
62+
olrapiNoteHtml = "application/vnd.olrapi.note+html",
63+
olrapiRenderedText = "application/vnd.olrapi.note+rendered-text",
64+
olrapiRenderedJson = "application/vnd.olrapi.note+rendered-json",
5465
jsonLogic = "application/vnd.olrapi.jsonlogic+json",
5566
dataviewDql = "application/vnd.olrapi.dataview.dql+txt",
5667
}

src/main.ts

Lines changed: 153 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
1+
import { App, Plugin, PluginSettingTab, Setting, TFile } from "obsidian";
22
import * as https from "https";
33
import * as http from "http";
44
import forge, { pki } from "node-forge";
55

66
import RequestHandler from "./requestHandler";
77
import { LocalRestApiSettings } from "./types";
8+
import { RenderCacheManager } from "./renderCacheManager";
89

910
import {
1011
DefaultBearerTokenHeaderName,
1112
CERT_NAME,
1213
DEFAULT_SETTINGS,
1314
DefaultBindingHost,
1415
LicenseUrl,
16+
DEFAULT_RENDER_CACHE_SETTINGS,
1517
} from "./constants";
1618
import {
1719
getCertificateIsUptoStandards,
@@ -26,6 +28,7 @@ export default class LocalRestApi extends Plugin {
2628
insecureServer: http.Server | null = null;
2729
requestHandler: RequestHandler;
2830
refreshServerState: () => void;
31+
renderCacheManager: RenderCacheManager | null = null;
2932

3033
async onload() {
3134
this.refreshServerState = this.debounce(
@@ -41,6 +44,35 @@ export default class LocalRestApi extends Plugin {
4144
);
4245
this.requestHandler.setupRouter();
4346

47+
// Initialize render cache manager
48+
this.renderCacheManager = new RenderCacheManager(this.app, {
49+
cacheDirectory: this.settings.renderCacheDirectory ?? ".obsidian/render-cache",
50+
maxCacheSizeMB: this.settings.renderCacheMaxSizeMB ?? 100,
51+
autoCleanup: this.settings.renderCacheAutoCleanup ?? true,
52+
renderTimeoutMs: this.settings.renderCacheTimeoutMs ?? 30000,
53+
});
54+
await this.renderCacheManager.initialize();
55+
56+
// Pass render cache manager to request handler
57+
this.requestHandler.renderCacheManager = this.renderCacheManager;
58+
59+
// Register vault event listeners for cache invalidation
60+
this.registerEvent(
61+
this.app.vault.on("modify", async (file) => {
62+
if (file instanceof TFile && file.extension === "md" && this.renderCacheManager) {
63+
await this.renderCacheManager.invalidate(file);
64+
}
65+
})
66+
);
67+
68+
this.registerEvent(
69+
this.app.vault.on("delete", async (file) => {
70+
if (file instanceof TFile && file.extension === "md" && this.renderCacheManager) {
71+
await this.renderCacheManager.invalidate(file);
72+
}
73+
})
74+
);
75+
4476
if (!this.settings.apiKey) {
4577
this.settings.apiKey = forge.md.sha256
4678
.create()
@@ -192,8 +224,7 @@ export default class LocalRestApi extends Plugin {
192224
);
193225

194226
console.log(
195-
`[REST API] Listening on https://${
196-
this.settings.bindingHost ?? DefaultBindingHost
227+
`[REST API] Listening on https://${this.settings.bindingHost ?? DefaultBindingHost
197228
}:${this.settings.port}/`
198229
);
199230
}
@@ -210,8 +241,7 @@ export default class LocalRestApi extends Plugin {
210241
);
211242

212243
console.log(
213-
`[REST API] Listening on http://${
214-
this.settings.bindingHost ?? DefaultBindingHost
244+
`[REST API] Listening on http://${this.settings.bindingHost ?? DefaultBindingHost
215245
}:${this.settings.insecurePort}/`
216246
);
217247
}
@@ -227,7 +257,12 @@ export default class LocalRestApi extends Plugin {
227257
}
228258

229259
async loadSettings() {
230-
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
260+
this.settings = Object.assign(
261+
{},
262+
DEFAULT_SETTINGS,
263+
DEFAULT_RENDER_CACHE_SETTINGS,
264+
await this.loadData()
265+
);
231266
}
232267

233268
async saveSettings() {
@@ -275,12 +310,12 @@ class LocalRestApiSettingTab extends PluginSettingTab {
275310
"tr",
276311
this.plugin.settings.enableSecureServer === false
277312
? {
278-
cls: "disabled",
279-
title: "Disabled. You can enable this in 'Settings' below.",
280-
}
313+
cls: "disabled",
314+
title: "Disabled. You can enable this in 'Settings' below.",
315+
}
281316
: {
282-
title: "Enabled",
283-
}
317+
title: "Enabled",
318+
}
284319
);
285320
const secureUrl = `https://127.0.0.1:${this.plugin.settings.port}/`;
286321
secureTr.innerHTML = `
@@ -290,9 +325,8 @@ class LocalRestApiSettingTab extends PluginSettingTab {
290325
<td class="name">
291326
Encrypted (HTTPS) API URL<br /><br />
292327
<i>
293-
Requires that <a href="https://127.0.0.1:${
294-
this.plugin.settings.port
295-
}/${CERT_NAME}">this certificate</a> be
328+
Requires that <a href="https://127.0.0.1:${this.plugin.settings.port
329+
}/${CERT_NAME}">this certificate</a> be
296330
configured as a trusted certificate authority for
297331
your browser. See <a href="https://github.com/coddingtonbear/obsidian-web/wiki/How-do-I-get-my-browser-trust-my-Obsidian-Local-REST-API-certificate%3F">wiki</a> for more information.
298332
</i>
@@ -305,9 +339,8 @@ class LocalRestApiSettingTab extends PluginSettingTab {
305339
if (this.plugin.settings.subjectAltNames) {
306340
for (const name of this.plugin.settings.subjectAltNames.split("\n")) {
307341
if (name.trim()) {
308-
const altSecureUrl = `https://${name.trim()}:${
309-
this.plugin.settings.port
310-
}/`;
342+
const altSecureUrl = `https://${name.trim()}:${this.plugin.settings.port
343+
}/`;
311344
secureUrlsTd.innerHTML += `
312345
${altSecureUrl} <a href="javascript:navigator.clipboard.writeText('${altSecureUrl}')">(copy)</a><br />
313346
`;
@@ -319,12 +352,12 @@ class LocalRestApiSettingTab extends PluginSettingTab {
319352
"tr",
320353
this.plugin.settings.enableInsecureServer === false
321354
? {
322-
cls: "disabled",
323-
title: "Disabled. You can enable this in 'Settings' below.",
324-
}
355+
cls: "disabled",
356+
title: "Disabled. You can enable this in 'Settings' below.",
357+
}
325358
: {
326-
title: "Enabled",
327-
}
359+
title: "Enabled",
360+
}
328361
);
329362
const insecureUrl = `http://127.0.0.1:${this.plugin.settings.insecurePort}/`;
330363
insecureTr.innerHTML = `
@@ -342,9 +375,8 @@ class LocalRestApiSettingTab extends PluginSettingTab {
342375
if (this.plugin.settings.subjectAltNames) {
343376
for (const name of this.plugin.settings.subjectAltNames.split("\n")) {
344377
if (name.trim()) {
345-
const altSecureUrl = `http://${name.trim()}:${
346-
this.plugin.settings.insecurePort
347-
}/`;
378+
const altSecureUrl = `http://${name.trim()}:${this.plugin.settings.insecurePort
379+
}/`;
348380
insecureUrlsTd.innerHTML += `
349381
${altSecureUrl} <a href="javascript:navigator.clipboard.writeText('${altSecureUrl}')">(copy)</a><br />
350382
`;
@@ -362,9 +394,8 @@ class LocalRestApiSettingTab extends PluginSettingTab {
362394
text: "For example, the following request will return all notes in the root directory of your vault:",
363395
});
364396
apiKeyDiv.createEl("pre", {
365-
text: `GET /vault/ HTTP/1.1\n${
366-
this.plugin.settings.authorizationHeaderName ?? "Authorization"
367-
}: Bearer ${this.plugin.settings.apiKey}`,
397+
text: `GET /vault/ HTTP/1.1\n${this.plugin.settings.authorizationHeaderName ?? "Authorization"
398+
}: Bearer ${this.plugin.settings.apiKey}`,
368399
});
369400

370401
const seeMore = apiKeyDiv.createEl("p");
@@ -390,10 +421,9 @@ class LocalRestApiSettingTab extends PluginSettingTab {
390421
soonExpiringCertDiv.classList.add("certificate-expiring-soon");
391422
soonExpiringCertDiv.innerHTML = `
392423
<b>Your certificate will expire in ${Math.floor(
393-
remainingCertificateValidityDays
394-
)} day${
395-
Math.floor(remainingCertificateValidityDays) === 1 ? "" : "s"
396-
}s!</b>
424+
remainingCertificateValidityDays
425+
)} day${Math.floor(remainingCertificateValidityDays) === 1 ? "" : "s"
426+
}s!</b>
397427
You should re-generate your certificate below by pressing
398428
the "Re-generate Certificates" button below in
399429
order to continue to connect securely to this API.
@@ -651,7 +681,7 @@ class LocalRestApiSettingTab extends PluginSettingTab {
651681
this.plugin.refreshServerState();
652682
}).setValue(
653683
this.plugin.settings.authorizationHeaderName ??
654-
DefaultBearerTokenHeaderName
684+
DefaultBearerTokenHeaderName
655685
);
656686
});
657687
new Setting(containerEl).setName("Binding Host").addText((cb) => {
@@ -665,10 +695,100 @@ class LocalRestApiSettingTab extends PluginSettingTab {
665695
this.plugin.refreshServerState();
666696
}).setValue(this.plugin.settings.bindingHost ?? DefaultBindingHost);
667697
});
698+
699+
// Render Cache Settings Section
700+
containerEl.createEl("h3", { text: "Render Cache" });
701+
702+
new Setting(containerEl)
703+
.setName("Cache Directory")
704+
.setDesc("Directory to store rendered PDF and text cache (relative to vault root)")
705+
.addText((text) =>
706+
text
707+
.setPlaceholder(".obsidian/render-cache")
708+
.setValue(this.plugin.settings.renderCacheDirectory ?? ".obsidian/render-cache")
709+
.onChange(async (value) => {
710+
this.plugin.settings.renderCacheDirectory = value;
711+
await this.plugin.saveSettings();
712+
})
713+
);
714+
715+
new Setting(containerEl)
716+
.setName("Max Cache Size (MB)")
717+
.setDesc("Maximum cache size in megabytes")
718+
.addText((text) =>
719+
text
720+
.setPlaceholder("100")
721+
.setValue(String(this.plugin.settings.renderCacheMaxSizeMB ?? 100))
722+
.onChange(async (value) => {
723+
const num = parseInt(value);
724+
if (!isNaN(num) && num > 0) {
725+
this.plugin.settings.renderCacheMaxSizeMB = num;
726+
await this.plugin.saveSettings();
727+
}
728+
})
729+
);
730+
731+
new Setting(containerEl)
732+
.setName("Auto Cleanup")
733+
.setDesc("Automatically clean up old cache entries")
734+
.addToggle((toggle) =>
735+
toggle
736+
.setValue(this.plugin.settings.renderCacheAutoCleanup ?? true)
737+
.onChange(async (value) => {
738+
this.plugin.settings.renderCacheAutoCleanup = value;
739+
await this.plugin.saveSettings();
740+
})
741+
);
742+
743+
new Setting(containerEl)
744+
.setName("Render Timeout (ms)")
745+
.setDesc("Maximum time to wait for content to render (milliseconds)")
746+
.addText((text) =>
747+
text
748+
.setPlaceholder("30000")
749+
.setValue(String(this.plugin.settings.renderCacheTimeoutMs ?? 30000))
750+
.onChange(async (value) => {
751+
const num = parseInt(value);
752+
if (!isNaN(num) && num > 0) {
753+
this.plugin.settings.renderCacheTimeoutMs = num;
754+
await this.plugin.saveSettings();
755+
}
756+
})
757+
);
758+
759+
// Cache statistics
760+
if (this.plugin.renderCacheManager) {
761+
this.plugin.renderCacheManager.getCacheStats().then((stats) => {
762+
new Setting(containerEl)
763+
.setName("Cache Statistics")
764+
.setDesc(
765+
`Entries: ${stats.entryCount} | Size: ${stats.totalSizeMB.toFixed(2)} MB`
766+
);
767+
});
768+
769+
new Setting(containerEl)
770+
.setName("Clear Cache")
771+
.setDesc("Delete all cached rendered content")
772+
.addButton((button) =>
773+
button
774+
.setButtonText("Clear Cache")
775+
.setWarning()
776+
.onClick(async () => {
777+
if (this.plugin.renderCacheManager) {
778+
await this.plugin.renderCacheManager.clearCache();
779+
this.display(); // Refresh display
780+
}
781+
})
782+
);
783+
}
784+
668785
}
669786
}
787+
788+
670789
}
671790

791+
672792
export const getAPI = (
673793
app: App,
674794
manifest: PluginManifest

0 commit comments

Comments
 (0)