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")} + + @@ -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*/` +
+ + +
+ ${t("ocr.extracted_text_title")} +
+ +
+ +
+
`; + +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);