diff --git a/apps/client/src/components/root_command_executor.ts b/apps/client/src/components/root_command_executor.ts
index 8e7df9494..70d875563 100644
--- a/apps/client/src/components/root_command_executor.ts
+++ b/apps/client/src/components/root_command_executor.ts
@@ -146,6 +146,19 @@ export default class RootCommandExecutor extends Component {
}
}
+ async showNoteOCRTextCommand() {
+ const notePath = appContext.tabManager.getActiveContextNotePath();
+
+ if (notePath) {
+ await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
+ activate: true,
+ viewScope: {
+ viewMode: "ocr"
+ }
+ });
+ }
+ }
+
async showAttachmentsCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 291bf193a..8a5884297 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -674,6 +674,7 @@
"search_in_note": "Search in note",
"note_source": "Note source",
"note_attachments": "Note attachments",
+ "view_ocr_text": "View OCR text",
"open_note_externally": "Open note externally",
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
"open_note_custom": "Open note custom",
@@ -2002,5 +2003,14 @@
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
"new-item": "New item",
"add-column": "Add Column"
+ },
+ "ocr": {
+ "extracted_text_title": "Extracted Text (OCR)",
+ "loading_text": "Loading OCR text...",
+ "no_text_available": "No OCR text available",
+ "no_text_explanation": "This note has not been processed for OCR text extraction or no text was found.",
+ "failed_to_load": "Failed to load OCR text",
+ "extracted_on": "Extracted on: {{date}}",
+ "unknown_date": "Unknown"
}
}
diff --git a/apps/client/src/widgets/buttons/note_actions.ts b/apps/client/src/widgets/buttons/note_actions.ts
index 9bef36f3a..011ba10ef 100644
--- a/apps/client/src/widgets/buttons/note_actions.ts
+++ b/apps/client/src/widgets/buttons/note_actions.ts
@@ -90,6 +90,10 @@ const TPL = /*html*/`
${t("note_actions.note_source")}
+
+ ${t("note_actions.view_ocr_text")}
+
+
@@ -117,6 +121,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
private $printActiveNoteButton!: JQuery;
private $exportAsPdfButton!: JQuery;
private $showSourceButton!: JQuery;
+ private $showOCRTextButton!: JQuery;
private $showAttachmentsButton!: JQuery;
private $renderNoteButton!: JQuery;
private $saveRevisionButton!: JQuery;
@@ -143,6 +148,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
this.$showSourceButton = this.$widget.find(".show-source-button");
+ this.$showOCRTextButton = this.$widget.find(".show-ocr-text-button");
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
this.$renderNoteButton = this.$widget.find(".render-note-button");
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
@@ -190,6 +196,9 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
+
+ // Show OCR text button for notes that could have OCR data (images and files)
+ this.toggleDisabled(this.$showOCRTextButton, ["image", "file"].includes(note.type));
const canPrint = ["text", "code"].includes(note.type);
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
diff --git a/apps/client/src/widgets/note_detail.ts b/apps/client/src/widgets/note_detail.ts
index bb66c39e6..c3018b21d 100644
--- a/apps/client/src/widgets/note_detail.ts
+++ b/apps/client/src/widgets/note_detail.ts
@@ -28,6 +28,7 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
import MindMapWidget from "./type_widgets/mind_map.js";
+import ReadOnlyOCRTextWidget from "./type_widgets/read_only_ocr_text.js";
import utils from "../services/utils.js";
import type { NoteType } from "../entities/fnote.js";
import type TypeWidget from "./type_widgets/type_widget.js";
@@ -55,6 +56,7 @@ const typeWidgetClasses = {
readOnlyText: ReadOnlyTextTypeWidget,
editableCode: EditableCodeTypeWidget,
readOnlyCode: ReadOnlyCodeTypeWidget,
+ readOnlyOCRText: ReadOnlyOCRTextWidget,
file: FileTypeWidget,
image: ImageTypeWidget,
search: NoneTypeWidget,
@@ -85,6 +87,7 @@ type ExtendedNoteType =
| "empty"
| "readOnlyCode"
| "readOnlyText"
+ | "readOnlyOCRText"
| "editableText"
| "editableCode"
| "attachmentDetail"
@@ -223,6 +226,8 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
if (viewScope?.viewMode === "source") {
resultingType = "readOnlyCode";
+ } else if (viewScope?.viewMode === "ocr") {
+ resultingType = "readOnlyOCRText";
} else if (viewScope && viewScope.viewMode === "attachments") {
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
diff --git a/apps/client/src/widgets/type_widgets/read_only_ocr_text.ts b/apps/client/src/widgets/type_widgets/read_only_ocr_text.ts
new file mode 100644
index 000000000..bdb425fed
--- /dev/null
+++ b/apps/client/src/widgets/type_widgets/read_only_ocr_text.ts
@@ -0,0 +1,155 @@
+import type { EventData } from "../../components/app_context.js";
+import type FNote from "../../entities/fnote.js";
+import server from "../../services/server.js";
+import toastService from "../../services/toast.js";
+import { t } from "../../services/i18n.js";
+import TypeWidget from "./type_widget.js";
+
+const TPL = /*html*/`
+`;
+
+interface OCRResponse {
+ success: boolean;
+ text: string;
+ hasOcr: boolean;
+ extractedAt: string | null;
+ error?: string;
+}
+
+export default class ReadOnlyOCRTextWidget extends TypeWidget {
+
+ private $content!: JQuery;
+ private $meta!: JQuery;
+
+ static getType() {
+ return "readOnlyOCRText";
+ }
+
+ doRender() {
+ this.$widget = $(TPL);
+ this.contentSized();
+ this.$content = this.$widget.find(".ocr-text-content");
+ this.$meta = this.$widget.find(".ocr-text-meta");
+
+ super.doRender();
+ }
+
+ async doRefresh(note: FNote) {
+ // Show loading state
+ this.$content.html(`
+ ${t("ocr.loading_text")}
+
`);
+ this.$meta.empty();
+
+ try {
+ const response = await server.get(`ocr/notes/${note.noteId}/text`);
+
+ if (!response.success) {
+ this.showError(response.error || t("ocr.failed_to_load"));
+ return;
+ }
+
+ if (!response.hasOcr || !response.text) {
+ this.$content.html(`
+ ${t("ocr.no_text_available")}
+
`);
+ this.$meta.html(t("ocr.no_text_explanation"));
+ return;
+ }
+
+ // Show the OCR text
+ this.$content.text(response.text);
+
+ // Show metadata
+ const extractedAt = response.extractedAt ? new Date(response.extractedAt).toLocaleString() : t("ocr.unknown_date");
+ this.$meta.html(t("ocr.extracted_on", { date: extractedAt }));
+
+ } catch (error: any) {
+ console.error("Error loading OCR text:", error);
+ this.showError(error.message || t("ocr.failed_to_load"));
+ }
+ }
+
+ private showError(message: string) {
+ this.$content.html(`
+ ${message}
+
`);
+ this.$meta.empty();
+ }
+
+ async executeWithContentElementEvent({ resolve, ntxId }: EventData<"executeWithContentElement">) {
+ if (!this.isNoteContext(ntxId)) {
+ return;
+ }
+
+ await this.initialized;
+ resolve(this.$content);
+ }
+}
diff --git a/apps/server/src/routes/api/ocr.ts b/apps/server/src/routes/api/ocr.ts
index 84817b6d2..a44da203e 100644
--- a/apps/server/src/routes/api/ocr.ts
+++ b/apps/server/src/routes/api/ocr.ts
@@ -2,6 +2,7 @@ import { Request, Response } from "express";
import ocrService from "../../services/ocr/ocr_service.js";
import log from "../../services/log.js";
import becca from "../../becca/becca.js";
+import sql from "../../services/sql.js";
/**
* @swagger
@@ -511,6 +512,94 @@ async function deleteOCRResults(req: Request, res: Response) {
}
}
+/**
+ * @swagger
+ * /api/ocr/notes/{noteId}/text:
+ * get:
+ * summary: Get OCR text for a specific note
+ * operationId: ocr-get-note-text
+ * parameters:
+ * - name: noteId
+ * in: path
+ * required: true
+ * schema:
+ * type: string
+ * description: Note ID to get OCR text for
+ * responses:
+ * 200:
+ * description: OCR text retrieved successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * text:
+ * type: string
+ * description: The extracted OCR text
+ * hasOcr:
+ * type: boolean
+ * description: Whether OCR text exists for this note
+ * extractedAt:
+ * type: string
+ * format: date-time
+ * description: When the OCR was last processed
+ * 404:
+ * description: Note not found
+ * tags: ["ocr"]
+ */
+async function getNoteOCRText(req: Request, res: Response) {
+ try {
+ const { noteId } = req.params;
+
+ const note = becca.getNote(noteId);
+ if (!note) {
+ res.status(404).json({
+ success: false,
+ error: 'Note not found'
+ });
+ (res as any).triliumResponseHandled = true;
+ return;
+ }
+
+ // Get stored OCR result
+ let ocrText: string | null = null;
+ let extractedAt: string | null = null;
+
+ if (note.blobId) {
+ const result = sql.getRow<{
+ ocr_text: string | null;
+ ocr_last_processed: string | null;
+ }>(`
+ SELECT ocr_text, ocr_last_processed
+ FROM blobs
+ WHERE blobId = ?
+ `, [note.blobId]);
+
+ if (result) {
+ ocrText = result.ocr_text;
+ extractedAt = result.ocr_last_processed;
+ }
+ }
+
+ res.json({
+ success: true,
+ text: ocrText || '',
+ hasOcr: !!ocrText,
+ extractedAt: extractedAt
+ });
+ (res as any).triliumResponseHandled = true;
+ } catch (error: unknown) {
+ log.error(`Error getting OCR text for note: ${error instanceof Error ? error.message : String(error)}`);
+ res.status(500).json({
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+ (res as any).triliumResponseHandled = true;
+ }
+}
+
export default {
processNoteOCR,
processAttachmentOCR,
@@ -518,5 +607,6 @@ export default {
batchProcessOCR,
getBatchProgress,
getOCRStats,
- deleteOCRResults
+ deleteOCRResults,
+ getNoteOCRText
};
\ No newline at end of file
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index 65241b123..bdf0bd429 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -394,6 +394,7 @@ function register(app: express.Application) {
asyncApiRoute(GET, "/api/ocr/batch-progress", ocrRoute.getBatchProgress);
asyncApiRoute(GET, "/api/ocr/stats", ocrRoute.getOCRStats);
asyncApiRoute(DEL, "/api/ocr/delete/:blobId", ocrRoute.deleteOCRResults);
+ asyncApiRoute(GET, "/api/ocr/notes/:noteId/text", ocrRoute.getNoteOCRText);
// API Documentation
apiDocsRoute(app);