diff --git a/apps/client/src/stylesheets/theme-next/base.css b/apps/client/src/stylesheets/theme-next/base.css
index 7bf9423138..c1d3cc3094 100644
--- a/apps/client/src/stylesheets/theme-next/base.css
+++ b/apps/client/src/stylesheets/theme-next/base.css
@@ -544,14 +544,11 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
vertical-align: middle;
}
-#toast-container .toast .toast-header .btn-close {
+#toast-container .toast .toast-header .btn-close,
+#toast-container .toast .toast-close .btn-close {
margin: 0 0 0 12px;
}
-#toast-container .toast.no-title {
- flex-direction: row;
-}
-
#toast-container .toast .toast-body {
flex-grow: 1;
overflow: hidden;
diff --git a/apps/client/src/stylesheets/theme-next/dialogs.css b/apps/client/src/stylesheets/theme-next/dialogs.css
index 613fb94f36..94b6ff21a1 100644
--- a/apps/client/src/stylesheets/theme-next/dialogs.css
+++ b/apps/client/src/stylesheets/theme-next/dialogs.css
@@ -26,7 +26,8 @@
.modal .modal-header .btn-close,
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button,
-#toast-container .toast .toast-header .btn-close {
+#toast-container .toast .toast-header .btn-close,
+#toast-container .toast .toast-close .btn-close {
display: flex;
justify-content: center;
align-items: center;
@@ -46,12 +47,14 @@
}
.modal .modal-header .btn-close,
-#toast-container .toast .toast-header .btn-close {
+#toast-container .toast .toast-header .btn-close,
+#toast-container .toast .toast-close .btn-close {
--modal-control-button-hover-background: var(--modal-close-button-hover-background);
}
.modal .modal-header .btn-close::after,
-#toast-container .toast .toast-header .btn-close::after {
+#toast-container .toast .toast-header .btn-close::after,
+#toast-container .toast .toast-close .btn-close::after {
content: "\ec8d";
font-family: boxicons;
}
@@ -67,7 +70,8 @@
.modal .modal-header .btn-close:hover,
.modal .modal-header .help-button:hover,
.modal .modal-header .custom-title-bar-button:hover,
-#toast-container .toast .toast-header .btn-close:hover {
+#toast-container .toast .toast-header .btn-close:hover,
+#toast-container .toast .toast-close .btn-close:hover {
background: var(--modal-control-button-hover-background);
color: var(--modal-control-button-hover-color);
}
@@ -75,19 +79,22 @@
.modal .modal-header .btn-close:active,
.modal .modal-header .help-button:active,
.modal .modal-header .custom-title-bar-button:active,
-#toast-container .toast .toast-header .btn-close:active {
+#toast-container .toast .toast-header .btn-close:active,
+#toast-container .toast .toast-close .btn-close:active {
transform: scale(.85);
}
.modal .modal-header .btn-close:focus,
.modal .modal-header .help-button:focus,
-#toast-container .toast .toast-header .btn-close:focus {
+#toast-container .toast .toast-header .btn-close:focus,
+#toast-container .toast .toast-close .btn-close:focus {
box-shadow: none !important;
}
.modal .modal-header .btn-close:focus-visible,
.modal .modal-header .help-button:focus-visible,
-#toast-container .toast .toast-header .btn-close:focus-visible {
+#toast-container .toast .toast-header .btn-close:focus-visible,
+#toast-container .toast .toast-close .btn-close:focus-visible {
outline: 2px solid var(--input-focus-outline-color);
outline-offset: 2px;
}
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index ce49038fb5..e051f8f720 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -2093,7 +2093,10 @@
"process_now": "Process OCR",
"processing": "Processing...",
"processing_started": "OCR processing has been started. Please wait a moment and refresh.",
+ "processing_complete": "OCR processing complete.",
"processing_failed": "Failed to start OCR processing",
+ "text_filtered_low_confidence": "OCR detected text with {{confidence}}% confidence, but it was discarded because your minimum threshold is {{threshold}}%.",
+ "open_media_settings": "Open Settings",
"view_extracted_text": "View extracted text (OCR)"
},
"command_palette": {
diff --git a/apps/client/src/widgets/Toast.css b/apps/client/src/widgets/Toast.css
index b58e6a13ab..95b9f69157 100644
--- a/apps/client/src/widgets/Toast.css
+++ b/apps/client/src/widgets/Toast.css
@@ -28,9 +28,10 @@
overflow: hidden;
}
-.toast.no-title {
+.toast.no-title .toast-main-row {
display: flex;
flex-direction: row;
+ align-items: center;
}
.toast.no-title .toast-icon {
@@ -40,22 +41,26 @@
}
.toast.no-title .toast-body {
- padding-inline-start: 0;
- padding-inline-end: 0;
+ flex: 1;
+ padding-block: var(--bs-toast-padding-y);
+ padding-inline: 0;
}
-.toast.no-title .toast-header {
- background-color: unset !important;
+.toast.no-title .toast-close {
+ display: flex;
+ align-items: center;
+ padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
}
.toast {
.toast-buttons {
- padding: 0 1em 1em 1em;
+ padding: 0 var(--bs-toast-padding-x) var(--bs-toast-padding-y) var(--bs-toast-padding-x);
display: flex;
- gap: 1em;
- justify-content: space-between;
+ flex-direction: column;
+ gap: 0.5em;
.btn {
+ width: 100%;
color: var(--bs-toast-color);
background: var(--modal-control-button-background);
diff --git a/apps/client/src/widgets/Toast.tsx b/apps/client/src/widgets/Toast.tsx
index f0321345a2..c251b900a3 100644
--- a/apps/client/src/widgets/Toast.tsx
+++ b/apps/client/src/widgets/Toast.tsx
@@ -42,21 +42,24 @@ function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOp
id={`toast-${id}`}
>
{title ? (
-
+ <>
+
+ {message}
+ >
) : (
- {toastIcon}
+
+
{toastIcon}
+
{message}
+
{closeButton}
+
)}
- {message}
-
- {!title && }
-
{buttons && (
{buttons.map(({ text, onClick }) => (
diff --git a/apps/client/src/widgets/type_widgets/ReadOnlyTextRepresentation.tsx b/apps/client/src/widgets/type_widgets/ReadOnlyTextRepresentation.tsx
index 9ee376f977..a5bada9f6b 100644
--- a/apps/client/src/widgets/type_widgets/ReadOnlyTextRepresentation.tsx
+++ b/apps/client/src/widgets/type_widgets/ReadOnlyTextRepresentation.tsx
@@ -1,11 +1,13 @@
import "./ReadOnlyTextRepresentation.css";
-import type { TextRepresentationResponse } from "@triliumnext/commons";
+import type { OCRProcessResponse, TextRepresentationResponse } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks";
+import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import server from "../../services/server";
import toast from "../../services/toast";
+import { randomString } from "../../services/utils";
import { TypeWidgetProps } from "./type_widget";
type State =
@@ -62,10 +64,35 @@ export function TextRepresentation({ textUrl, processUrl }: TextRepresentationPr
async function processOCR() {
setProcessing(true);
try {
- const response = await server.post<{ success: boolean; message?: string }>(processUrl, { forceReprocess: true });
+ const response = await server.post(processUrl, { forceReprocess: true });
if (response.success) {
- toast.showMessage(t("ocr.processing_started"));
- setTimeout(fetchText, 2000);
+ const result = response.result;
+ const minConfidence = response.minConfidence ?? 0;
+
+ // Check if text was filtered due to low confidence
+ if (result && !result.text && result.confidence > 0 && minConfidence > 0) {
+ const confidencePercent = Math.round(result.confidence * 100);
+ const thresholdPercent = Math.round(minConfidence * 100);
+ toast.showPersistent({
+ id: `ocr-low-confidence-${randomString(8)}`,
+ icon: "bx bx-info-circle",
+ message: t("ocr.text_filtered_low_confidence", {
+ confidence: confidencePercent,
+ threshold: thresholdPercent
+ }),
+ timeout: 15000,
+ buttons: [{
+ text: t("ocr.open_media_settings"),
+ onClick: ({ dismissToast }) => {
+ appContext.tabManager.openInNewTab("_optionsMedia", null, true);
+ dismissToast();
+ }
+ }]
+ });
+ } else {
+ toast.showMessage(t("ocr.processing_complete"));
+ }
+ setTimeout(fetchText, 500);
} else {
toast.showError(response.message || t("ocr.processing_failed"));
}
diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts
index 2e5f82c506..d080036b11 100644
--- a/apps/desktop/scripts/build.ts
+++ b/apps/desktop/scripts/build.ts
@@ -16,7 +16,7 @@ async function main() {
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
// Copy node modules dependencies
- build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path", "@electron/remote" ]);
+ build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path", "@electron/remote", "tesseract.js" ]);
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
build.buildFrontend();
diff --git a/apps/server/scripts/build.ts b/apps/server/scripts/build.ts
index 9fa1f9cb83..e8a27f66c3 100644
--- a/apps/server/scripts/build.ts
+++ b/apps/server/scripts/build.ts
@@ -11,7 +11,7 @@ async function main() {
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
// Copy node modules dependencies
- build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
+ build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path", "tesseract.js" ]);
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
build.buildFrontend();
diff --git a/apps/server/src/routes/api/ocr.ts b/apps/server/src/routes/api/ocr.ts
index 65618b7db8..1177634d00 100644
--- a/apps/server/src/routes/api/ocr.ts
+++ b/apps/server/src/routes/api/ocr.ts
@@ -1,10 +1,16 @@
-import { TextRepresentationResponse } from "@triliumnext/commons";
+import type { OCRProcessResponse, TextRepresentationResponse } from "@triliumnext/commons";
import type { Request } from "express";
import becca from "../../becca/becca.js";
import ocrService from "../../services/ocr/ocr_service.js";
+import options from "../../services/options.js";
import sql from "../../services/sql.js";
+function getMinConfidenceThreshold(): number {
+ const minConfidence = options.getOption('ocrMinConfidence') ?? 0;
+ return parseFloat(minConfidence);
+}
+
/**
* @swagger
* /api/ocr/process-note/{noteId}:
@@ -48,7 +54,7 @@ import sql from "../../services/sql.js";
* - session: []
* tags: ["ocr"]
*/
-async function processNoteOCR(req: Request<{ noteId: string }>) {
+async function processNoteOCR(req: Request<{ noteId: string }>): Promise {
const { noteId } = req.params;
const { language, forceReprocess = false } = req.body || {};
@@ -62,7 +68,11 @@ async function processNoteOCR(req: Request<{ noteId: string }>) {
return [400, { success: false, message: 'Note is not an image or has unsupported format' }];
}
- return { success: true, result };
+ return {
+ success: true,
+ result,
+ minConfidence: getMinConfidenceThreshold()
+ };
}
/**
@@ -108,7 +118,7 @@ async function processNoteOCR(req: Request<{ noteId: string }>) {
* - session: []
* tags: ["ocr"]
*/
-async function processAttachmentOCR(req: Request<{ attachmentId: string }>) {
+async function processAttachmentOCR(req: Request<{ attachmentId: string }>): Promise {
const { attachmentId } = req.params;
const { language, forceReprocess = false } = req.body || {};
@@ -122,7 +132,11 @@ async function processAttachmentOCR(req: Request<{ attachmentId: string }>) {
return [400, { success: false, message: 'Attachment is not an image or has unsupported format' }];
}
- return { success: true, result };
+ return {
+ success: true,
+ result,
+ minConfidence: getMinConfidenceThreshold()
+ };
}
/**
diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts
index ee59c1b595..bf9a7c6c1e 100644
--- a/apps/server/src/services/app_info.ts
+++ b/apps/server/src/services/app_info.ts
@@ -6,7 +6,7 @@ import build from "./build.js";
import dataDir from "./data_dir.js";
const APP_DB_VERSION = 236;
-const SYNC_VERSION = 37;
+const SYNC_VERSION = 38;
const CLIPPER_PROTOCOL_VERSION = "1.0";
export default {
diff --git a/flake.nix b/flake.nix
index 5c206301d4..55f299b854 100644
--- a/flake.nix
+++ b/flake.nix
@@ -151,9 +151,10 @@
runHook postInstall
'';
- # This file is a symlink into /build which is not allowed.
+ # Symlinks pointing to /build directory are not allowed in the Nix store.
+ # This removes all dangling symlinks that point to the temporary build directory.
postFixup = ''
- find $out/opt -name prebuild-install -path "*/better-sqlite3/node_modules/.bin/*" -delete || true
+ find $out/opt -type l -lname '/build/*' -delete || true
'';
components = [
diff --git a/package.json b/package.json
index 76c682174e..5bf4b146e6 100644
--- a/package.json
+++ b/package.json
@@ -158,6 +158,7 @@
"handlebars@<4.7.9": ">=4.7.9",
"qs@<6.14.2": ">=6.14.2",
"minimatch@<3.1.4": "^3.1.4",
+ "minimatch@3>brace-expansion": "^1.1.13",
"serialize-javascript@<7.0.5": ">=7.0.5",
"webpack@<5.104.1": ">=5.104.1"
},
diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts
index 48abb76404..84b16482bb 100644
--- a/packages/commons/src/lib/server_api.ts
+++ b/packages/commons/src/lib/server_api.ts
@@ -295,6 +295,20 @@ export interface TextRepresentationResponse {
message?: string;
}
+export interface OCRProcessResponse {
+ success: boolean;
+ message?: string;
+ result?: {
+ text: string;
+ confidence: number;
+ extractedAt: string;
+ language?: string;
+ pageCount?: number;
+ };
+ /** The minimum confidence threshold that was applied (0-1 scale). */
+ minConfidence?: number;
+}
+
export interface IconRegistry {
sources: {
prefix: string;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc66dfdb2e..cb48c91293 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -61,6 +61,7 @@ overrides:
handlebars@<4.7.9: '>=4.7.9'
qs@<6.14.2: '>=6.14.2'
minimatch@<3.1.4: ^3.1.4
+ minimatch@3>brace-expansion: ^1.1.13
serialize-javascript@<7.0.5: '>=7.0.5'
webpack@<5.104.1: '>=5.104.1'
@@ -7147,6 +7148,9 @@ packages:
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
balanced-match@4.0.3:
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
engines: {node: 20 || >=22}
@@ -7275,6 +7279,9 @@ packages:
bplist-creator@0.0.8:
resolution: {integrity: sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==}
+ brace-expansion@1.1.13:
+ resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
+
brace-expansion@5.0.2:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
@@ -7761,6 +7768,9 @@ packages:
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
engines: {node: '>= 0.8.0'}
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
concat-stream@1.6.2:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
engines: {'0': node >= 0.8}
@@ -22478,6 +22488,8 @@ snapshots:
bail@2.0.2: {}
+ balanced-match@1.0.2: {}
+
balanced-match@4.0.3: {}
bare-events@2.7.0: {}
@@ -22618,6 +22630,11 @@ snapshots:
stream-buffers: 2.2.0
optional: true
+ brace-expansion@1.1.13:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
brace-expansion@5.0.2:
dependencies:
balanced-match: 4.0.3
@@ -23272,6 +23289,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ concat-map@0.0.1: {}
+
concat-stream@1.6.2:
dependencies:
buffer-from: 1.1.2
@@ -27261,7 +27280,7 @@ snapshots:
minimatch@3.1.5:
dependencies:
- brace-expansion: 5.0.5
+ brace-expansion: 1.1.13
minimatch@5.1.9:
dependencies:
diff --git a/scripts/build-utils.ts b/scripts/build-utils.ts
index 07a93a256e..7349cecb2b 100644
--- a/scripts/build-utils.ts
+++ b/scripts/build-utils.ts
@@ -53,7 +53,8 @@ export default class BuildHelper {
"better-sqlite3",
"pdfjs-dist",
"./xhr-sync-worker.js",
- "vite"
+ "vite",
+ "tesseract.js"
],
metafile: true,
splitting: false,