mirror of
https://github.com/zadam/trilium.git
synced 2025-11-03 20:06:08 +01:00
Compare commits
54 Commits
feat/add-o
...
v0.97.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1ec266ad | ||
|
|
42fedaa241 | ||
|
|
4387bd4c6f | ||
|
|
51e1367b82 | ||
|
|
8bea3f4422 | ||
|
|
0eb2e405ff | ||
|
|
5dbd4a765f | ||
|
|
f6961c7e06 | ||
|
|
8d3ba90072 | ||
|
|
3772412d82 | ||
|
|
84389f467e | ||
|
|
eb41e0f96f | ||
|
|
2d44dff997 | ||
|
|
1483bf3d46 | ||
|
|
064cf6a3ee | ||
|
|
0c0d5eaa0a | ||
|
|
afecb33b5c | ||
|
|
fbb1e3a302 | ||
|
|
8704350359 | ||
|
|
d09e725d98 | ||
|
|
8be5b149c4 | ||
|
|
faeea6af18 | ||
|
|
3fa5ea1010 | ||
|
|
6aa31ae125 | ||
|
|
27f2e9c286 | ||
|
|
67cc36fdd2 | ||
|
|
ef7297e03b | ||
|
|
97a5314cdb | ||
|
|
a1195a2856 | ||
|
|
81419c6fe3 | ||
|
|
b8da793353 | ||
|
|
8140fa79cc | ||
|
|
abff4fe67d | ||
|
|
ec8f737eba | ||
|
|
cc6688ea00 | ||
|
|
c448b29be7 | ||
|
|
61bde294b3 | ||
|
|
acab81c61e | ||
|
|
1dd965973b | ||
|
|
d61981033f | ||
|
|
30197ba7ce | ||
|
|
1b6c957334 | ||
|
|
fb7a397bf9 | ||
|
|
133c9c5a7b | ||
|
|
8a587d4d21 | ||
|
|
29b813fa3b | ||
|
|
d5866a99ec | ||
|
|
5289d41b12 | ||
|
|
030178cad2 | ||
|
|
5d00630452 | ||
|
|
eb805bfa2a | ||
|
|
ee3a8e105e | ||
|
|
97fb273e7f | ||
|
|
2ef9009384 |
2
.github/instructions/nx.instructions.md
vendored
2
.github/instructions/nx.instructions.md
vendored
@@ -4,7 +4,7 @@ applyTo: '**'
|
||||
|
||||
// This file is automatically generated by Nx Console
|
||||
|
||||
You are in an nx workspace using Nx 21.3.7 and pnpm as the package manager.
|
||||
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
|
||||
|
||||
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
|
||||
|
||||
|
||||
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@@ -35,6 +35,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
|
||||
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
|
||||
# - run: npx nx-cloud record -- echo Hello World
|
||||
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
|
||||
|
||||
17
README.md
17
README.md
@@ -1,10 +1,9 @@
|
||||
# Trilium Notes
|
||||
|
||||
Donate:  
|
||||
|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
|
||||
 
|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
|
||||
|
||||
@@ -116,6 +115,14 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
|
||||
|
||||
## 💻 Contribute
|
||||
|
||||
### Translations
|
||||
|
||||
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Here's the language coverage we have so far:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Code
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.1",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@stylistic/eslint-plugin": "5.2.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.17.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.8",
|
||||
"typedoc": "0.28.9",
|
||||
"typedoc-plugin-missing-exports": "4.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -39,7 +39,6 @@
|
||||
"i18next": "25.3.2",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery-hotkeys": "0.2.2",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.22",
|
||||
@@ -65,7 +64,7 @@
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.2.8",
|
||||
"@types/tabulator-tables": "6.2.9",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
|
||||
@@ -266,6 +266,72 @@ export type CommandMappings = {
|
||||
jumpToNote: CommandData;
|
||||
commandPalette: CommandData;
|
||||
|
||||
// Keyboard shortcuts
|
||||
backInNoteHistory: CommandData;
|
||||
forwardInNoteHistory: CommandData;
|
||||
forceSaveRevision: CommandData;
|
||||
scrollToActiveNote: CommandData;
|
||||
quickSearch: CommandData;
|
||||
collapseTree: CommandData;
|
||||
createNoteAfter: CommandData;
|
||||
createNoteInto: CommandData;
|
||||
addNoteAboveToSelection: CommandData;
|
||||
addNoteBelowToSelection: CommandData;
|
||||
openNewTab: CommandData;
|
||||
activateNextTab: CommandData;
|
||||
activatePreviousTab: CommandData;
|
||||
openNewWindow: CommandData;
|
||||
toggleTray: CommandData;
|
||||
firstTab: CommandData;
|
||||
secondTab: CommandData;
|
||||
thirdTab: CommandData;
|
||||
fourthTab: CommandData;
|
||||
fifthTab: CommandData;
|
||||
sixthTab: CommandData;
|
||||
seventhTab: CommandData;
|
||||
eigthTab: CommandData;
|
||||
ninthTab: CommandData;
|
||||
lastTab: CommandData;
|
||||
showNoteSource: CommandData;
|
||||
showSQLConsole: CommandData;
|
||||
showBackendLog: CommandData;
|
||||
showCheatsheet: CommandData;
|
||||
showHelp: CommandData;
|
||||
addLinkToText: CommandData;
|
||||
followLinkUnderCursor: CommandData;
|
||||
insertDateTimeToText: CommandData;
|
||||
pasteMarkdownIntoText: CommandData;
|
||||
cutIntoNote: CommandData;
|
||||
addIncludeNoteToText: CommandData;
|
||||
editReadOnlyNote: CommandData;
|
||||
toggleRibbonTabClassicEditor: CommandData;
|
||||
toggleRibbonTabBasicProperties: CommandData;
|
||||
toggleRibbonTabBookProperties: CommandData;
|
||||
toggleRibbonTabFileProperties: CommandData;
|
||||
toggleRibbonTabImageProperties: CommandData;
|
||||
toggleRibbonTabOwnedAttributes: CommandData;
|
||||
toggleRibbonTabInheritedAttributes: CommandData;
|
||||
toggleRibbonTabPromotedAttributes: CommandData;
|
||||
toggleRibbonTabNoteMap: CommandData;
|
||||
toggleRibbonTabNoteInfo: CommandData;
|
||||
toggleRibbonTabNotePaths: CommandData;
|
||||
toggleRibbonTabSimilarNotes: CommandData;
|
||||
toggleRightPane: CommandData;
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
openDevTools: CommandData;
|
||||
findInText: CommandData;
|
||||
toggleLeftPane: CommandData;
|
||||
toggleFullscreen: CommandData;
|
||||
zoomOut: CommandData;
|
||||
zoomIn: CommandData;
|
||||
zoomReset: CommandData;
|
||||
copyWithoutFormatting: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
|
||||
|
||||
@@ -30,13 +30,6 @@ interface CreateChildrenResponse {
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (jQuery.hotkeys) {
|
||||
// hot keys are active also inside inputs and content editables
|
||||
jQuery.hotkeys.options.filterInputAcceptingElements = false;
|
||||
jQuery.hotkeys.options.filterContentEditable = false;
|
||||
jQuery.hotkeys.options.filterTextInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
openDevToolsCommand() {
|
||||
|
||||
@@ -146,19 +146,6 @@ 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();
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "jquery-hotkeys";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Options {
|
||||
tooltip?: boolean;
|
||||
trim?: boolean;
|
||||
imageHasZoom?: boolean;
|
||||
showOcrText?: boolean;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
@@ -47,9 +46,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
await renderImage(entity, $renderedContent, options);
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent, options);
|
||||
renderFile(entity, type, $renderedContent);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
@@ -162,7 +161,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
const encodedTitle = encodeURIComponent(entity.title);
|
||||
|
||||
let url;
|
||||
@@ -202,39 +201,9 @@ async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery
|
||||
}
|
||||
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
|
||||
// Add OCR text display for image notes
|
||||
if (entity instanceof FNote && options.showOcrText) {
|
||||
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
|
||||
try {
|
||||
const response = await fetch(`api/ocr/notes/${note.noteId}/text`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.hasOcr && data.text) {
|
||||
const $ocrSection = $(`
|
||||
<div class="ocr-text-section">
|
||||
<div class="ocr-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
|
||||
</div>
|
||||
<div class="ocr-content"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$ocrSection.find('.ocr-content').text(data.text);
|
||||
$content.append($ocrSection);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR API is not available
|
||||
console.debug('Failed to fetch OCR text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -270,11 +239,6 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
||||
$content.append($videoPreview);
|
||||
}
|
||||
|
||||
// Add OCR text display for file notes
|
||||
if (entity instanceof FNote && options.showOcrText) {
|
||||
await addOCRTextIfAvailable(entity, $content);
|
||||
}
|
||||
|
||||
if (entityType === "notes" && "noteId" in entity) {
|
||||
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
||||
// in attachment list
|
||||
|
||||
@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
let dismissTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function setupGlobalTooltip() {
|
||||
$(document).on("mouseenter", "a", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
|
||||
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
|
||||
$(document).on("click", (e) => {
|
||||
|
||||
323
apps/client/src/services/shortcuts.spec.ts
Normal file
323
apps/client/src/services/shortcuts.spec.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
default: {
|
||||
isDesktop: () => true
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock jQuery globally since it's used in the shortcuts module
|
||||
const mockElement = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
};
|
||||
|
||||
const mockJQuery = vi.fn(() => [mockElement]);
|
||||
(mockJQuery as any).length = 1;
|
||||
mockJQuery[0] = mockElement;
|
||||
|
||||
(global as any).$ = mockJQuery as any;
|
||||
global.document = mockElement as any;
|
||||
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any active bindings after each test
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
});
|
||||
|
||||
describe("normalizeShortcut", () => {
|
||||
it("should normalize shortcut to lowercase and remove whitespace", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
|
||||
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
|
||||
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
|
||||
});
|
||||
|
||||
it("should handle empty or null shortcuts", () => {
|
||||
expect(shortcuts.normalizeShortcut("")).toBe("");
|
||||
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
|
||||
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should handle shortcuts with multiple spaces", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
|
||||
});
|
||||
|
||||
it("should warn about malformed shortcuts", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
shortcuts.normalizeShortcut("ctrl+");
|
||||
shortcuts.normalizeShortcut("+a");
|
||||
shortcuts.normalizeShortcut("ctrl++a");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
const event = createKeyboardEvent("a", "KeyA");
|
||||
expect(keyMatches(event, "a")).toBe(true);
|
||||
expect(keyMatches(event, "A")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match number keys using digit codes", () => {
|
||||
const event = createKeyboardEvent("1", "Digit1");
|
||||
expect(keyMatches(event, "1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match special keys using key mapping", () => {
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
|
||||
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
|
||||
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
|
||||
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
|
||||
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match function keys", () => {
|
||||
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
|
||||
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle undefined or null keys", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
|
||||
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesShortcut", () => {
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
code?: string;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
}) => ({
|
||||
key: options.key,
|
||||
code: options.code || `Key${options.key.toUpperCase()}`,
|
||||
ctrlKey: options.ctrlKey || false,
|
||||
altKey: options.altKey || false,
|
||||
shiftKey: options.shiftKey || false,
|
||||
metaKey: options.metaKey || false
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match simple key shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match shortcuts with modifiers", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
|
||||
|
||||
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
|
||||
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match complex modifier combinations", () => {
|
||||
const event = createKeyboardEvent({
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
});
|
||||
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match when modifiers don't match", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "alt+a")).toBe(false);
|
||||
expect(matchesShortcut(event, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle alternative modifier names", () => {
|
||||
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
|
||||
|
||||
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
|
||||
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
|
||||
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty or invalid shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "")).toBe(false);
|
||||
expect(matchesShortcut(event, null as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle invalid events", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(matchesShortcut(null as any, "a")).toBe(false);
|
||||
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should warn about invalid shortcut formats", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
|
||||
matchesShortcut(event, "ctrl+");
|
||||
matchesShortcut(event, "+");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindGlobalShortcut", () => {
|
||||
it("should bind a global shortcut", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove previous bindings when namespace is reused", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindElShortcut", () => {
|
||||
it("should bind shortcut to specific element", () => {
|
||||
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const mockJQueryEl = [mockEl] as any;
|
||||
mockJQueryEl.length = 1;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
const emptyJQuery = [] as any;
|
||||
emptyJQuery.length = 0;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeGlobalShortcut", () => {
|
||||
it("should remove shortcuts for a specific namespace", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handling", () => {
|
||||
it.skip("should call handler when shortcut matches", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
// Get the listener that was registered
|
||||
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// First verify that matchesShortcut works directly
|
||||
const testEvent = {
|
||||
type: "keydown",
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Test matchesShortcut directly first
|
||||
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
|
||||
|
||||
// Now test the actual listener
|
||||
listener(testEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(testEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(testEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler for non-keyboard events", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-keyboard event
|
||||
const event = {
|
||||
type: "click"
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler when shortcut doesn't match", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-matching keydown event
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
shortcut: string;
|
||||
handler: Handler;
|
||||
namespace: string | null;
|
||||
listener: (evt: Event) => void;
|
||||
}
|
||||
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
@@ -15,38 +26,167 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
if (utils.isDesktop()) {
|
||||
keyboardShortcut = normalizeShortcut(keyboardShortcut);
|
||||
|
||||
let eventName = "keydown";
|
||||
|
||||
// If namespace is provided, remove all previous bindings for this namespace
|
||||
if (namespace) {
|
||||
eventName += `.${namespace}`;
|
||||
|
||||
// if there's a namespace, then we replace the existing event handler with the new one
|
||||
$el.off(eventName);
|
||||
removeNamespaceBindings(namespace);
|
||||
}
|
||||
|
||||
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut) {
|
||||
$el.bind(eventName, keyboardShortcut, (e) => {
|
||||
if (handler) {
|
||||
handler(e);
|
||||
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut && handler) {
|
||||
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
|
||||
|
||||
const listener = (evt: Event) => {
|
||||
// Only handle keyboard events
|
||||
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
const e = evt as KeyboardEvent;
|
||||
if (matchesShortcut(e, keyboardShortcut)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
element,
|
||||
shortcut: keyboardShortcut,
|
||||
handler,
|
||||
namespace,
|
||||
listener
|
||||
};
|
||||
|
||||
const key = namespace || 'global';
|
||||
if (!activeBindings.has(key)) {
|
||||
activeBindings.set(key, []);
|
||||
}
|
||||
activeBindings.get(key)!.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||
if (!shortcut) return false;
|
||||
|
||||
// Ensure we have a proper KeyboardEvent with key property
|
||||
if (!e || typeof e.key !== 'string') {
|
||||
console.warn('matchesShortcut called with invalid event:', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = shortcut.toLowerCase().split('+');
|
||||
const key = parts[parts.length - 1]; // Last part is the actual key
|
||||
const modifiers = parts.slice(0, -1); // Everything before is modifiers
|
||||
|
||||
// Defensive check - ensure we have a valid key
|
||||
if (!key || key.trim() === '') {
|
||||
console.warn('Invalid shortcut format:', shortcut);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the main key matches
|
||||
if (!keyMatches(e, key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
|
||||
const expectedAlt = modifiers.includes('alt');
|
||||
const expectedShift = modifiers.includes('shift');
|
||||
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
|
||||
|
||||
return e.ctrlKey === expectedCtrl &&
|
||||
e.altKey === expectedAlt &&
|
||||
e.shiftKey === expectedShift &&
|
||||
e.metaKey === expectedMeta;
|
||||
}
|
||||
|
||||
export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
// Defensive check for undefined/null key
|
||||
if (!key) {
|
||||
console.warn('keyMatches called with undefined/null key');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
const mappedKeys = keyMap[key.toLowerCase()];
|
||||
if (mappedKeys) {
|
||||
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
||||
}
|
||||
|
||||
// For number keys, use the physical key code regardless of modifiers
|
||||
// This works across all keyboard layouts
|
||||
if (key >= '0' && key <= '9') {
|
||||
return e.code === `Digit${key}`;
|
||||
}
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
return e.key.toLowerCase() === key.toLowerCase() ||
|
||||
e.code.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize to the form expected by the jquery.hotkeys.js
|
||||
* Simple normalization - just lowercase and trim whitespace
|
||||
*/
|
||||
function normalizeShortcut(shortcut: string): string {
|
||||
if (!shortcut) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
|
||||
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
|
||||
|
||||
// Warn about potentially problematic shortcuts
|
||||
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
|
||||
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -36,7 +36,9 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
|
||||
const $copyButton = $("<button>")
|
||||
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
|
||||
.attr("title", t("code_block.copy_title"))
|
||||
.on("click", () => {
|
||||
.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isShare) {
|
||||
copyTextWithToast($codeBlock.text());
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "jquery";
|
||||
import "jquery-hotkeys";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
@@ -2251,26 +2251,3 @@ footer.webview-footer button {
|
||||
content: "\ec24";
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ocr-text-section {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: var(--accented-background-color);
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.ocr-content {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -674,7 +674,6 @@
|
||||
"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",
|
||||
@@ -1304,22 +1303,7 @@
|
||||
"enable_image_compression": "Enable image compression",
|
||||
"max_image_dimensions": "Max width / height of an image (image will be resized if it exceeds this setting).",
|
||||
"max_image_dimensions_unit": "pixels",
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)",
|
||||
"ocr_section_title": "Optical Character Recognition (OCR)",
|
||||
"enable_ocr": "Enable OCR for images",
|
||||
"ocr_description": "Automatically extract text from images using OCR technology. This makes image content searchable within your notes.",
|
||||
"ocr_auto_process": "Automatically process new images with OCR",
|
||||
"ocr_language": "OCR Language",
|
||||
"ocr_min_confidence": "Minimum confidence threshold",
|
||||
"ocr_confidence_unit": "(0.0-1.0)",
|
||||
"ocr_confidence_description": "Only extract text with confidence above this threshold. Lower values include more text but may be less accurate.",
|
||||
"batch_ocr_title": "Process Existing Images",
|
||||
"batch_ocr_description": "Process all existing images in your notes with OCR. This may take some time depending on the number of images.",
|
||||
"batch_ocr_start": "Start Batch OCR Processing",
|
||||
"batch_ocr_starting": "Starting batch OCR processing...",
|
||||
"batch_ocr_progress": "Processing {{processed}} of {{total}} images...",
|
||||
"batch_ocr_completed": "Batch OCR completed! Processed {{processed}} images.",
|
||||
"batch_ocr_error": "Error during batch OCR: {{error}}"
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Attachment Erasure Timeout",
|
||||
@@ -2004,20 +1988,6 @@
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "Extracted Text (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",
|
||||
"process_now": "Process OCR",
|
||||
"processing": "Processing...",
|
||||
"processing_started": "OCR processing has been started. Please wait a moment and refresh.",
|
||||
"processing_failed": "Failed to start OCR processing"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"revisions": {
|
||||
"delete_button": ""
|
||||
},
|
||||
"code_block": {
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
}
|
||||
"code_block": {
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
170
apps/client/src/translations/sr/translation.json
Normal file
170
apps/client/src/translations/sr/translation.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "O Trilium Belеškama",
|
||||
"close": "Zatvori",
|
||||
"homepage": "Početna stranica:",
|
||||
"app_version": "Verzija aplikacije:",
|
||||
"db_version": "Verzija baze podataka:",
|
||||
"sync_version": "Verzija sinhronizacije:",
|
||||
"build_date": "Datum izgradnje:",
|
||||
"build_revision": "Revizija izgradnje:",
|
||||
"data_directory": "Direktorijum sa podacima:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritična greška",
|
||||
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Pokretanje vidžeta nije uspelo",
|
||||
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
|
||||
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Pokretanje prilagođene skripte neuspešno",
|
||||
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Dodaj link",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"close": "Zatvori",
|
||||
"note": "Beleška",
|
||||
"search_note": "potražite belešku po njenom imenu",
|
||||
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
|
||||
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
|
||||
"link_title": "Naziv linka",
|
||||
"button_add_link": "Dodaj link <kbd>enter</kbd>"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Izmeni prefiks grane",
|
||||
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
|
||||
"close": "Zatvori",
|
||||
"prefix": "Prefiks: ",
|
||||
"save": "Sačuvaj",
|
||||
"branch_prefix_saved": "Prefiks grane je sačuvan."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Grupne akcije",
|
||||
"close": "Zatvori",
|
||||
"affected_notes": "Pogođene beleške",
|
||||
"include_descendants": "Obuhvati potomke izabranih beleški",
|
||||
"available_actions": "Dostupne akcije",
|
||||
"chosen_actions": "Izabrane akcije",
|
||||
"execute_bulk_actions": "Izvrši grupne akcije",
|
||||
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
|
||||
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
|
||||
"labels": "Oznake",
|
||||
"relations": "Odnosi",
|
||||
"notes": "Beleške",
|
||||
"other": "Ostalo"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Klonirajte beleške u...",
|
||||
"close": "Zatvori",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"notes_to_clone": "Beleške za kloniranje",
|
||||
"target_parent_note": "Ciljna nadređena beleška",
|
||||
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
|
||||
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
|
||||
"prefix_optional": "Prefiks (opciono)",
|
||||
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
|
||||
"no_path_to_clone_to": "Nema putanje za kloniranje.",
|
||||
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Potvrda",
|
||||
"close": "Zatvori",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
|
||||
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
|
||||
"also_delete_note": "Takođe obriši belešku"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Obriši pregled beleške",
|
||||
"close": "Zatvori",
|
||||
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
|
||||
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
|
||||
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
|
||||
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
|
||||
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Izvezi belešku",
|
||||
"close": "Zatvori",
|
||||
"export_type_subtree": "Ova beleška i svi njeni potomci",
|
||||
"format_html": "HTML - preporučuje se jer čuva formatiranje",
|
||||
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
|
||||
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
|
||||
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
|
||||
"opml_version_1": "OPML v1.0 - samo običan tekst",
|
||||
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
|
||||
"export_type_single": "Samo ovu belešku bez njenih potomaka",
|
||||
"export": "Izvoz",
|
||||
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
|
||||
"export_status": "Status izvoza",
|
||||
"export_in_progress": "Izvoz u toku: {{progressCount}}",
|
||||
"export_finished_successfully": "Izvoz je uspešno završen.",
|
||||
"format_pdf": "PDF - za namene štampanja ili deljenja."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Zatvori",
|
||||
"noteNavigation": "Navigacija beleški",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
|
||||
"notSet": "nije podešeno",
|
||||
"goBackForwards": "idi u nazad/napred kroz istoriju",
|
||||
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
|
||||
"scrollToActiveNote": "skroluj do aktivne beleške",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - idi do nadređene beleške",
|
||||
"collapseWholeTree": "sakupi celo drvo beleški",
|
||||
"collapseSubTree": "sakupi pod-drvo",
|
||||
"tabShortcuts": "Prečice na karticama",
|
||||
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (ili <kbd>middle mouse click</kbd>) na link beleške otvara belešku u novoj kartici",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (ili <kbd>Shift+middle mouse click</kbd>) na link beleške otvara i aktivira belešku u novoj kartici",
|
||||
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
|
||||
"openEmptyTab": "otvori praznu karticu",
|
||||
"closeActiveTab": "zatvori aktivnu karticu",
|
||||
"activateNextTab": "aktiviraj narednu karticu",
|
||||
"activatePreviousTab": "aktiviraj prethodnu karticu",
|
||||
"creatingNotes": "Pravljenje beleški",
|
||||
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
|
||||
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
|
||||
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
|
||||
"movingCloningNotes": "Premeštanje / kloniranje beleški",
|
||||
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
|
||||
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
|
||||
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
|
||||
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - izaberi belešku",
|
||||
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
|
||||
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
|
||||
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
|
||||
"deleteNotes": "obriši belešku / podstablo",
|
||||
"editingNotes": "Izmena beleški",
|
||||
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - napravi / izmeni spoljašnji link",
|
||||
"createInternalLink": "napravi unutrašnji link",
|
||||
"followLink": "prati link ispod kursora",
|
||||
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
|
||||
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
|
||||
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
|
||||
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
|
||||
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
|
||||
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
|
||||
"troubleshooting": "Rešavanje problema",
|
||||
"reloadFrontend": "ponovo učitaj Trilium frontend",
|
||||
"showDevTools": "prikaži alate za programere",
|
||||
"showSQLConsole": "prikaži SQL konzolu",
|
||||
"other": "Ostalo",
|
||||
"quickSearch": "fokus na unos za brzu pretragu",
|
||||
"inPageSearch": "pretraga unutar stranice"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
10
apps/client/src/types.d.ts
vendored
10
apps/client/src/types.d.ts
vendored
@@ -97,16 +97,6 @@ declare global {
|
||||
setNote(noteId: string);
|
||||
}
|
||||
|
||||
interface JQueryStatic {
|
||||
hotkeys: {
|
||||
options: {
|
||||
filterInputAcceptingElements: boolean;
|
||||
filterContentEditable: boolean;
|
||||
filterTextInputs: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var logError: (message: string, e?: Error | string) => void;
|
||||
var logInfo: (message: string) => void;
|
||||
var glob: CustomGlobals;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import keyboardActionsService, { type Action } from "../../services/keyboard_actions.js";
|
||||
import keyboardActionsService from "../../services/keyboard_actions.js";
|
||||
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
|
||||
import type { ButtonNoteIdProvider } from "./button_from_note.js";
|
||||
|
||||
let actions: Action[];
|
||||
let actions: ActionKeyboardShortcut[];
|
||||
|
||||
keyboardActionsService.getActions().then((as) => (actions = as));
|
||||
|
||||
@@ -49,7 +50,7 @@ export default class CommandButtonWidget extends AbstractButtonWidget<CommandBut
|
||||
|
||||
const action = actions.find((act) => act.actionName === this._command);
|
||||
|
||||
if (action && action.effectiveShortcuts.length > 0) {
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title;
|
||||
|
||||
@@ -90,10 +90,6 @@ const TPL = /*html*/`
|
||||
<span class="bx bx-code"></span> ${t("note_actions.note_source")}<kbd data-command="showNoteSource"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="showNoteOCRText" class="dropdown-item show-ocr-text-button">
|
||||
<span class="bx bx-text"></span> ${t("note_actions.view_ocr_text")}<kbd data-command="showNoteOCRText"></kbd>
|
||||
</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
@@ -121,7 +117,6 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
private $printActiveNoteButton!: JQuery<HTMLElement>;
|
||||
private $exportAsPdfButton!: JQuery<HTMLElement>;
|
||||
private $showSourceButton!: JQuery<HTMLElement>;
|
||||
private $showOCRTextButton!: JQuery<HTMLElement>;
|
||||
private $showAttachmentsButton!: JQuery<HTMLElement>;
|
||||
private $renderNoteButton!: JQuery<HTMLElement>;
|
||||
private $saveRevisionButton!: JQuery<HTMLElement>;
|
||||
@@ -148,7 +143,6 @@ 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");
|
||||
@@ -192,13 +186,10 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
|
||||
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
|
||||
|
||||
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);
|
||||
|
||||
@@ -268,7 +268,7 @@ export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
const action = actions.find((act) => act.actionName === toggleCommandName);
|
||||
const title = $(this).attr("data-title");
|
||||
|
||||
if (action && action.effectiveShortcuts.length > 0) {
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title ?? "";
|
||||
|
||||
@@ -187,7 +187,7 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
}
|
||||
}
|
||||
|
||||
showInFullText(e: JQuery.TriggeredEvent) {
|
||||
showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
|
||||
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -97,6 +97,7 @@ const TPL = /*html*/`
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const SUPPORTED_NOTE_TYPES = ["text", "code", "render", "mindMap", "doc"];
|
||||
export default class FindWidget extends NoteContextAwareWidget {
|
||||
|
||||
private searchTerm: string | null;
|
||||
@@ -188,7 +189,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["text", "code", "render", "mindMap"].includes(this.note?.type ?? "")) {
|
||||
if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,6 +252,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
const readOnly = await this.noteContext?.isReadOnly();
|
||||
return readOnly ? this.htmlHandler : this.textHandler;
|
||||
case "mindMap":
|
||||
case "doc":
|
||||
return this.htmlHandler;
|
||||
default:
|
||||
console.warn("FindWidget: Unsupported note type for find widget", this.note?.type);
|
||||
@@ -354,7 +356,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && ["text", "code", "render", "mindMap"].includes(this.note?.type ?? "");
|
||||
return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "");
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
|
||||
@@ -28,7 +28,6 @@ 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";
|
||||
@@ -56,7 +55,6 @@ const typeWidgetClasses = {
|
||||
readOnlyText: ReadOnlyTextTypeWidget,
|
||||
editableCode: EditableCodeTypeWidget,
|
||||
readOnlyCode: ReadOnlyCodeTypeWidget,
|
||||
readOnlyOCRText: ReadOnlyOCRTextWidget,
|
||||
file: FileTypeWidget,
|
||||
image: ImageTypeWidget,
|
||||
search: NoneTypeWidget,
|
||||
@@ -87,7 +85,6 @@ type ExtendedNoteType =
|
||||
| "empty"
|
||||
| "readOnlyCode"
|
||||
| "readOnlyText"
|
||||
| "readOnlyOCRText"
|
||||
| "editableText"
|
||||
| "editableCode"
|
||||
| "attachmentDetail"
|
||||
@@ -226,8 +223,6 @@ 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())) {
|
||||
|
||||
@@ -727,9 +727,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
for (const key in hotKeys) {
|
||||
const handler = hotKeys[key];
|
||||
|
||||
$(this.tree.$container).on("keydown", null, key, (evt) => {
|
||||
shortcutService.bindElShortcut($(this.tree.$container), key, () => {
|
||||
const node = this.tree.getActiveNode();
|
||||
return handler(node, evt);
|
||||
return handler(node, {} as JQuery.KeyDownEvent);
|
||||
// return false from the handler will stop default handling.
|
||||
});
|
||||
}
|
||||
@@ -1552,7 +1552,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const hotKeyMap: Record<string, (node: Fancytree.FancytreeNode, e: JQuery.KeyDownEvent) => boolean> = {};
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
hotKeyMap[shortcutService.normalizeShortcut(shortcut)] = (node) => {
|
||||
const notePath = treeService.getNotePath(node);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
@@ -11,43 +9,6 @@ const TPL = /*html*/`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.batch-ocr-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.batch-ocr-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ocr-language-checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
.ocr-language-display {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ocr-language-display .placeholder-text {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
.ocr-language-display .language-code {
|
||||
background-color: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h4>${t("images.images_section_title")}</h4>
|
||||
@@ -83,123 +44,6 @@ const TPL = /*html*/`
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5>${t("images.ocr_section_title")}</h5>
|
||||
|
||||
<label class="tn-checkbox">
|
||||
<input class="ocr-enabled" type="checkbox" name="ocr-enabled">
|
||||
${t("images.enable_ocr")}
|
||||
</label>
|
||||
|
||||
<p class="form-text">${t("images.ocr_description")}</p>
|
||||
|
||||
<div class="ocr-settings-wrapper">
|
||||
<label class="tn-checkbox">
|
||||
<input class="ocr-auto-process" type="checkbox" name="ocr-auto-process">
|
||||
${t("images.ocr_auto_process")}
|
||||
</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("images.ocr_language")}</label>
|
||||
<p class="form-text">${t("images.ocr_multi_language_description")}</p>
|
||||
<div class="ocr-language-checkboxes">
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="eng" data-language="eng">
|
||||
English
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="spa" data-language="spa">
|
||||
Spanish
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="fra" data-language="fra">
|
||||
French
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="deu" data-language="deu">
|
||||
German
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ita" data-language="ita">
|
||||
Italian
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="por" data-language="por">
|
||||
Portuguese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="rus" data-language="rus">
|
||||
Russian
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="chi_sim" data-language="chi_sim">
|
||||
Chinese (Simplified)
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="chi_tra" data-language="chi_tra">
|
||||
Chinese (Traditional)
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="jpn" data-language="jpn">
|
||||
Japanese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="kor" data-language="kor">
|
||||
Korean
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ara" data-language="ara">
|
||||
Arabic
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="hin" data-language="hin">
|
||||
Hindi
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="tha" data-language="tha">
|
||||
Thai
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="vie" data-language="vie">
|
||||
Vietnamese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ron" data-language="ron">
|
||||
Romanian
|
||||
</label>
|
||||
</div>
|
||||
<div class="ocr-language-display form-control" readonly>
|
||||
<span class="placeholder-text">${t("images.ocr_no_languages_selected")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("images.ocr_min_confidence")}</label>
|
||||
<label class="input-group tn-number-unit-pair">
|
||||
<input class="ocr-min-confidence form-control options-number-input" type="number" min="0" max="1" step="0.1">
|
||||
<span class="input-group-text">${t("images.ocr_confidence_unit")}</span>
|
||||
</label>
|
||||
<div class="form-text">${t("images.ocr_confidence_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-ocr-section">
|
||||
<h6>${t("images.batch_ocr_title")}</h6>
|
||||
<p class="form-text">${t("images.batch_ocr_description")}</p>
|
||||
|
||||
<button class="btn btn-primary batch-ocr-button">
|
||||
${t("images.batch_ocr_start")}
|
||||
</button>
|
||||
|
||||
<div class="batch-ocr-progress" style="display: none;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="batch-ocr-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -211,22 +55,9 @@ export default class ImageOptions extends OptionsWidget {
|
||||
private $enableImageCompression!: JQuery<HTMLElement>;
|
||||
private $imageCompressionWrapper!: JQuery<HTMLElement>;
|
||||
|
||||
// OCR elements
|
||||
private $ocrEnabled!: JQuery<HTMLElement>;
|
||||
private $ocrAutoProcess!: JQuery<HTMLElement>;
|
||||
private $ocrLanguageCheckboxes!: JQuery<HTMLElement>;
|
||||
private $ocrLanguageDisplay!: JQuery<HTMLElement>;
|
||||
private $ocrMinConfidence!: JQuery<HTMLElement>;
|
||||
private $ocrSettingsWrapper!: JQuery<HTMLElement>;
|
||||
private $batchOcrButton!: JQuery<HTMLElement>;
|
||||
private $batchOcrProgress!: JQuery<HTMLElement>;
|
||||
private $batchOcrProgressBar!: JQuery<HTMLElement>;
|
||||
private $batchOcrStatus!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
// Image settings
|
||||
this.$imageMaxWidthHeight = this.$widget.find(".image-max-width-height");
|
||||
this.$imageJpegQuality = this.$widget.find(".image-jpeg-quality");
|
||||
|
||||
@@ -245,49 +76,16 @@ export default class ImageOptions extends OptionsWidget {
|
||||
this.updateCheckboxOption("compressImages", this.$enableImageCompression);
|
||||
this.setImageCompression();
|
||||
});
|
||||
|
||||
// OCR settings
|
||||
this.$ocrEnabled = this.$widget.find(".ocr-enabled");
|
||||
this.$ocrAutoProcess = this.$widget.find(".ocr-auto-process");
|
||||
this.$ocrLanguageCheckboxes = this.$widget.find(".ocr-language-checkboxes");
|
||||
this.$ocrLanguageDisplay = this.$widget.find(".ocr-language-display");
|
||||
this.$ocrMinConfidence = this.$widget.find(".ocr-min-confidence");
|
||||
this.$ocrSettingsWrapper = this.$widget.find(".ocr-settings-wrapper");
|
||||
this.$batchOcrButton = this.$widget.find(".batch-ocr-button");
|
||||
this.$batchOcrProgress = this.$widget.find(".batch-ocr-progress");
|
||||
this.$batchOcrProgressBar = this.$widget.find(".progress-bar");
|
||||
this.$batchOcrStatus = this.$widget.find(".batch-ocr-status");
|
||||
|
||||
this.$ocrEnabled.on("change", () => {
|
||||
this.updateCheckboxOption("ocrEnabled", this.$ocrEnabled);
|
||||
this.setOcrVisibility();
|
||||
});
|
||||
|
||||
this.$ocrAutoProcess.on("change", () => this.updateCheckboxOption("ocrAutoProcessImages", this.$ocrAutoProcess));
|
||||
|
||||
this.$ocrLanguageCheckboxes.on("change", "input[type='checkbox']", () => this.updateOcrLanguages());
|
||||
|
||||
this.$ocrMinConfidence.on("change", () => this.updateOption("ocrMinConfidence", String(this.$ocrMinConfidence.val()).trim() || "0.6"));
|
||||
|
||||
this.$batchOcrButton.on("click", () => this.startBatchOcr());
|
||||
}
|
||||
|
||||
optionsLoaded(options: OptionMap) {
|
||||
// Image settings
|
||||
this.$imageMaxWidthHeight.val(options.imageMaxWidthHeight);
|
||||
this.$imageJpegQuality.val(options.imageJpegQuality);
|
||||
|
||||
this.setCheckboxState(this.$downloadImagesAutomatically, options.downloadImagesAutomatically);
|
||||
this.setCheckboxState(this.$enableImageCompression, options.compressImages);
|
||||
|
||||
// OCR settings
|
||||
this.setCheckboxState(this.$ocrEnabled, options.ocrEnabled);
|
||||
this.setCheckboxState(this.$ocrAutoProcess, options.ocrAutoProcessImages);
|
||||
this.setOcrLanguages(options.ocrLanguage || "eng");
|
||||
this.$ocrMinConfidence.val(options.ocrMinConfidence || "0.6");
|
||||
|
||||
this.setImageCompression();
|
||||
this.setOcrVisibility();
|
||||
}
|
||||
|
||||
setImageCompression() {
|
||||
@@ -297,134 +95,4 @@ export default class ImageOptions extends OptionsWidget {
|
||||
this.$imageCompressionWrapper.addClass("disabled-field");
|
||||
}
|
||||
}
|
||||
|
||||
setOcrVisibility() {
|
||||
if (this.$ocrEnabled.prop("checked")) {
|
||||
this.$ocrSettingsWrapper.removeClass("disabled-field");
|
||||
} else {
|
||||
this.$ocrSettingsWrapper.addClass("disabled-field");
|
||||
}
|
||||
}
|
||||
|
||||
setOcrLanguages(languageString: string) {
|
||||
// Clear all checkboxes first
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]').prop('checked', false);
|
||||
|
||||
if (languageString) {
|
||||
// Split by '+' to handle multi-language format like "ron+eng"
|
||||
const languages = languageString.split('+');
|
||||
|
||||
languages.forEach(lang => {
|
||||
const checkbox = this.$ocrLanguageCheckboxes.find(`input[data-language="${lang.trim()}"]`);
|
||||
if (checkbox.length > 0) {
|
||||
checkbox.prop('checked', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.updateOcrLanguageDisplay();
|
||||
}
|
||||
|
||||
updateOcrLanguages() {
|
||||
const selectedLanguages: string[] = [];
|
||||
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]:checked').each(function() {
|
||||
selectedLanguages.push($(this).val() as string);
|
||||
});
|
||||
|
||||
// Join with '+' for Tesseract multi-language format
|
||||
const languageString = selectedLanguages.join('+');
|
||||
|
||||
this.updateOption("ocrLanguage", languageString || "eng");
|
||||
this.updateOcrLanguageDisplay();
|
||||
}
|
||||
|
||||
updateOcrLanguageDisplay() {
|
||||
const selectedLanguages: string[] = [];
|
||||
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]:checked').each(function() {
|
||||
selectedLanguages.push($(this).val() as string);
|
||||
});
|
||||
|
||||
const displayContent = this.$ocrLanguageDisplay.find('.placeholder-text, .language-code');
|
||||
displayContent.remove();
|
||||
|
||||
if (selectedLanguages.length === 0) {
|
||||
this.$ocrLanguageDisplay.html(`<span class="placeholder-text">${t("images.ocr_no_languages_selected")}</span>`);
|
||||
} else {
|
||||
const languageTags = selectedLanguages.map(lang =>
|
||||
`<span class="language-code">${lang}</span>`
|
||||
).join('');
|
||||
this.$ocrLanguageDisplay.html(languageTags);
|
||||
}
|
||||
}
|
||||
|
||||
async startBatchOcr() {
|
||||
this.$batchOcrButton.prop("disabled", true);
|
||||
this.$batchOcrProgress.show();
|
||||
this.$batchOcrProgressBar.css("width", "0%");
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_starting"));
|
||||
|
||||
try {
|
||||
const result = await server.post("ocr/batch-process") as {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
this.pollBatchOcrProgress();
|
||||
} else {
|
||||
throw new Error(result.message || "Failed to start batch OCR");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error starting batch OCR:", error);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_error", { error: error.message }));
|
||||
toastService.showError(`Failed to start batch OCR: ${error.message}`);
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
async pollBatchOcrProgress() {
|
||||
try {
|
||||
const result = await server.get("ocr/batch-progress") as {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
};
|
||||
|
||||
if (result.inProgress) {
|
||||
const progress = (result.processed / result.total) * 100;
|
||||
this.$batchOcrProgressBar.css("width", `${progress}%`);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_progress", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
|
||||
// Continue polling
|
||||
setTimeout(() => this.pollBatchOcrProgress(), 1000);
|
||||
} else {
|
||||
// Batch OCR completed
|
||||
this.$batchOcrProgressBar.css("width", "100%");
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_completed", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
toastService.showMessage(t("images.batch_ocr_completed", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
|
||||
// Hide progress after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.$batchOcrProgress.hide();
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error polling batch OCR progress:", error);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_error", { error: error.message }));
|
||||
toastService.showError(`Failed to get batch OCR progress: ${error.message}`);
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ export default class DateTimeFormatOptions extends OptionsWidget {
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
|
||||
const action = await keyboardActionsService.getAction("insertDateTimeToText");
|
||||
const shortcutKey = (action.effectiveShortcuts ?? []).join(", ");
|
||||
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
|
||||
"title": shortcutKey,
|
||||
"showTooltip": false
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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*/`
|
||||
<div class="note-detail-ocr-text note-detail-printable">
|
||||
<style>
|
||||
.note-detail-ocr-text {
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ocr-text-content {
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--detail-text-font-family);
|
||||
font-size: var(--detail-text-font-size);
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: var(--accented-background-color);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.ocr-text-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.ocr-text-meta {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ocr-text-empty {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.ocr-text-loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.ocr-text-error {
|
||||
color: var(--error-color);
|
||||
background-color: var(--error-background-color);
|
||||
border: 1px solid var(--error-border-color);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ocr-process-button {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ocr-text-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text_title")}
|
||||
</div>
|
||||
|
||||
<div class="ocr-text-content"></div>
|
||||
|
||||
<div class="ocr-text-actions"></div>
|
||||
|
||||
<div class="ocr-text-meta"></div>
|
||||
</div>`;
|
||||
|
||||
interface OCRResponse {
|
||||
success: boolean;
|
||||
text: string;
|
||||
hasOcr: boolean;
|
||||
extractedAt: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default class ReadOnlyOCRTextWidget extends TypeWidget {
|
||||
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private $actions!: JQuery<HTMLElement>;
|
||||
private $meta!: JQuery<HTMLElement>;
|
||||
private currentNote?: FNote;
|
||||
|
||||
static getType() {
|
||||
return "readOnlyOCRText";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$content = this.$widget.find(".ocr-text-content");
|
||||
this.$actions = this.$widget.find(".ocr-text-actions");
|
||||
this.$meta = this.$widget.find(".ocr-text-meta");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.currentNote = note;
|
||||
|
||||
// Show loading state
|
||||
this.$content.html(`<div class="ocr-text-loading">
|
||||
<span class="bx bx-loader-alt bx-spin"></span> ${t("ocr.loading_text")}
|
||||
</div>`);
|
||||
this.$actions.empty();
|
||||
this.$meta.empty();
|
||||
|
||||
try {
|
||||
const response = await server.get<OCRResponse>(`ocr/notes/${note.noteId}/text`);
|
||||
|
||||
if (!response.success) {
|
||||
this.showError(response.error || t("ocr.failed_to_load"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.hasOcr || !response.text) {
|
||||
this.showNoOCRAvailable();
|
||||
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 showNoOCRAvailable() {
|
||||
const $processButton = $(`<button class="btn btn-secondary ocr-process-button" type="button">
|
||||
<span class="bx bx-play"></span> ${t("ocr.process_now")}
|
||||
</button>`);
|
||||
|
||||
$processButton.on("click", () => this.processOCR());
|
||||
|
||||
this.$content.html(`<div class="ocr-text-empty">
|
||||
<span class="bx bx-info-circle"></span> ${t("ocr.no_text_available")}
|
||||
</div>`);
|
||||
|
||||
this.$actions.append($processButton);
|
||||
this.$meta.html(t("ocr.no_text_explanation"));
|
||||
}
|
||||
|
||||
private async processOCR() {
|
||||
if (!this.currentNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $button = this.$actions.find(".ocr-process-button");
|
||||
|
||||
// Disable button and show processing state
|
||||
$button.prop("disabled", true);
|
||||
$button.html(`<span class="bx bx-loader-alt bx-spin"></span> ${t("ocr.processing")}`);
|
||||
|
||||
try {
|
||||
const response = await server.post(`ocr/process-note/${this.currentNote.noteId}`);
|
||||
|
||||
if (response.success) {
|
||||
toastService.showMessage(t("ocr.processing_started"));
|
||||
// Refresh the view after a short delay to allow processing to begin
|
||||
setTimeout(() => {
|
||||
if (this.currentNote) {
|
||||
this.doRefresh(this.currentNote);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(response.error || t("ocr.processing_failed"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error processing OCR:", error);
|
||||
toastService.showError(error.message || t("ocr.processing_failed"));
|
||||
|
||||
// Re-enable button
|
||||
$button.prop("disabled", false);
|
||||
$button.html(`<span class="bx bx-play"></span> ${t("ocr.process_now")}`);
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.$content.html(`<div class="ocr-text-error">
|
||||
<span class="bx bx-error"></span> ${message}
|
||||
</div>`);
|
||||
this.$actions.empty();
|
||||
this.$meta.empty();
|
||||
}
|
||||
|
||||
async executeWithContentElementEvent({ resolve, ntxId }: EventData<"executeWithContentElement">) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialized;
|
||||
resolve(this.$content);
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
|
||||
const $card = $('<div class="note-book-card">')
|
||||
.attr("data-note-id", note.noteId)
|
||||
.addClass("no-tooltip-preview")
|
||||
.append(
|
||||
$('<h5 class="note-book-header">')
|
||||
.append($expander)
|
||||
@@ -351,8 +352,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
|
||||
try {
|
||||
const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, {
|
||||
trim: this.viewType === "grid", // for grid only short content is needed
|
||||
showOcrText: this.parentNote.type === "search" // show OCR text only in search results
|
||||
trim: this.viewType === "grid" // for grid only short content is needed
|
||||
});
|
||||
|
||||
if (this.highlightRegex) {
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "17.2.1",
|
||||
"electron": "37.2.4"
|
||||
"electron": "37.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "main.cjs",
|
||||
@@ -17,7 +17,7 @@
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.4",
|
||||
"electron": "37.2.5",
|
||||
"@electron-forge/cli": "7.8.2",
|
||||
"@electron-forge/maker-deb": "7.8.2",
|
||||
"@electron-forge/maker-dmg": "7.8.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.4",
|
||||
"electron": "37.2.5",
|
||||
"fs-extra": "11.3.0"
|
||||
},
|
||||
"nx": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@@ -34,7 +34,6 @@
|
||||
"@types/stream-throttle": "0.1.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -60,7 +59,7 @@
|
||||
"debounce": "2.2.0",
|
||||
"debug": "4.4.1",
|
||||
"ejs": "3.1.10",
|
||||
"electron": "37.2.4",
|
||||
"electron": "37.2.5",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
@@ -89,7 +88,7 @@
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.5.16",
|
||||
"openai": "5.10.2",
|
||||
"openai": "5.11.0",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
@@ -103,16 +102,12 @@
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"time2fa": "^1.3.0",
|
||||
"tesseract.js": "6.0.1",
|
||||
"tmp": "0.2.3",
|
||||
"turndown": "7.2.0",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "8.18.3",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.0",
|
||||
"officeparser": "5.2.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"sharp": "0.34.3"
|
||||
"yauzl": "3.2.0"
|
||||
},
|
||||
"nx": {
|
||||
"name": "server",
|
||||
|
||||
@@ -107,8 +107,6 @@ CREATE TABLE IF NOT EXISTS "recent_notes"
|
||||
CREATE TABLE IF NOT EXISTS "blobs" (
|
||||
`blobId` TEXT NOT NULL,
|
||||
`content` TEXT NULL DEFAULT NULL,
|
||||
`ocr_text` TEXT DEFAULT NULL,
|
||||
`ocr_last_processed` TEXT DEFAULT NULL,
|
||||
`dateModified` TEXT NOT NULL,
|
||||
`utcDateModified` TEXT NOT NULL,
|
||||
PRIMARY KEY(`blobId`)
|
||||
|
||||
@@ -1,306 +1,390 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Navegar a la nota previa en el historial",
|
||||
"forward-in-note-history": "Navegar a la nota siguiente en el historial",
|
||||
"open-jump-to-note-dialog": "Abrir cuadro de diálogo \"Saltar a nota\"",
|
||||
"scroll-to-active-note": "Desplazarse a la nota activa en el árbol de notas",
|
||||
"quick-search": "Activar barra de búisqueda rápida",
|
||||
"search-in-subtree": "Buscar notas en el subárbol de la nota activa",
|
||||
"expand-subtree": "Expandir el subárbol de la nota actual",
|
||||
"collapse-tree": "Colapsa el árbol de notas completo",
|
||||
"collapse-subtree": "Colapsa el subárbol de la nota actual",
|
||||
"sort-child-notes": "Ordenar subnotas",
|
||||
"creating-and-moving-notes": "Creando y moviendo notas",
|
||||
"create-note-after": "Crear nota después de la nota activa",
|
||||
"create-note-into": "Crear nota como subnota de la nota activa",
|
||||
"create-note-into-inbox": "Crear una nota en la bandeja de entrada (si está definida) o nota del día",
|
||||
"delete-note": "Eliminar nota",
|
||||
"move-note-up": "Mover nota hacia arriba",
|
||||
"move-note-down": "Mover nota hacia abajo",
|
||||
"move-note-up-in-hierarchy": "Mover nota hacia arriba en la jerarquía",
|
||||
"move-note-down-in-hierarchy": "Mover nota hacia abajo en la jerarquía",
|
||||
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
|
||||
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
|
||||
"cloneNotesTo": "Clonar notas seleccionadas",
|
||||
"moveNotesTo": "Mover notas seleccionadas",
|
||||
"note-clipboard": "Portapapeles de notas",
|
||||
"copy-notes-to-clipboard": "Copiar las notas seleccionadas al portapapeles",
|
||||
"paste-notes-from-clipboard": "Pegar las notas del portapapeles en una nota activa",
|
||||
"cut-notes-to-clipboard": "Cortar las notas seleccionadas al portapapeles",
|
||||
"select-all-notes-in-parent": "Seleccionar todas las notas del nivel de la nota actual",
|
||||
"add-note-above-to-the-selection": "Agregar nota arriba de la selección",
|
||||
"add-note-below-to-selection": "Agregar nota arriba de la selección",
|
||||
"duplicate-subtree": "Duplicar subárbol",
|
||||
"tabs-and-windows": "Pestañas y ventanas",
|
||||
"open-new-tab": "Abre una nueva pestaña",
|
||||
"close-active-tab": "Cierra la pestaña activa",
|
||||
"reopen-last-tab": "Vuelve a abrir la última pestaña cerrada",
|
||||
"activate-next-tab": "Activa la pestaña de la derecha",
|
||||
"activate-previous-tab": "Activa la pestaña de la izquierda",
|
||||
"open-new-window": "Abrir nueva ventana vacía",
|
||||
"toggle-tray": "Muestra/Oculta la aplicación en la bandeja del sistema",
|
||||
"first-tab": "Activa la primera pestaña de la lista",
|
||||
"second-tab": "Activa la segunda pestaña de la lista",
|
||||
"third-tab": "Activa la tercera pestaña de la lista",
|
||||
"fourth-tab": "Activa la cuarta pestaña de la lista",
|
||||
"fifth-tab": "Activa la quinta pestaña de la lista",
|
||||
"sixth-tab": "Activa la sexta pestaña de la lista",
|
||||
"seventh-tab": "Activa la séptima pestaña de la lista",
|
||||
"eight-tab": "Activa la octava pestaña de la lista",
|
||||
"ninth-tab": "Activa la novena pestaña de la lista",
|
||||
"last-tab": "Activa la última pestaña de la lista",
|
||||
"dialogs": "Diálogos",
|
||||
"show-note-source": "Muestra el cuadro de diálogo Fuente de nota",
|
||||
"show-options": "Muestra el cuadro de diálogo Opciones",
|
||||
"show-revisions": "Muestra el cuadro de diálogo Revisiones de notas",
|
||||
"show-recent-changes": "Muestra el cuadro de diálogo Cambios recientes",
|
||||
"show-sql-console": "Muestra el cuadro de diálogo Consola SQL",
|
||||
"show-backend-log": "Muestra el cuadro de diálogo Registro de backend",
|
||||
"show-help": "Muestra ayuda/hoja de referencia integrada",
|
||||
"show-cheatsheet": "Muestra un modal con operaciones de teclado comunes",
|
||||
"text-note-operations": "Operaciones de notas de texto",
|
||||
"add-link-to-text": "Abrir cuadro de diálogo para agregar un enlace al texto",
|
||||
"follow-link-under-cursor": "Seguir el enlace dentro del cual se coloca el cursor",
|
||||
"insert-date-and-time-to-text": "Insertar fecha y hora actuales en el texto",
|
||||
"paste-markdown-into-text": "Pega Markdown del portapapeles en la nota de texto",
|
||||
"cut-into-note": "Corta la selección de la nota actual y crea una subnota con el texto seleccionado",
|
||||
"add-include-note-to-text": "Abre el cuadro de diálogo para incluir una nota",
|
||||
"edit-readonly-note": "Editar una nota de sólo lectura",
|
||||
"attributes-labels-and-relations": "Atributos (etiquetas y relaciones)",
|
||||
"add-new-label": "Crear nueva etiqueta",
|
||||
"create-new-relation": "Crear nueva relación",
|
||||
"ribbon-tabs": "Pestañas de cinta",
|
||||
"toggle-basic-properties": "Alternar propiedades básicas",
|
||||
"toggle-file-properties": "Alternar propiedades de archivo",
|
||||
"toggle-image-properties": "Alternar propiedades de imagen",
|
||||
"toggle-owned-attributes": "Alternar atributos de propiedad",
|
||||
"toggle-inherited-attributes": "Alternar atributos heredados",
|
||||
"toggle-promoted-attributes": "Alternar atributos promocionados",
|
||||
"toggle-link-map": "Alternar mapa de enlaces",
|
||||
"toggle-note-info": "Alternar información de nota",
|
||||
"toggle-note-paths": "Alternar rutas de notas",
|
||||
"toggle-similar-notes": "Alternar notas similares",
|
||||
"other": "Otro",
|
||||
"toggle-right-pane": "Alternar la visualización del panel derecho, que incluye la tabla de contenidos y aspectos destacados",
|
||||
"print-active-note": "Imprimir nota activa",
|
||||
"open-note-externally": "Abrir nota como un archivo con la aplicación predeterminada",
|
||||
"render-active-note": "Renderizar (volver a renderizar) nota activa",
|
||||
"run-active-note": "Ejecutar nota de código JavaScript activa (frontend/backend)",
|
||||
"toggle-note-hoisting": "Alterna la elevación de la nota activa",
|
||||
"unhoist": "Bajar desde cualquier lugar",
|
||||
"reload-frontend-app": "Recargar frontend de la aplicación",
|
||||
"open-dev-tools": "Abrir herramientas de desarrollo",
|
||||
"find-in-text": "Alternar panel de búsqueda",
|
||||
"toggle-left-note-tree-panel": "Alternar panel izquierdo (árbol de notas)",
|
||||
"toggle-full-screen": "Alternar pantalla completa",
|
||||
"zoom-out": "Alejar",
|
||||
"zoom-in": "Acercar",
|
||||
"note-navigation": "Navegación de notas",
|
||||
"reset-zoom-level": "Restablecer nivel de zoom",
|
||||
"copy-without-formatting": "Copiar el texto seleccionado sin formatear",
|
||||
"force-save-revision": "Forzar la creación/guardado de una nueva revisión de nota de la nota activa",
|
||||
"toggle-book-properties": "Alternar propiedades del libro",
|
||||
"toggle-classic-editor-toolbar": "Alternar la pestaña de formato por el editor con barra de herramientas fija",
|
||||
"export-as-pdf": "Exporta la nota actual como un PDF",
|
||||
"toggle-zen-mode": "Habilita/Deshabilita el modo Zen (IU mínima para edición sin distracciones)"
|
||||
},
|
||||
"login": {
|
||||
"title": "Iniciar sesión",
|
||||
"heading": "Iniciar sesión en Trilium",
|
||||
"incorrect-totp": "El TOTP es incorrecto. Por favor, intente de nuevo.",
|
||||
"incorrect-password": "La contraseña es incorrecta. Por favor inténtalo de nuevo.",
|
||||
"password": "Contraseña",
|
||||
"remember-me": "Recordarme",
|
||||
"button": "Iniciar sesión",
|
||||
"sign_in_with_sso": "Iniciar sesión con {{ ssoIssuerName }}"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Establecer contraseña",
|
||||
"heading": "Establecer contraseña",
|
||||
"description": "Antes de poder comenzar a usar Trilium desde la web, primero debe establecer una contraseña. Luego utilizará esta contraseña para iniciar sesión.",
|
||||
"password": "Contraseña",
|
||||
"password-confirmation": "Confirmación de contraseña",
|
||||
"button": "Establecer contraseña"
|
||||
},
|
||||
"javascript-required": "Trilium requiere que JavaScript esté habilitado.",
|
||||
"setup": {
|
||||
"heading": "Configuración de Trilium Notes",
|
||||
"new-document": "Soy un usuario nuevo y quiero crear un nuevo documento de Trilium para mis notas",
|
||||
"sync-from-desktop": "Ya tengo una instancia de escritorio y quiero configurar la sincronización con ella",
|
||||
"sync-from-server": "Ya tengo una instancia de servidor y quiero configurar la sincronización con ella",
|
||||
"next": "Siguiente",
|
||||
"init-in-progress": "Inicialización del documento en curso",
|
||||
"redirecting": "En breve será redirigido a la aplicación.",
|
||||
"title": "Configuración"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar desde el escritorio",
|
||||
"description": "Esta configuración debe iniciarse desde la instancia de escritorio:",
|
||||
"step1": "Abra su instancia de escritorio de Trilium Notes.",
|
||||
"step2": "En el menú Trilium, dé clic en Opciones.",
|
||||
"step3": "Dé clic en la categoría Sincronizar.",
|
||||
"step4": "Cambie la dirección de la instancia del servidor a: {{- host}} y dé clic en Guardar.",
|
||||
"step5": "Dé clic en el botón \"Probar sincronización\" para verificar que la conexión fue exitosa.",
|
||||
"step6": "Una vez que haya completado estos pasos, dé clic en {{- link}}.",
|
||||
"step6-here": "aquí"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronización desde el servidor",
|
||||
"instructions": "Por favor, ingrese la dirección y las credenciales del servidor Trilium a continuación. Esto descargará todo el documento de Trilium desde el servidor y configurará la sincronización. Dependiendo del tamaño del documento y de la velocidad de su conexión, esto puede tardar un poco.",
|
||||
"server-host": "Dirección del servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Si deja la configuración de proxy en blanco, se utilizará el proxy del sistema (aplica únicamente a la aplicación de escritorio)",
|
||||
"password": "Contraseña",
|
||||
"password-placeholder": "Contraseña",
|
||||
"back": "Atrás",
|
||||
"finish-setup": "Finalizar la configuración"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronización en progreso",
|
||||
"successful": "La sincronización se ha configurado correctamente. La sincronización inicial tardará algún tiempo en finalizar. Una vez hecho esto, será redirigido a la página de inicio de sesión.",
|
||||
"outstanding-items": "Elementos de sincronización destacados:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "No encontrado",
|
||||
"heading": "No encontrado"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "padre:",
|
||||
"clipped-from": "Esta nota fue recortada originalmente de {{- url}}",
|
||||
"child-notes": "Subnotas:",
|
||||
"no-content": "Esta nota no tiene contenido."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo"
|
||||
},
|
||||
"weekdayNumber": "Semana {weekNumber}",
|
||||
"months": {
|
||||
"january": "Enero",
|
||||
"february": "Febrero",
|
||||
"march": "Marzo",
|
||||
"april": "Abril",
|
||||
"may": "Mayo",
|
||||
"june": "Junio",
|
||||
"july": "Julio",
|
||||
"august": "Agosto",
|
||||
"september": "Septiembre",
|
||||
"october": "Octubre",
|
||||
"november": "Noviembre",
|
||||
"december": "Diciembre"
|
||||
},
|
||||
"quarterNumber": "Cuarto {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Buscar:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "El servidor de sincronización no está configurado. Por favor configure primero la sincronización.",
|
||||
"successful": "El protocolo de enlace del servidor de sincronización ha sido exitoso, la sincronización ha comenzado."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Notas ocultas",
|
||||
"search-history-title": "Buscar historial",
|
||||
"note-map-title": "Mapa de nota",
|
||||
"sql-console-history-title": "Historial de consola SQL",
|
||||
"shared-notes-title": "Notas compartidas",
|
||||
"bulk-action-title": "Acción en lote",
|
||||
"backend-log-title": "Registro de Backend",
|
||||
"user-hidden-title": "Usuario oculto",
|
||||
"launch-bar-templates-title": "Plantillas de barra de lanzamiento",
|
||||
"base-abstract-launcher-title": "Lanzador abstracto base",
|
||||
"command-launcher-title": "Lanzador de comando",
|
||||
"note-launcher-title": "Lanzador de nota",
|
||||
"script-launcher-title": "Lanzador de script",
|
||||
"built-in-widget-title": "Widget integrado",
|
||||
"spacer-title": "Espaciador",
|
||||
"custom-widget-title": "Widget personalizado",
|
||||
"launch-bar-title": "Barra de lanzamiento",
|
||||
"available-launchers-title": "Lanzadores disponibles",
|
||||
"go-to-previous-note-title": "Ir a nota previa",
|
||||
"go-to-next-note-title": "Ir a nota siguiente",
|
||||
"new-note-title": "Nueva nota",
|
||||
"search-notes-title": "Buscar notas",
|
||||
"calendar-title": "Calendario",
|
||||
"recent-changes-title": "Cambios recientes",
|
||||
"bookmarks-title": "Marcadores",
|
||||
"open-today-journal-note-title": "Abrir nota del diario de hoy",
|
||||
"quick-search-title": "Búsqueda rápida",
|
||||
"protected-session-title": "Sesión protegida",
|
||||
"sync-status-title": "Sincronizar estado",
|
||||
"settings-title": "Ajustes",
|
||||
"llm-chat-title": "Chat con notas",
|
||||
"options-title": "Opciones",
|
||||
"appearance-title": "Apariencia",
|
||||
"shortcuts-title": "Atajos",
|
||||
"text-notes": "Notas de texto",
|
||||
"code-notes-title": "Notas de código",
|
||||
"images-title": "Imágenes",
|
||||
"spellcheck-title": "Corrección ortográfica",
|
||||
"password-title": "Contraseña",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Respaldo",
|
||||
"sync-title": "Sincronizar",
|
||||
"ai-llm-title": "IA/LLM",
|
||||
"other": "Otros",
|
||||
"advanced-title": "Avanzado",
|
||||
"visible-launchers-title": "Lanzadores visibles",
|
||||
"user-guide": "Guía de Usuario",
|
||||
"localization": "Idioma y Región",
|
||||
"inbox-title": "Bandeja"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nueva nota",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle}} {{duplicateNoteSuffix}}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "El archivo de registro del backend '{{fileName}}' no existe (aún).",
|
||||
"reading-log-failed": "Leer el archivo de registro del backend '{{fileName}}' falló."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Este tipo de nota no puede ser mostrado."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Documento PDF (*.pdf)",
|
||||
"unable-to-export-message": "La nota actual no pudo ser exportada como PDF.",
|
||||
"unable-to-export-title": "No es posible exportar como PDF",
|
||||
"unable-to-save-message": "No se pudo escribir en el archivo seleccionado. Intente de nuevo o seleccione otro destino."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Cerrar Trilium",
|
||||
"recents": "Notas recientes",
|
||||
"bookmarks": "Marcadores",
|
||||
"today": "Abrir nota del diario de hoy",
|
||||
"new-note": "Nueva nota",
|
||||
"show-windows": "Mostrar ventanas",
|
||||
"open_new_window": "Abrir nueva ventana"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "La migración directa desde tu versión actual no está soportada. Por favor actualice a v0.60.4 primero y solo después a esta versión.",
|
||||
"error_message": "Error durante la migración a la versión {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La versión de la DB {{version}} es más nueva que la versión de la DB actual {{targetVersion}}, lo que significa que fue creada por una versión más reciente e incompatible de Trilium. Actualice a la última versión de Trilium para resolver este problema."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Error"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "Tema de sitio",
|
||||
"search_placeholder": "Búsqueda...",
|
||||
"image_alt": "Imagen de artículo",
|
||||
"last-updated": "Última actualización en {{-date}}",
|
||||
"subpages": "Subpáginas:",
|
||||
"on-this-page": "En esta página",
|
||||
"expand": "Expandir"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Navegar a la nota previa en el historial",
|
||||
"forward-in-note-history": "Navegar a la nota siguiente en el historial",
|
||||
"open-jump-to-note-dialog": "Abrir cuadro de diálogo \"Saltar a nota\"",
|
||||
"scroll-to-active-note": "Desplazarse a la nota activa en el árbol de notas",
|
||||
"quick-search": "Activar barra de búsqueda rápida",
|
||||
"search-in-subtree": "Buscar notas en el subárbol de la nota activa",
|
||||
"expand-subtree": "Expandir el subárbol de la nota actual",
|
||||
"collapse-tree": "Colapsa el árbol de notas completo",
|
||||
"collapse-subtree": "Colapsa el subárbol de la nota actual",
|
||||
"sort-child-notes": "Ordenar subnotas",
|
||||
"creating-and-moving-notes": "Creando y moviendo notas",
|
||||
"create-note-after": "Crear nota después de la nota activa",
|
||||
"create-note-into": "Crear nota como subnota de la nota activa",
|
||||
"create-note-into-inbox": "Crear una nota en la bandeja de entrada (si está definida) o nota del día",
|
||||
"delete-note": "Eliminar nota",
|
||||
"move-note-up": "Mover nota hacia arriba",
|
||||
"move-note-down": "Mover nota hacia abajo",
|
||||
"move-note-up-in-hierarchy": "Mover nota hacia arriba en la jerarquía",
|
||||
"move-note-down-in-hierarchy": "Mover nota hacia abajo en la jerarquía",
|
||||
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
|
||||
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
|
||||
"cloneNotesTo": "Clonar notas seleccionadas",
|
||||
"moveNotesTo": "Mover notas seleccionadas",
|
||||
"note-clipboard": "Portapapeles de notas",
|
||||
"copy-notes-to-clipboard": "Copiar las notas seleccionadas al portapapeles",
|
||||
"paste-notes-from-clipboard": "Pegar las notas del portapapeles en una nota activa",
|
||||
"cut-notes-to-clipboard": "Cortar las notas seleccionadas al portapapeles",
|
||||
"select-all-notes-in-parent": "Seleccionar todas las notas del nivel de la nota actual",
|
||||
"add-note-above-to-the-selection": "Agregar nota arriba de la selección",
|
||||
"add-note-below-to-selection": "Agregar nota arriba de la selección",
|
||||
"duplicate-subtree": "Duplicar subárbol",
|
||||
"tabs-and-windows": "Pestañas y ventanas",
|
||||
"open-new-tab": "Abre una nueva pestaña",
|
||||
"close-active-tab": "Cierra la pestaña activa",
|
||||
"reopen-last-tab": "Vuelve a abrir la última pestaña cerrada",
|
||||
"activate-next-tab": "Activa la pestaña de la derecha",
|
||||
"activate-previous-tab": "Activa la pestaña de la izquierda",
|
||||
"open-new-window": "Abrir nueva ventana vacía",
|
||||
"toggle-tray": "Muestra/Oculta la aplicación en la bandeja del sistema",
|
||||
"first-tab": "Activa la primera pestaña de la lista",
|
||||
"second-tab": "Activa la segunda pestaña de la lista",
|
||||
"third-tab": "Activa la tercera pestaña de la lista",
|
||||
"fourth-tab": "Activa la cuarta pestaña de la lista",
|
||||
"fifth-tab": "Activa la quinta pestaña de la lista",
|
||||
"sixth-tab": "Activa la sexta pestaña de la lista",
|
||||
"seventh-tab": "Activa la séptima pestaña de la lista",
|
||||
"eight-tab": "Activa la octava pestaña de la lista",
|
||||
"ninth-tab": "Activa la novena pestaña de la lista",
|
||||
"last-tab": "Activa la última pestaña de la lista",
|
||||
"dialogs": "Diálogos",
|
||||
"show-note-source": "Muestra el cuadro de diálogo Fuente de nota",
|
||||
"show-options": "Muestra el cuadro de diálogo Opciones",
|
||||
"show-revisions": "Muestra el cuadro de diálogo Revisiones de notas",
|
||||
"show-recent-changes": "Muestra el cuadro de diálogo Cambios recientes",
|
||||
"show-sql-console": "Muestra el cuadro de diálogo Consola SQL",
|
||||
"show-backend-log": "Muestra el cuadro de diálogo Registro de backend",
|
||||
"show-help": "Muestra ayuda/hoja de referencia integrada",
|
||||
"show-cheatsheet": "Muestra un modal con operaciones de teclado comunes",
|
||||
"text-note-operations": "Operaciones de notas de texto",
|
||||
"add-link-to-text": "Abrir cuadro de diálogo para agregar un enlace al texto",
|
||||
"follow-link-under-cursor": "Seguir el enlace dentro del cual se coloca el cursor",
|
||||
"insert-date-and-time-to-text": "Insertar fecha y hora actuales en el texto",
|
||||
"paste-markdown-into-text": "Pega Markdown del portapapeles en la nota de texto",
|
||||
"cut-into-note": "Corta la selección de la nota actual y crea una subnota con el texto seleccionado",
|
||||
"add-include-note-to-text": "Abre el cuadro de diálogo para incluir una nota",
|
||||
"edit-readonly-note": "Editar una nota de sólo lectura",
|
||||
"attributes-labels-and-relations": "Atributos (etiquetas y relaciones)",
|
||||
"add-new-label": "Crear nueva etiqueta",
|
||||
"create-new-relation": "Crear nueva relación",
|
||||
"ribbon-tabs": "Pestañas de cinta",
|
||||
"toggle-basic-properties": "Alternar propiedades básicas",
|
||||
"toggle-file-properties": "Alternar propiedades de archivo",
|
||||
"toggle-image-properties": "Alternar propiedades de imagen",
|
||||
"toggle-owned-attributes": "Alternar atributos de propiedad",
|
||||
"toggle-inherited-attributes": "Alternar atributos heredados",
|
||||
"toggle-promoted-attributes": "Alternar atributos promocionados",
|
||||
"toggle-link-map": "Alternar mapa de enlaces",
|
||||
"toggle-note-info": "Alternar información de nota",
|
||||
"toggle-note-paths": "Alternar rutas de notas",
|
||||
"toggle-similar-notes": "Alternar notas similares",
|
||||
"other": "Otro",
|
||||
"toggle-right-pane": "Alternar la visualización del panel derecho, que incluye la tabla de contenidos y aspectos destacados",
|
||||
"print-active-note": "Imprimir nota activa",
|
||||
"open-note-externally": "Abrir nota como un archivo con la aplicación predeterminada",
|
||||
"render-active-note": "Renderizar (volver a renderizar) nota activa",
|
||||
"run-active-note": "Ejecutar nota de código JavaScript activa (frontend/backend)",
|
||||
"toggle-note-hoisting": "Alterna la elevación de la nota activa",
|
||||
"unhoist": "Bajar desde cualquier lugar",
|
||||
"reload-frontend-app": "Recargar frontend de la aplicación",
|
||||
"open-dev-tools": "Abrir herramientas de desarrollo",
|
||||
"find-in-text": "Alternar panel de búsqueda",
|
||||
"toggle-left-note-tree-panel": "Alternar panel izquierdo (árbol de notas)",
|
||||
"toggle-full-screen": "Alternar pantalla completa",
|
||||
"zoom-out": "Alejar",
|
||||
"zoom-in": "Acercar",
|
||||
"note-navigation": "Navegación de notas",
|
||||
"reset-zoom-level": "Restablecer nivel de zoom",
|
||||
"copy-without-formatting": "Copiar el texto seleccionado sin formatear",
|
||||
"force-save-revision": "Forzar la creación/guardado de una nueva revisión de nota de la nota activa",
|
||||
"toggle-book-properties": "Alternar propiedades del libro",
|
||||
"toggle-classic-editor-toolbar": "Alternar la pestaña de formato por el editor con barra de herramientas fija",
|
||||
"export-as-pdf": "Exporta la nota actual como un PDF",
|
||||
"toggle-zen-mode": "Habilita/Deshabilita el modo Zen (IU mínima para edición sin distracciones)",
|
||||
"open-command-palette": "Abrir paleta de comandos",
|
||||
"clone-notes-to": "Clonar notas seleccionadas",
|
||||
"move-notes-to": "Mover notas seleccionadas"
|
||||
},
|
||||
"login": {
|
||||
"title": "Iniciar sesión",
|
||||
"heading": "Iniciar sesión en Trilium",
|
||||
"incorrect-totp": "El TOTP es incorrecto. Por favor, intente de nuevo.",
|
||||
"incorrect-password": "La contraseña es incorrecta. Por favor inténtalo de nuevo.",
|
||||
"password": "Contraseña",
|
||||
"remember-me": "Recordarme",
|
||||
"button": "Iniciar sesión",
|
||||
"sign_in_with_sso": "Iniciar sesión con {{ ssoIssuerName }}"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Establecer contraseña",
|
||||
"heading": "Establecer contraseña",
|
||||
"description": "Antes de poder comenzar a usar Trilium desde la web, primero debe establecer una contraseña. Luego utilizará esta contraseña para iniciar sesión.",
|
||||
"password": "Contraseña",
|
||||
"password-confirmation": "Confirmación de contraseña",
|
||||
"button": "Establecer contraseña"
|
||||
},
|
||||
"javascript-required": "Trilium requiere que JavaScript esté habilitado.",
|
||||
"setup": {
|
||||
"heading": "Configuración de Trilium Notes",
|
||||
"new-document": "Soy un usuario nuevo y quiero crear un nuevo documento de Trilium para mis notas",
|
||||
"sync-from-desktop": "Ya tengo una instancia de escritorio y quiero configurar la sincronización con ella",
|
||||
"sync-from-server": "Ya tengo una instancia de servidor y quiero configurar la sincronización con ella",
|
||||
"next": "Siguiente",
|
||||
"init-in-progress": "Inicialización del documento en curso",
|
||||
"redirecting": "En breve será redirigido a la aplicación.",
|
||||
"title": "Configuración"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar desde el escritorio",
|
||||
"description": "Esta configuración debe iniciarse desde la instancia de escritorio:",
|
||||
"step1": "Abra su instancia de escritorio de Trilium Notes.",
|
||||
"step2": "En el menú Trilium, dé clic en Opciones.",
|
||||
"step3": "Dé clic en la categoría Sincronizar.",
|
||||
"step4": "Cambie la dirección de la instancia del servidor a: {{- host}} y dé clic en Guardar.",
|
||||
"step5": "Dé clic en el botón \"Probar sincronización\" para verificar que la conexión fue exitosa.",
|
||||
"step6": "Una vez que haya completado estos pasos, dé clic en {{- link}}.",
|
||||
"step6-here": "aquí"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronización desde el servidor",
|
||||
"instructions": "Por favor, ingrese la dirección y las credenciales del servidor Trilium a continuación. Esto descargará todo el documento de Trilium desde el servidor y configurará la sincronización. Dependiendo del tamaño del documento y de la velocidad de su conexión, esto puede tardar un poco.",
|
||||
"server-host": "Dirección del servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Si deja la configuración de proxy en blanco, se utilizará el proxy del sistema (aplica únicamente a la aplicación de escritorio)",
|
||||
"password": "Contraseña",
|
||||
"password-placeholder": "Contraseña",
|
||||
"back": "Atrás",
|
||||
"finish-setup": "Finalizar la configuración"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronización en progreso",
|
||||
"successful": "La sincronización se ha configurado correctamente. La sincronización inicial tardará algún tiempo en finalizar. Una vez hecho esto, será redirigido a la página de inicio de sesión.",
|
||||
"outstanding-items": "Elementos de sincronización destacados:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "No encontrado",
|
||||
"heading": "No encontrado"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "padre:",
|
||||
"clipped-from": "Esta nota fue recortada originalmente de {{- url}}",
|
||||
"child-notes": "Subnotas:",
|
||||
"no-content": "Esta nota no tiene contenido."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo"
|
||||
},
|
||||
"weekdayNumber": "Semana {weekNumber}",
|
||||
"months": {
|
||||
"january": "Enero",
|
||||
"february": "Febrero",
|
||||
"march": "Marzo",
|
||||
"april": "Abril",
|
||||
"may": "Mayo",
|
||||
"june": "Junio",
|
||||
"july": "Julio",
|
||||
"august": "Agosto",
|
||||
"september": "Septiembre",
|
||||
"october": "Octubre",
|
||||
"november": "Noviembre",
|
||||
"december": "Diciembre"
|
||||
},
|
||||
"quarterNumber": "Cuarto {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Buscar:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "El servidor de sincronización no está configurado. Por favor configure primero la sincronización.",
|
||||
"successful": "El protocolo de enlace del servidor de sincronización ha sido exitoso, la sincronización ha comenzado."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Notas ocultas",
|
||||
"search-history-title": "Buscar historial",
|
||||
"note-map-title": "Mapa de nota",
|
||||
"sql-console-history-title": "Historial de consola SQL",
|
||||
"shared-notes-title": "Notas compartidas",
|
||||
"bulk-action-title": "Acción en lote",
|
||||
"backend-log-title": "Registro de Backend",
|
||||
"user-hidden-title": "Usuario oculto",
|
||||
"launch-bar-templates-title": "Plantillas de barra de lanzamiento",
|
||||
"base-abstract-launcher-title": "Lanzador abstracto base",
|
||||
"command-launcher-title": "Lanzador de comando",
|
||||
"note-launcher-title": "Lanzador de nota",
|
||||
"script-launcher-title": "Lanzador de script",
|
||||
"built-in-widget-title": "Widget integrado",
|
||||
"spacer-title": "Espaciador",
|
||||
"custom-widget-title": "Widget personalizado",
|
||||
"launch-bar-title": "Barra de lanzamiento",
|
||||
"available-launchers-title": "Lanzadores disponibles",
|
||||
"go-to-previous-note-title": "Ir a nota previa",
|
||||
"go-to-next-note-title": "Ir a nota siguiente",
|
||||
"new-note-title": "Nueva nota",
|
||||
"search-notes-title": "Buscar notas",
|
||||
"calendar-title": "Calendario",
|
||||
"recent-changes-title": "Cambios recientes",
|
||||
"bookmarks-title": "Marcadores",
|
||||
"open-today-journal-note-title": "Abrir nota del diario de hoy",
|
||||
"quick-search-title": "Búsqueda rápida",
|
||||
"protected-session-title": "Sesión protegida",
|
||||
"sync-status-title": "Sincronizar estado",
|
||||
"settings-title": "Ajustes",
|
||||
"llm-chat-title": "Chat con notas",
|
||||
"options-title": "Opciones",
|
||||
"appearance-title": "Apariencia",
|
||||
"shortcuts-title": "Atajos",
|
||||
"text-notes": "Notas de texto",
|
||||
"code-notes-title": "Notas de código",
|
||||
"images-title": "Imágenes",
|
||||
"spellcheck-title": "Corrección ortográfica",
|
||||
"password-title": "Contraseña",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Respaldo",
|
||||
"sync-title": "Sincronizar",
|
||||
"ai-llm-title": "IA/LLM",
|
||||
"other": "Otros",
|
||||
"advanced-title": "Avanzado",
|
||||
"visible-launchers-title": "Lanzadores visibles",
|
||||
"user-guide": "Guía de Usuario",
|
||||
"localization": "Idioma y Región",
|
||||
"inbox-title": "Bandeja",
|
||||
"jump-to-note-title": "Saltar a..."
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nueva nota",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle}} {{duplicateNoteSuffix}}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "El archivo de registro del backend '{{fileName}}' no existe (aún).",
|
||||
"reading-log-failed": "Leer el archivo de registro del backend '{{fileName}}' falló."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Este tipo de nota no puede ser mostrado."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Documento PDF (*.pdf)",
|
||||
"unable-to-export-message": "La nota actual no pudo ser exportada como PDF.",
|
||||
"unable-to-export-title": "No es posible exportar como PDF",
|
||||
"unable-to-save-message": "No se pudo escribir en el archivo seleccionado. Intente de nuevo o seleccione otro destino."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Cerrar Trilium",
|
||||
"recents": "Notas recientes",
|
||||
"bookmarks": "Marcadores",
|
||||
"today": "Abrir nota del diario de hoy",
|
||||
"new-note": "Nueva nota",
|
||||
"show-windows": "Mostrar ventanas",
|
||||
"open_new_window": "Abrir nueva ventana"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "La migración directa desde tu versión actual no está soportada. Por favor actualice a v0.60.4 primero y solo después a esta versión.",
|
||||
"error_message": "Error durante la migración a la versión {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La versión de la DB {{version}} es más nueva que la versión de la DB actual {{targetVersion}}, lo que significa que fue creada por una versión más reciente e incompatible de Trilium. Actualice a la última versión de Trilium para resolver este problema."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Error"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "Tema de sitio",
|
||||
"search_placeholder": "Búsqueda...",
|
||||
"image_alt": "Imagen de artículo",
|
||||
"last-updated": "Última actualización en {{-date}}",
|
||||
"subpages": "Subpáginas:",
|
||||
"on-this-page": "En esta página",
|
||||
"expand": "Expandir"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"jump-to-note": "Saltar a...",
|
||||
"command-palette": "Paleta de comandos",
|
||||
"scroll-to-active-note": "Desplazarse a la nota activa",
|
||||
"quick-search": "Búsqueda rápida",
|
||||
"search-in-subtree": "Buscar en subárbol",
|
||||
"expand-subtree": "Expandir subárbol",
|
||||
"collapse-tree": "Colapsar árbol",
|
||||
"collapse-subtree": "Colapsar subárbol",
|
||||
"sort-child-notes": "Ordenar nodos hijos",
|
||||
"create-note-after": "Crear nota tras",
|
||||
"create-note-into": "Crear nota en",
|
||||
"create-note-into-inbox": "Crear nota en bandeja de entrada",
|
||||
"delete-notes": "Eliminar notas",
|
||||
"move-note-up": "Subir nota",
|
||||
"move-note-down": "Bajar nota",
|
||||
"move-note-up-in-hierarchy": "Subir nota en la jerarquía",
|
||||
"move-note-down-in-hierarchy": "Bajar nota en la jerarquía",
|
||||
"edit-note-title": "Editar título de nota",
|
||||
"edit-branch-prefix": "Editar prefijo de rama",
|
||||
"clone-notes-to": "Clonar notas a",
|
||||
"move-notes-to": "Mover notas a",
|
||||
"copy-notes-to-clipboard": "Copiar notas al portapapeles",
|
||||
"paste-notes-from-clipboard": "Pegar notas del portapapeles",
|
||||
"add-note-above-to-selection": "Añadir nota superior a la selección",
|
||||
"add-note-below-to-selection": "Añadir nota inferior a la selección",
|
||||
"duplicate-subtree": "Duplicar subárbol",
|
||||
"open-new-tab": "Abrir nueva pestaña",
|
||||
"close-active-tab": "Cerrar pestaña activa",
|
||||
"reopen-last-tab": "Reabrir última pestaña",
|
||||
"activate-next-tab": "Activar siguiente pestaña",
|
||||
"activate-previous-tab": "Activar pestaña anterior",
|
||||
"open-new-window": "Abrir nueva ventana",
|
||||
"show-options": "Mostrar opciones",
|
||||
"show-revisions": "Mostrar revisiones",
|
||||
"show-recent-changes": "Mostrar cambios recientes",
|
||||
"show-sql-console": "Mostrar consola SQL",
|
||||
"switch-to-first-tab": "Ir a la primera pestaña",
|
||||
"switch-to-second-tab": "Ir a la segunda pestaña",
|
||||
"switch-to-third-tab": "Ir a la tercera pestaña",
|
||||
"switch-to-fourth-tab": "Ir a la cuarta pestaña",
|
||||
"switch-to-fifth-tab": "Ir a la quinta pestaña",
|
||||
"switch-to-sixth-tab": "Ir a la sexta pestaña",
|
||||
"switch-to-seventh-tab": "Ir a la séptima pestaña",
|
||||
"switch-to-eighth-tab": "Ir a la octava pestaña",
|
||||
"switch-to-ninth-tab": "Ir a la novena pestaña",
|
||||
"switch-to-last-tab": "Ir a la última pestaña",
|
||||
"show-note-source": "Mostrar nota fuente",
|
||||
"show-help": "Mostrar ayuda",
|
||||
"add-new-label": "Añadir nueva etiqueta",
|
||||
"add-new-relation": "Añadir nueva relación",
|
||||
"print-active-note": "Imprimir nota activa",
|
||||
"export-active-note-as-pdf": "Exportar nota activa como PDF",
|
||||
"open-note-externally": "Abrir nota externamente",
|
||||
"find-in-text": "Encontrar en texto",
|
||||
"copy-without-formatting": "Copiar sin formato",
|
||||
"reset-zoom-level": "Restablecer el nivel de zoom",
|
||||
"open-developer-tools": "Abrir herramientas de desarrollo",
|
||||
"insert-date-and-time-to-text": "Insertar fecha y hora al texto",
|
||||
"edit-read-only-note": "Editar nota de solo lectura",
|
||||
"toggle-system-tray-icon": "Mostrar/ocultar icono en la bandeja del sistema",
|
||||
"toggle-zen-mode": "Activar/desactivar modo Zen",
|
||||
"add-link-to-text": "Añadir enlace al texto",
|
||||
"zoom-in": "Acercar",
|
||||
"zoom-out": "Alejar",
|
||||
"toggle-full-screen": "Activar/desactivar pantalla completa",
|
||||
"toggle-left-pane": "Abrir/cerrar panel izquierdo"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"board_note_first": "Primera nota",
|
||||
"board_note_second": "Segunda nota",
|
||||
"board_note_third": "Tercera nota",
|
||||
"board_status_progress": "En progreso",
|
||||
"calendar": "Calendario",
|
||||
"description": "Descripción",
|
||||
"list-view": "Vista de lista",
|
||||
"grid-view": "Vista de cuadrícula",
|
||||
"status": "Estado",
|
||||
"table": "Tabla"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,279 +1,431 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"activate-next-tab": "Activează tab-ul din dreapta",
|
||||
"activate-previous-tab": "Activează tab-ul din stânga",
|
||||
"add-include-note-to-text": "Deschide fereastra de includere notițe",
|
||||
"add-link-to-text": "Deschide fereastra de adaugă legături în text",
|
||||
"add-new-label": "Crează o nouă etichetă",
|
||||
"add-note-above-to-the-selection": "Adaugă notița deasupra selecției",
|
||||
"add-note-below-to-selection": "Adaugă notița deasupra selecției",
|
||||
"attributes-labels-and-relations": "Atribute (etichete și relații)",
|
||||
"close-active-tab": "Închide tab-ul activ",
|
||||
"collapse-subtree": "Minimizează ierarhia notiței curente",
|
||||
"collapse-tree": "Minimizează întreaga ierarhie de notițe",
|
||||
"copy-notes-to-clipboard": "Copiază notițele selectate în clipboard",
|
||||
"copy-without-formatting": "Copiază textul selectat fără formatare",
|
||||
"create-new-relation": "Crează o nouă relație",
|
||||
"create-note-into-inbox": "Crează o notiță și o deschide în inbox (dacă este definit) sau notița zilnică",
|
||||
"cut-notes-to-clipboard": "Decupează notițele selectate în clipboard",
|
||||
"creating-and-moving-notes": "Crearea și mutarea notițelor",
|
||||
"cut-into-note": "Decupează selecția din notița curentă și crează o subnotiță cu textul selectat",
|
||||
"delete-note": "Șterge notița",
|
||||
"dialogs": "Ferestre",
|
||||
"duplicate-subtree": "Dublifică subnotițele",
|
||||
"edit-branch-prefix": "Afișează ecranul „Editează prefixul ramurii”",
|
||||
"edit-note-title": "Sare de la arborele notițelor la detaliile notiței și editează titlul",
|
||||
"edit-readonly-note": "Editează o notiță care este în modul doar în citire",
|
||||
"eight-tab": "Activează cel de-al optelea tab din listă",
|
||||
"expand-subtree": "Expandează ierarhia notiței curente",
|
||||
"fifth-tab": "Activează cel de-al cincelea tab din listă",
|
||||
"first-tab": "Activează cel primul tab din listă",
|
||||
"follow-link-under-cursor": "Urmărește legătura de sub poziția cursorului",
|
||||
"force-save-revision": "Forțează crearea/salvarea unei noi revizii ale notiției curente",
|
||||
"fourth-tab": "Activează cel de-al patrulea tab din listă",
|
||||
"insert-date-and-time-to-text": "Inserează data curentă și timpul în text",
|
||||
"last-tab": "Activează ultimul tab din listă",
|
||||
"move-note-down": "Mută notița mai jos",
|
||||
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
|
||||
"move-note-up": "Mută notița mai sus",
|
||||
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
|
||||
"ninth-tab": "Activează cel de-al nouălea tab din listă",
|
||||
"note-clipboard": "Clipboard-ul notițelor",
|
||||
"note-navigation": "Navigarea printre notițe",
|
||||
"open-dev-tools": "Deschide uneltele de dezvoltator",
|
||||
"open-jump-to-note-dialog": "Deschide ecranul „Sari la notiță”",
|
||||
"open-new-tab": "Deschide un tab nou",
|
||||
"open-new-window": "Deschide o nouă fereastră goală",
|
||||
"open-note-externally": "Deschide notița ca un fișier utilizând aplicația implicită",
|
||||
"other": "Altele",
|
||||
"paste-markdown-into-text": "Lipește text Markdown din clipboard în notița de tip text",
|
||||
"paste-notes-from-clipboard": "Lipește notițele din clipboard în notița activă",
|
||||
"print-active-note": "Imprimă notița activă",
|
||||
"reload-frontend-app": "Reîncarcă interfața grafică",
|
||||
"render-active-note": "Reîmprospătează notița activă",
|
||||
"reopen-last-tab": "Deschide din nou ultimul tab închis",
|
||||
"reset-zoom-level": "Resetează nivelul de magnificare",
|
||||
"ribbon-tabs": "Tab-urile din panglică",
|
||||
"run-active-note": "Rulează notița curentă de tip JavaScript (frontend sau backend)",
|
||||
"search-in-subtree": "Caută notițe în ierarhia notiței active",
|
||||
"second-tab": "Activează cel de-al doilea tab din listă",
|
||||
"select-all-notes-in-parent": "Selectează toate notițele din nivelul notiței curente",
|
||||
"seventh-tab": "Activează cel de-al șaptelea tab din listă",
|
||||
"show-backend-log": "Afișează fereastra „Log-uri din backend”",
|
||||
"show-help": "Afișează informații utile",
|
||||
"show-note-source": "Afișează fereastra „Sursa notiței”",
|
||||
"show-options": "Afișează fereastra de opțiuni",
|
||||
"show-recent-changes": "Afișează fereastra „Schimbări recente”",
|
||||
"show-revisions": "Afișează fereastra „Revizii ale notiței”",
|
||||
"show-sql-console": "Afișează ecranul „Consolă SQL”",
|
||||
"sixth-tab": "Activează cel de-al șaselea tab din listă",
|
||||
"sort-child-notes": "Ordonează subnotițele",
|
||||
"tabs-and-windows": "Tab-uri și ferestre",
|
||||
"text-note-operations": "Operații asupra notițelor text",
|
||||
"third-tab": "Activează cel de-al treilea tab din listă",
|
||||
"toggle-basic-properties": "Comută tab-ul „Proprietăți de bază”",
|
||||
"toggle-book-properties": "Comută tab-ul „Proprietăți ale cărții”",
|
||||
"toggle-file-properties": "Comută tab-ul „Proprietăți fișier”",
|
||||
"toggle-full-screen": "Comută modul ecran complet",
|
||||
"toggle-image-properties": "Comută tab-ul „Proprietăți imagini”",
|
||||
"toggle-inherited-attributes": "Comută tab-ul „Atribute moștenite”",
|
||||
"toggle-left-note-tree-panel": "Comută panoul din stânga (arborele notițelor)",
|
||||
"toggle-link-map": "Comută harta legăturilor",
|
||||
"toggle-note-hoisting": "Comută focalizarea pe notița curentă",
|
||||
"toggle-note-info": "Comută tab-ul „Informații despre notiță”",
|
||||
"toggle-note-paths": "Comută tab-ul „Căile notiței”",
|
||||
"toggle-owned-attributes": "Comută tab-ul „Atribute proprii”",
|
||||
"toggle-promoted-attributes": "Comută tab-ul „Atribute promovate”",
|
||||
"toggle-right-pane": "Comută afișarea panoului din dreapta, ce include tabela de conținut și evidențieri",
|
||||
"toggle-similar-notes": "Comută tab-ul „Notițe similare”",
|
||||
"toggle-tray": "Afișează/ascunde aplicația din tray-ul de sistem",
|
||||
"unhoist": "Defocalizează complet",
|
||||
"zoom-in": "Mărește zoom-ul",
|
||||
"zoom-out": "Micșorează zoom-ul",
|
||||
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă",
|
||||
"export-as-pdf": "Exportă notița curentă ca PDF",
|
||||
"show-cheatsheet": "Afișează o fereastră cu scurtături de la tastatură comune",
|
||||
"toggle-zen-mode": "Activează/dezactivează modul zen (o interfață minimală, fără distrageri)"
|
||||
},
|
||||
"login": {
|
||||
"button": "Autentifică",
|
||||
"heading": "Autentificare în Trilium",
|
||||
"incorrect-password": "Parola nu este corectă. Încercați din nou.",
|
||||
"password": "Parolă",
|
||||
"remember-me": "Ține-mă minte",
|
||||
"title": "Autentificare"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Setare parolă",
|
||||
"heading": "Setare parolă",
|
||||
"button": "Setează parola",
|
||||
"description": "Înainte de a putea utiliza Trilium din navigator, trebuie mai întâi setată o parolă. Ulterior această parolă va fi folosită la autentificare.",
|
||||
"password": "Parolă",
|
||||
"password-confirmation": "Confirmarea parolei"
|
||||
},
|
||||
"javascript-required": "Trilium necesită JavaScript să fie activat pentru a putea funcționa.",
|
||||
"setup": {
|
||||
"heading": "Instalarea Trilium Notes",
|
||||
"init-in-progress": "Se inițializează documentul",
|
||||
"new-document": "Sunt un utilizator nou și doresc să creez un document Trilium pentru notițele mele",
|
||||
"next": "Mai departe",
|
||||
"redirecting": "În scurt timp veți fi redirecționat la aplicație.",
|
||||
"sync-from-desktop": "Am deja o instanță de desktop și aș dori o sincronizare cu aceasta",
|
||||
"sync-from-server": "Am deja o instanță de server și doresc o sincronizare cu aceasta",
|
||||
"title": "Inițializare"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"description": "Acești pași trebuie urmați de pe aplicația de desktop:",
|
||||
"heading": "Sincronizare cu aplicația desktop",
|
||||
"step1": "Deschideți aplicația Trilium Notes pentru desktop.",
|
||||
"step2": "Din meniul Trilium, dați clic pe Opțiuni.",
|
||||
"step3": "Clic pe categoria „Sincronizare”.",
|
||||
"step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
|
||||
"step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
|
||||
"step6": "După ce ați completat pașii, dați click {{- link}}.",
|
||||
"step6-here": "aici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"back": "Înapoi",
|
||||
"finish-setup": "Finalizează inițializarea",
|
||||
"heading": "Sincronizare cu server-ul",
|
||||
"instructions": "Introduceți adresa server-ului Trilium și credențialele în secțiunea de jos. Astfel se va descărca întregul document Trilium de pe server și se va configura sincronizarea cu acesta. În funcție de dimensiunea documentului și viteza rețelei, acest proces poate dura.",
|
||||
"note": "De remarcat:",
|
||||
"password": "Parolă",
|
||||
"proxy-instruction": "Dacă lăsați câmpul de proxy nesetat, proxy-ul de sistem va fi folosit (valabil doar pentru aplicația de desktop)",
|
||||
"proxy-server": "Server-ul proxy (opțional)",
|
||||
"proxy-server-placeholder": "https://<sistem>:<port>",
|
||||
"server-host": "Adresa server-ului Trilium",
|
||||
"server-host-placeholder": "https://<sistem>:<port>",
|
||||
"password-placeholder": "Parolă"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronizare în curs",
|
||||
"outstanding-items": "Elemente de sincronizat:",
|
||||
"outstanding-items-default": "-",
|
||||
"successful": "Sincronizarea a fost configurată cu succes. Poate dura ceva timp pentru a finaliza sincronizarea inițială. După efectuarea ei se va redirecționa către pagina de autentificare."
|
||||
},
|
||||
"share_404": {
|
||||
"heading": "Pagină negăsită",
|
||||
"title": "Pagină negăsită"
|
||||
},
|
||||
"share_page": {
|
||||
"child-notes": "Subnotițe:",
|
||||
"clipped-from": "Această notiță a fost decupată inițial de pe {{- url}}",
|
||||
"no-content": "Această notiță nu are conținut.",
|
||||
"parent": "părinte:"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Luni",
|
||||
"tuesday": "Marți",
|
||||
"wednesday": "Miercuri",
|
||||
"thursday": "Joi",
|
||||
"friday": "Vineri",
|
||||
"saturday": "Sâmbătă",
|
||||
"sunday": "Duminică"
|
||||
},
|
||||
"months": {
|
||||
"january": "Ianuarie",
|
||||
"february": "Februarie",
|
||||
"march": "Martie",
|
||||
"april": "Aprilie",
|
||||
"may": "Mai",
|
||||
"june": "Iunie",
|
||||
"july": "Iulie",
|
||||
"august": "August",
|
||||
"september": "Septembrie",
|
||||
"october": "Octombrie",
|
||||
"november": "Noiembrie",
|
||||
"december": "Decembrie"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Căutare:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Calea către serverul de sincronizare nu este configurată. Configurați sincronizarea înainte.",
|
||||
"successful": "Comunicarea cu serverul de sincronizare a avut loc cu succes, s-a început sincronizarea."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"advanced-title": "Setări avansate",
|
||||
"appearance-title": "Aspect",
|
||||
"available-launchers-title": "Lansatoare disponibile",
|
||||
"backup-title": "Copii de siguranță",
|
||||
"base-abstract-launcher-title": "Lansator abstract de bază",
|
||||
"bookmarks-title": "Semne de carte",
|
||||
"built-in-widget-title": "Widget predefinit",
|
||||
"bulk-action-title": "Acțiuni în masă",
|
||||
"calendar-title": "Calendar",
|
||||
"code-notes-title": "Notițe de cod",
|
||||
"command-launcher-title": "Lansator de comenzi",
|
||||
"custom-widget-title": "Widget personalizat",
|
||||
"etapi-title": "ETAPI",
|
||||
"go-to-previous-note-title": "Mergi la notița anterioară",
|
||||
"images-title": "Imagini",
|
||||
"launch-bar-title": "Bară de lansare",
|
||||
"new-note-title": "Notiță nouă",
|
||||
"note-launcher-title": "Lansator de notițe",
|
||||
"note-map-title": "Harta notițelor",
|
||||
"open-today-journal-note-title": "Deschide notița de astăzi",
|
||||
"options-title": "Opțiuni",
|
||||
"other": "Diverse",
|
||||
"password-title": "Parolă",
|
||||
"protected-session-title": "Sesiune protejată",
|
||||
"recent-changes-title": "Schimbări recente",
|
||||
"script-launcher-title": "Lansator de script-uri",
|
||||
"search-history-title": "Istoric de căutare",
|
||||
"settings-title": "Setări",
|
||||
"shared-notes-title": "Notițe partajate",
|
||||
"quick-search-title": "Căutare rapidă",
|
||||
"root-title": "Notițe ascunse",
|
||||
"search-notes-title": "Căutare notițe",
|
||||
"shortcuts-title": "Scurtături",
|
||||
"spellcheck-title": "Corectare gramaticală",
|
||||
"sync-status-title": "Starea sincronizării",
|
||||
"sync-title": "Sincronizare",
|
||||
"text-notes": "Notițe text",
|
||||
"user-hidden-title": "Definite de utilizator",
|
||||
"backend-log-title": "Log backend",
|
||||
"spacer-title": "Separator",
|
||||
"sql-console-history-title": "Istoricul consolei SQL",
|
||||
"go-to-next-note-title": "Mergi la notița următoare",
|
||||
"launch-bar-templates-title": "Șabloane bară de lansare",
|
||||
"visible-launchers-title": "Lansatoare vizibile",
|
||||
"user-guide": "Ghidul de utilizare"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Notiță nouă"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).",
|
||||
"reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”."
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Document PDF (*.pdf)",
|
||||
"unable-to-export-message": "Notița curentă nu a putut fi exportată ca PDF.",
|
||||
"unable-to-export-title": "Nu s-a putut exporta ca PDF",
|
||||
"unable-to-save-message": "Nu s-a putut scrie fișierul selectat. Încercați din nou sau selectați altă destinație."
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Semne de carte",
|
||||
"close": "Închide Trilium",
|
||||
"new-note": "Notiță nouă",
|
||||
"recents": "Notițe recente",
|
||||
"today": "Mergi la notița de astăzi",
|
||||
"tooltip": "Trilium Notes",
|
||||
"show-windows": "Afișează ferestrele"
|
||||
},
|
||||
"migration": {
|
||||
"error_message": "Eroare la migrarea către versiunea {{version}}: {{stack}}",
|
||||
"old_version": "Nu se poate migra la ultima versiune direct de la versiunea dvs. Actualizați mai întâi la versiunea v0.60.4 și ulterior la această versiune.",
|
||||
"wrong_db_version": "Versiunea actuală a bazei de date ({{version}}) este mai nouă decât versiunea de bază de date suportată de aplicație ({{targetVersion}}), ceea ce înseamnă că a fost creată de către o versiune mai nouă de Trilium. Actualizați aplicația la ultima versiune pentru a putea continua."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Eroare"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"activate-next-tab": "Activează tab-ul din dreapta",
|
||||
"activate-previous-tab": "Activează tab-ul din stânga",
|
||||
"add-include-note-to-text": "Deschide fereastra de includere notițe",
|
||||
"add-link-to-text": "Deschide fereastra de adaugă legături în text",
|
||||
"add-new-label": "Crează o nouă etichetă",
|
||||
"add-note-above-to-the-selection": "Adaugă notița deasupra selecției",
|
||||
"add-note-below-to-selection": "Adaugă notița deasupra selecției",
|
||||
"attributes-labels-and-relations": "Atribute (etichete și relații)",
|
||||
"close-active-tab": "Închide tab-ul activ",
|
||||
"collapse-subtree": "Minimizează ierarhia notiței curente",
|
||||
"collapse-tree": "Minimizează întreaga ierarhie de notițe",
|
||||
"copy-notes-to-clipboard": "Copiază notițele selectate în clipboard",
|
||||
"copy-without-formatting": "Copiază textul selectat fără formatare",
|
||||
"create-new-relation": "Crează o nouă relație",
|
||||
"create-note-into-inbox": "Crează o notiță și o deschide în inbox (dacă este definit) sau notița zilnică",
|
||||
"cut-notes-to-clipboard": "Decupează notițele selectate în clipboard",
|
||||
"creating-and-moving-notes": "Crearea și mutarea notițelor",
|
||||
"cut-into-note": "Decupează selecția din notița curentă și crează o subnotiță cu textul selectat",
|
||||
"delete-note": "Șterge notița",
|
||||
"dialogs": "Ferestre",
|
||||
"duplicate-subtree": "Dublifică subnotițele",
|
||||
"edit-branch-prefix": "Afișează ecranul „Editează prefixul ramurii”",
|
||||
"edit-note-title": "Sare de la arborele notițelor la detaliile notiței și editează titlul",
|
||||
"edit-readonly-note": "Editează o notiță care este în modul doar în citire",
|
||||
"eight-tab": "Activează cel de-al optelea tab din listă",
|
||||
"expand-subtree": "Expandează ierarhia notiței curente",
|
||||
"fifth-tab": "Activează cel de-al cincelea tab din listă",
|
||||
"first-tab": "Activează cel primul tab din listă",
|
||||
"follow-link-under-cursor": "Urmărește legătura de sub poziția cursorului",
|
||||
"force-save-revision": "Forțează crearea/salvarea unei noi revizii ale notiției curente",
|
||||
"fourth-tab": "Activează cel de-al patrulea tab din listă",
|
||||
"insert-date-and-time-to-text": "Inserează data curentă și timpul în text",
|
||||
"last-tab": "Activează ultimul tab din listă",
|
||||
"move-note-down": "Mută notița mai jos",
|
||||
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
|
||||
"move-note-up": "Mută notița mai sus",
|
||||
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
|
||||
"ninth-tab": "Activează cel de-al nouălea tab din listă",
|
||||
"note-clipboard": "Clipboard-ul notițelor",
|
||||
"note-navigation": "Navigarea printre notițe",
|
||||
"open-dev-tools": "Deschide uneltele de dezvoltator",
|
||||
"open-jump-to-note-dialog": "Deschide ecranul „Sari la notiță”",
|
||||
"open-new-tab": "Deschide un tab nou",
|
||||
"open-new-window": "Deschide o nouă fereastră goală",
|
||||
"open-note-externally": "Deschide notița ca un fișier utilizând aplicația implicită",
|
||||
"other": "Altele",
|
||||
"paste-markdown-into-text": "Lipește text Markdown din clipboard în notița de tip text",
|
||||
"paste-notes-from-clipboard": "Lipește notițele din clipboard în notița activă",
|
||||
"print-active-note": "Imprimă notița activă",
|
||||
"reload-frontend-app": "Reîncarcă interfața grafică",
|
||||
"render-active-note": "Reîmprospătează notița activă",
|
||||
"reopen-last-tab": "Deschide din nou ultimul tab închis",
|
||||
"reset-zoom-level": "Resetează nivelul de magnificare",
|
||||
"ribbon-tabs": "Tab-urile din panglică",
|
||||
"run-active-note": "Rulează notița curentă de tip JavaScript (frontend sau backend)",
|
||||
"search-in-subtree": "Caută notițe în ierarhia notiței active",
|
||||
"second-tab": "Activează cel de-al doilea tab din listă",
|
||||
"select-all-notes-in-parent": "Selectează toate notițele din nivelul notiței curente",
|
||||
"seventh-tab": "Activează cel de-al șaptelea tab din listă",
|
||||
"show-backend-log": "Afișează fereastra „Log-uri din backend”",
|
||||
"show-help": "Afișează informații utile",
|
||||
"show-note-source": "Afișează fereastra „Sursa notiței”",
|
||||
"show-options": "Afișează fereastra de opțiuni",
|
||||
"show-recent-changes": "Afișează fereastra „Schimbări recente”",
|
||||
"show-revisions": "Afișează fereastra „Revizii ale notiței”",
|
||||
"show-sql-console": "Afișează ecranul „Consolă SQL”",
|
||||
"sixth-tab": "Activează cel de-al șaselea tab din listă",
|
||||
"sort-child-notes": "Ordonează subnotițele",
|
||||
"tabs-and-windows": "Tab-uri și ferestre",
|
||||
"text-note-operations": "Operații asupra notițelor text",
|
||||
"third-tab": "Activează cel de-al treilea tab din listă",
|
||||
"toggle-basic-properties": "Comută tab-ul „Proprietăți de bază”",
|
||||
"toggle-book-properties": "Comută tab-ul „Proprietăți ale cărții”",
|
||||
"toggle-file-properties": "Comută tab-ul „Proprietăți fișier”",
|
||||
"toggle-full-screen": "Comută modul ecran complet",
|
||||
"toggle-image-properties": "Comută tab-ul „Proprietăți imagini”",
|
||||
"toggle-inherited-attributes": "Comută tab-ul „Atribute moștenite”",
|
||||
"toggle-left-note-tree-panel": "Comută panoul din stânga (arborele notițelor)",
|
||||
"toggle-link-map": "Comută harta legăturilor",
|
||||
"toggle-note-hoisting": "Comută focalizarea pe notița curentă",
|
||||
"toggle-note-info": "Comută tab-ul „Informații despre notiță”",
|
||||
"toggle-note-paths": "Comută tab-ul „Căile notiței”",
|
||||
"toggle-owned-attributes": "Comută tab-ul „Atribute proprii”",
|
||||
"toggle-promoted-attributes": "Comută tab-ul „Atribute promovate”",
|
||||
"toggle-right-pane": "Comută afișarea panoului din dreapta, ce include tabela de conținut și evidențieri",
|
||||
"toggle-similar-notes": "Comută tab-ul „Notițe similare”",
|
||||
"toggle-tray": "Afișează/ascunde aplicația din tray-ul de sistem",
|
||||
"unhoist": "Defocalizează complet",
|
||||
"zoom-in": "Mărește zoom-ul",
|
||||
"zoom-out": "Micșorează zoom-ul",
|
||||
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă",
|
||||
"export-as-pdf": "Exportă notița curentă ca PDF",
|
||||
"show-cheatsheet": "Afișează o fereastră cu scurtături de la tastatură comune",
|
||||
"toggle-zen-mode": "Activează/dezactivează modul zen (o interfață minimală, fără distrageri)",
|
||||
"back-in-note-history": "Mergi la notița anterioară din istoric",
|
||||
"forward-in-note-history": "Mergi la următoarea notiță din istoric",
|
||||
"scroll-to-active-note": "Derulează la notița activă în lista de notițe",
|
||||
"quick-search": "Mergi la bara de căutare rapidă",
|
||||
"create-note-after": "Crează o notiță după cea activă",
|
||||
"create-note-into": "Crează notiță ca subnotiță a notiței active",
|
||||
"clone-notes-to": "Clonează notițele selectate",
|
||||
"move-notes-to": "Mută notițele selectate",
|
||||
"find-in-text": "Afișează/ascunde panoul de căutare",
|
||||
"open-command-palette": "Deschide paleta de comenzi"
|
||||
},
|
||||
"login": {
|
||||
"button": "Autentifică",
|
||||
"heading": "Autentificare în Trilium",
|
||||
"incorrect-password": "Parola nu este corectă. Încercați din nou.",
|
||||
"password": "Parolă",
|
||||
"remember-me": "Ține-mă minte",
|
||||
"title": "Autentificare",
|
||||
"incorrect-totp": "TOTP-ul este incorect. Încercați din nou.",
|
||||
"sign_in_with_sso": "Autentificare cu {{ ssoIssuerName }}"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Setare parolă",
|
||||
"heading": "Setare parolă",
|
||||
"button": "Setează parola",
|
||||
"description": "Înainte de a putea utiliza Trilium din navigator, trebuie mai întâi setată o parolă. Ulterior această parolă va fi folosită la autentificare.",
|
||||
"password": "Parolă",
|
||||
"password-confirmation": "Confirmarea parolei"
|
||||
},
|
||||
"javascript-required": "Trilium necesită JavaScript să fie activat pentru a putea funcționa.",
|
||||
"setup": {
|
||||
"heading": "Instalarea Trilium Notes",
|
||||
"init-in-progress": "Se inițializează documentul",
|
||||
"new-document": "Sunt un utilizator nou și doresc să creez un document Trilium pentru notițele mele",
|
||||
"next": "Mai departe",
|
||||
"redirecting": "În scurt timp veți fi redirecționat la aplicație.",
|
||||
"sync-from-desktop": "Am deja o instanță de desktop și aș dori o sincronizare cu aceasta",
|
||||
"sync-from-server": "Am deja o instanță de server și doresc o sincronizare cu aceasta",
|
||||
"title": "Inițializare"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"description": "Acești pași trebuie urmați de pe aplicația de desktop:",
|
||||
"heading": "Sincronizare cu aplicația desktop",
|
||||
"step1": "Deschideți aplicația Trilium Notes pentru desktop.",
|
||||
"step2": "Din meniul Trilium, dați clic pe Opțiuni.",
|
||||
"step3": "Clic pe categoria „Sincronizare”.",
|
||||
"step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
|
||||
"step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
|
||||
"step6": "După ce ați completat pașii, dați click {{- link}}.",
|
||||
"step6-here": "aici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"back": "Înapoi",
|
||||
"finish-setup": "Finalizează inițializarea",
|
||||
"heading": "Sincronizare cu server-ul",
|
||||
"instructions": "Introduceți adresa server-ului Trilium și credențialele în secțiunea de jos. Astfel se va descărca întregul document Trilium de pe server și se va configura sincronizarea cu acesta. În funcție de dimensiunea documentului și viteza rețelei, acest proces poate dura.",
|
||||
"note": "De remarcat:",
|
||||
"password": "Parolă",
|
||||
"proxy-instruction": "Dacă lăsați câmpul de proxy nesetat, proxy-ul de sistem va fi folosit (valabil doar pentru aplicația de desktop)",
|
||||
"proxy-server": "Server-ul proxy (opțional)",
|
||||
"proxy-server-placeholder": "https://<sistem>:<port>",
|
||||
"server-host": "Adresa server-ului Trilium",
|
||||
"server-host-placeholder": "https://<sistem>:<port>",
|
||||
"password-placeholder": "Parolă"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronizare în curs",
|
||||
"outstanding-items": "Elemente de sincronizat:",
|
||||
"outstanding-items-default": "-",
|
||||
"successful": "Sincronizarea a fost configurată cu succes. Poate dura ceva timp pentru a finaliza sincronizarea inițială. După efectuarea ei se va redirecționa către pagina de autentificare."
|
||||
},
|
||||
"share_404": {
|
||||
"heading": "Pagină negăsită",
|
||||
"title": "Pagină negăsită"
|
||||
},
|
||||
"share_page": {
|
||||
"child-notes": "Subnotițe:",
|
||||
"clipped-from": "Această notiță a fost decupată inițial de pe {{- url}}",
|
||||
"no-content": "Această notiță nu are conținut.",
|
||||
"parent": "părinte:"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Luni",
|
||||
"tuesday": "Marți",
|
||||
"wednesday": "Miercuri",
|
||||
"thursday": "Joi",
|
||||
"friday": "Vineri",
|
||||
"saturday": "Sâmbătă",
|
||||
"sunday": "Duminică"
|
||||
},
|
||||
"months": {
|
||||
"january": "Ianuarie",
|
||||
"february": "Februarie",
|
||||
"march": "Martie",
|
||||
"april": "Aprilie",
|
||||
"may": "Mai",
|
||||
"june": "Iunie",
|
||||
"july": "Iulie",
|
||||
"august": "August",
|
||||
"september": "Septembrie",
|
||||
"october": "Octombrie",
|
||||
"november": "Noiembrie",
|
||||
"december": "Decembrie"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Căutare:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Calea către serverul de sincronizare nu este configurată. Configurați sincronizarea înainte.",
|
||||
"successful": "Comunicarea cu serverul de sincronizare a avut loc cu succes, s-a început sincronizarea."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"advanced-title": "Setări avansate",
|
||||
"appearance-title": "Aspect",
|
||||
"available-launchers-title": "Lansatoare disponibile",
|
||||
"backup-title": "Copii de siguranță",
|
||||
"base-abstract-launcher-title": "Lansator abstract de bază",
|
||||
"bookmarks-title": "Semne de carte",
|
||||
"built-in-widget-title": "Widget predefinit",
|
||||
"bulk-action-title": "Acțiuni în masă",
|
||||
"calendar-title": "Calendar",
|
||||
"code-notes-title": "Notițe de cod",
|
||||
"command-launcher-title": "Lansator de comenzi",
|
||||
"custom-widget-title": "Widget personalizat",
|
||||
"etapi-title": "ETAPI",
|
||||
"go-to-previous-note-title": "Mergi la notița anterioară",
|
||||
"images-title": "Imagini",
|
||||
"launch-bar-title": "Bară de lansare",
|
||||
"new-note-title": "Notiță nouă",
|
||||
"note-launcher-title": "Lansator de notițe",
|
||||
"note-map-title": "Harta notițelor",
|
||||
"open-today-journal-note-title": "Deschide notița de astăzi",
|
||||
"options-title": "Opțiuni",
|
||||
"other": "Diverse",
|
||||
"password-title": "Parolă",
|
||||
"protected-session-title": "Sesiune protejată",
|
||||
"recent-changes-title": "Schimbări recente",
|
||||
"script-launcher-title": "Lansator de script-uri",
|
||||
"search-history-title": "Istoric de căutare",
|
||||
"settings-title": "Setări",
|
||||
"shared-notes-title": "Notițe partajate",
|
||||
"quick-search-title": "Căutare rapidă",
|
||||
"root-title": "Notițe ascunse",
|
||||
"search-notes-title": "Căutare notițe",
|
||||
"shortcuts-title": "Scurtături",
|
||||
"spellcheck-title": "Corectare gramaticală",
|
||||
"sync-status-title": "Starea sincronizării",
|
||||
"sync-title": "Sincronizare",
|
||||
"text-notes": "Notițe text",
|
||||
"user-hidden-title": "Definite de utilizator",
|
||||
"backend-log-title": "Log backend",
|
||||
"spacer-title": "Separator",
|
||||
"sql-console-history-title": "Istoricul consolei SQL",
|
||||
"go-to-next-note-title": "Mergi la notița următoare",
|
||||
"launch-bar-templates-title": "Șabloane bară de lansare",
|
||||
"visible-launchers-title": "Lansatoare vizibile",
|
||||
"user-guide": "Ghidul de utilizare",
|
||||
"jump-to-note-title": "Sari la...",
|
||||
"llm-chat-title": "Întreabă Notes",
|
||||
"multi-factor-authentication-title": "Autentificare multi-factor",
|
||||
"ai-llm-title": "AI/LLM",
|
||||
"localization": "Limbă și regiune",
|
||||
"inbox-title": "Inbox"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Notiță nouă",
|
||||
"duplicate-note-suffix": "(dupl.)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).",
|
||||
"reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”."
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Document PDF (*.pdf)",
|
||||
"unable-to-export-message": "Notița curentă nu a putut fi exportată ca PDF.",
|
||||
"unable-to-export-title": "Nu s-a putut exporta ca PDF",
|
||||
"unable-to-save-message": "Nu s-a putut scrie fișierul selectat. Încercați din nou sau selectați altă destinație."
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Semne de carte",
|
||||
"close": "Închide Trilium",
|
||||
"new-note": "Notiță nouă",
|
||||
"recents": "Notițe recente",
|
||||
"today": "Mergi la notița de astăzi",
|
||||
"tooltip": "Trilium Notes",
|
||||
"show-windows": "Afișează ferestrele",
|
||||
"open_new_window": "Deschide fereastră nouă"
|
||||
},
|
||||
"migration": {
|
||||
"error_message": "Eroare la migrarea către versiunea {{version}}: {{stack}}",
|
||||
"old_version": "Nu se poate migra la ultima versiune direct de la versiunea dvs. Actualizați mai întâi la versiunea v0.60.4 și ulterior la această versiune.",
|
||||
"wrong_db_version": "Versiunea actuală a bazei de date ({{version}}) este mai nouă decât versiunea de bază de date suportată de aplicație ({{targetVersion}}), ceea ce înseamnă că a fost creată de către o versiune mai nouă de Trilium. Actualizați aplicația la ultima versiune pentru a putea continua."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Eroare"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"quick-search": "Căutare rapidă",
|
||||
"back-in-note-history": "Înapoi în istoricul notițelor",
|
||||
"forward-in-note-history": "Înainte în istoricul notițelor",
|
||||
"jump-to-note": "Mergi la...",
|
||||
"scroll-to-active-note": "Derulează la notița activă",
|
||||
"search-in-subtree": "Caută în subnotițe",
|
||||
"expand-subtree": "Expandează subnotițele",
|
||||
"collapse-tree": "Minimizează arborele de notițe",
|
||||
"collapse-subtree": "Ascunde subnotițele",
|
||||
"sort-child-notes": "Ordonează subnotițele",
|
||||
"create-note-after": "Crează notiță după",
|
||||
"create-note-into": "Crează subnotiță în",
|
||||
"create-note-into-inbox": "Crează notiță în inbox",
|
||||
"delete-notes": "Șterge notițe",
|
||||
"move-note-up": "Mută notița deasupra",
|
||||
"move-note-down": "Mută notița dedesubt",
|
||||
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
|
||||
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
|
||||
"edit-note-title": "Editează titlul notiței",
|
||||
"edit-branch-prefix": "Editează prefixul ramurii",
|
||||
"clone-notes-to": "Clonează notițele în",
|
||||
"move-notes-to": "Mută notițele în",
|
||||
"copy-notes-to-clipboard": "Copiază notițele în clipboard",
|
||||
"paste-notes-from-clipboard": "Lipește notițele din clipboard",
|
||||
"cut-notes-to-clipboard": "Decupează notițele în clipboard",
|
||||
"select-all-notes-in-parent": "Selectează toate notițele din părinte",
|
||||
"add-note-above-to-selection": "Adaugă notița de deasupra la selecție",
|
||||
"add-note-below-to-selection": "Adaugă notița de dedesubt la selecție",
|
||||
"duplicate-subtree": "Dublifică ierarhia",
|
||||
"open-new-tab": "Deschide în tab nou",
|
||||
"close-active-tab": "Închide tab-ul activ",
|
||||
"reopen-last-tab": "Redeschide ultimul tab",
|
||||
"activate-next-tab": "Activează tab-ul următorul",
|
||||
"activate-previous-tab": "Activează tab-ul anterior",
|
||||
"open-new-window": "Deschide în fereastră nouă",
|
||||
"toggle-system-tray-icon": "Afișează/ascunde iconița din bara de sistem",
|
||||
"toggle-zen-mode": "Activează/dezactivează modul zen",
|
||||
"switch-to-first-tab": "Mergi la primul tab",
|
||||
"switch-to-second-tab": "Mergi la al doilea tab",
|
||||
"switch-to-third-tab": "Mergi la al treilea tab",
|
||||
"switch-to-fourth-tab": "Mergi la al patrulea tab",
|
||||
"switch-to-fifth-tab": "Mergi la al cincelea tab",
|
||||
"switch-to-sixth-tab": "Mergi la al șaselea tab",
|
||||
"switch-to-seventh-tab": "Mergi la al șaptelea tab",
|
||||
"switch-to-eighth-tab": "Mergi la al optelea tab",
|
||||
"switch-to-ninth-tab": "Mergi la al nouălea tab",
|
||||
"switch-to-last-tab": "Mergi la ultimul tab",
|
||||
"show-note-source": "Afișează sursa notiței",
|
||||
"show-options": "Afișează opțiunile",
|
||||
"show-revisions": "Afișează reviziile",
|
||||
"show-recent-changes": "Afișează modificările recente",
|
||||
"show-sql-console": "Afișează consola SQL",
|
||||
"show-backend-log": "Afișează log-urile din backend",
|
||||
"show-help": "Afișează ghidul",
|
||||
"show-cheatsheet": "Afișează ghidul rapid",
|
||||
"add-link-to-text": "Inserează o legătură în text",
|
||||
"follow-link-under-cursor": "Urmează legătura de la poziția curentă",
|
||||
"insert-date-and-time-to-text": "Inserează data și timpul în text",
|
||||
"paste-markdown-into-text": "Lipește Markdown în text",
|
||||
"cut-into-note": "Decupează în subnotiță",
|
||||
"add-include-note-to-text": "Adaugă o includere de notiță în text",
|
||||
"edit-read-only-note": "Editează notiță ce este în modul citire",
|
||||
"add-new-label": "Adaugă o nouă etichetă",
|
||||
"add-new-relation": "Adaugă o nouă relație",
|
||||
"toggle-ribbon-tab-classic-editor": "Comută la tab-ul de panglică pentru formatare text",
|
||||
"toggle-ribbon-tab-basic-properties": "Comută la tab-ul de panglică pentru proprietăți de bază",
|
||||
"toggle-ribbon-tab-book-properties": "Comută la tab-ul de panglică pentru proprietăți colecție",
|
||||
"toggle-ribbon-tab-file-properties": "Comută la tab-ul de panglică pentru proprietăți fișier",
|
||||
"toggle-ribbon-tab-image-properties": "Comută la tab-ul de panglică pentru proprietăți imagini",
|
||||
"toggle-ribbon-tab-owned-attributes": "Comută la tab-ul de panglică pentru atribute proprii",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Comută la tab-ul de panglică pentru atribute moștenite",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Comută la tab-ul de panglică pentru atribute promovate",
|
||||
"toggle-ribbon-tab-note-map": "Comută la tab-ul de panglică pentru harta notiței",
|
||||
"toggle-ribbon-tab-note-info": "Comută la tab-ul de panglică pentru informații despre notiță",
|
||||
"toggle-ribbon-tab-note-paths": "Comută la tab-ul de panglică pentru căile notiței",
|
||||
"toggle-ribbon-tab-similar-notes": "Comută la tab-ul de panglică pentru notițe similare",
|
||||
"toggle-right-pane": "Comută panoul din dreapta",
|
||||
"print-active-note": "Imprimă notița activă",
|
||||
"export-active-note-as-pdf": "Exportă notița activă ca PDF",
|
||||
"open-note-externally": "Deschide notița într-o aplicație externă",
|
||||
"render-active-note": "Randează notița activă",
|
||||
"run-active-note": "Rulează notița activă",
|
||||
"toggle-note-hoisting": "Comută focalizarea notiței",
|
||||
"unhoist-note": "Defocalizează notița",
|
||||
"reload-frontend-app": "Reîmprospătează aplicația",
|
||||
"open-developer-tools": "Deschide unelete de dezvoltare",
|
||||
"find-in-text": "Caută în text",
|
||||
"toggle-left-pane": "Comută panoul din stânga",
|
||||
"toggle-full-screen": "Comută mod pe tot ecranul",
|
||||
"zoom-out": "Micșorare",
|
||||
"zoom-in": "Mărire",
|
||||
"reset-zoom-level": "Resetează nivelul de zoom",
|
||||
"copy-without-formatting": "Copiază fără formatare",
|
||||
"force-save-revision": "Forțează salvarea unei revizii",
|
||||
"command-palette": "Paleta de comenzi"
|
||||
},
|
||||
"weekdayNumber": "Săptămâna {weekNumber}",
|
||||
"quarterNumber": "Trimestrul {quarterNumber}",
|
||||
"share_theme": {
|
||||
"site-theme": "Tema site-ului",
|
||||
"search_placeholder": "Caută...",
|
||||
"image_alt": "Imaginea articolului",
|
||||
"last-updated": "Ultima actualizare: {{- date}}",
|
||||
"subpages": "Subpagini:",
|
||||
"on-this-page": "Pe această pagină",
|
||||
"expand": "Expandează"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"text-snippet": "Fragment de text",
|
||||
"description": "Descriere",
|
||||
"list-view": "Mod listă",
|
||||
"grid-view": "Mod grilă",
|
||||
"calendar": "Calendar",
|
||||
"table": "Tabel",
|
||||
"geo-map": "Hartă geografică",
|
||||
"start-date": "Dată de început",
|
||||
"end-date": "Dată de sfârșit",
|
||||
"start-time": "Timp de început",
|
||||
"end-time": "Timp de sfârșit",
|
||||
"geolocation": "Geolocație",
|
||||
"built-in-templates": "Șabloane predefinite",
|
||||
"board": "Tablă Kanban",
|
||||
"status": "Stare",
|
||||
"board_note_first": "Prima notiță",
|
||||
"board_note_second": "A doua notiță",
|
||||
"board_note_third": "A treia notiță",
|
||||
"board_status_todo": "De făcut",
|
||||
"board_status_progress": "În lucru",
|
||||
"board_status_done": "Finalizat"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,11 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
return "blobId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["blobId", "content", "ocr_text"];
|
||||
return ["blobId", "content"];
|
||||
}
|
||||
|
||||
content!: string | Buffer;
|
||||
contentLength!: number;
|
||||
ocr_text?: string | null;
|
||||
|
||||
constructor(row: BlobRow) {
|
||||
super();
|
||||
@@ -26,7 +25,6 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
this.blobId = row.blobId;
|
||||
this.content = row.content;
|
||||
this.contentLength = row.contentLength;
|
||||
this.ocr_text = row.ocr_text;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
}
|
||||
@@ -36,7 +34,6 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
blobId: this.blobId,
|
||||
content: this.content || null,
|
||||
contentLength: this.contentLength,
|
||||
ocr_text: this.ocr_text || null,
|
||||
dateModified: this.dateModified,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
|
||||
@@ -6,25 +6,6 @@
|
||||
|
||||
// Migrations should be kept in descending order, so the latest migration is first.
|
||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||
// Add OCR text column and last processed timestamp to blobs table
|
||||
{
|
||||
version: 234,
|
||||
sql: /*sql*/`\
|
||||
-- Add OCR text column to blobs table
|
||||
ALTER TABLE blobs ADD COLUMN ocr_text TEXT DEFAULT NULL;
|
||||
|
||||
-- Add OCR last processed timestamp to blobs table
|
||||
ALTER TABLE blobs ADD COLUMN ocr_last_processed TEXT DEFAULT NULL;
|
||||
|
||||
-- Create index for OCR text searches
|
||||
CREATE INDEX IF NOT EXISTS idx_blobs_ocr_text
|
||||
ON blobs (ocr_text);
|
||||
|
||||
-- Create index for OCR last processed timestamp
|
||||
CREATE INDEX IF NOT EXISTS idx_blobs_ocr_last_processed
|
||||
ON blobs (ocr_last_processed);
|
||||
`
|
||||
},
|
||||
// Migrate geo map to collection
|
||||
{
|
||||
version: 233,
|
||||
|
||||
@@ -308,7 +308,7 @@ describe("LLM API Tests", () => {
|
||||
let testChatId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks for clean state
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import options service to access mock
|
||||
@@ -449,10 +449,33 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming with note mentions", async () => {
|
||||
// This test simply verifies that the endpoint accepts note mentions
|
||||
// and returns the expected success response for streaming initiation
|
||||
// Mock becca for note content retrieval
|
||||
vi.doMock('../../becca/becca.js', () => ({
|
||||
default: {
|
||||
getNote: vi.fn().mockReturnValue({
|
||||
noteId: 'root',
|
||||
title: 'Root Note',
|
||||
getBlob: () => ({
|
||||
getContent: () => 'Root note content for testing'
|
||||
})
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
// Setup streaming with mention context
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
// Verify mention content is included
|
||||
expect(input.query).toContain('Tell me about this note');
|
||||
expect(input.query).toContain('Root note content for testing');
|
||||
|
||||
const callback = input.streamCallback;
|
||||
await callback('The root note contains', false, {});
|
||||
await callback(' important information.', true, {});
|
||||
});
|
||||
|
||||
const response = await supertest(app)
|
||||
.post(`/api/llm/chat/${testChatId}/messages/stream`)
|
||||
|
||||
.send({
|
||||
content: "Tell me about this note",
|
||||
useAdvancedContext: true,
|
||||
@@ -470,6 +493,16 @@ describe("LLM API Tests", () => {
|
||||
success: true,
|
||||
message: "Streaming initiated successfully"
|
||||
});
|
||||
|
||||
// Import ws service to access mock
|
||||
const ws = (await import("../../services/ws.js")).default;
|
||||
|
||||
// Verify thinking message was sent
|
||||
expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({
|
||||
type: 'llm-stream',
|
||||
chatNoteId: testChatId,
|
||||
thinking: 'Initializing streaming LLM response...'
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle streaming with thinking states", async () => {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import ocrRoutes from "./ocr.js";
|
||||
|
||||
// Mock the OCR service
|
||||
vi.mock("../../services/ocr/ocr_service.js", () => ({
|
||||
default: {
|
||||
isOCREnabled: vi.fn(() => true),
|
||||
startBatchProcessing: vi.fn(() => Promise.resolve({ success: true })),
|
||||
getBatchProgress: vi.fn(() => ({ inProgress: false, total: 0, processed: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock becca
|
||||
vi.mock("../../becca/becca.js", () => ({
|
||||
default: {}
|
||||
}));
|
||||
|
||||
// Mock log
|
||||
vi.mock("../../services/log.js", () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("OCR API", () => {
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
params: {},
|
||||
body: {},
|
||||
query: {}
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
triliumResponseHandled: false
|
||||
};
|
||||
});
|
||||
|
||||
it("should set triliumResponseHandled flag in batch processing", async () => {
|
||||
await ocrRoutes.batchProcessOCR(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ success: true });
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
|
||||
it("should set triliumResponseHandled flag in get batch progress", async () => {
|
||||
await ocrRoutes.getBatchProgress(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
});
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle errors and set triliumResponseHandled flag", async () => {
|
||||
// Mock service to throw error
|
||||
const ocrService = await import("../../services/ocr/ocr_service.js");
|
||||
vi.mocked(ocrService.default.startBatchProcessing).mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
await ocrRoutes.batchProcessOCR(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Test error"
|
||||
});
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,612 +0,0 @@
|
||||
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
|
||||
* /api/ocr/process-note/{noteId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific note
|
||||
* operationId: ocr-process-note
|
||||
* parameters:
|
||||
* - name: noteId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the note to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: OCR language code (e.g. 'eng', 'fra', 'deu')
|
||||
* default: 'eng'
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* result:
|
||||
* type: object
|
||||
* properties:
|
||||
* text:
|
||||
* type: string
|
||||
* confidence:
|
||||
* type: number
|
||||
* extractedAt:
|
||||
* type: string
|
||||
* language:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or unsupported file type
|
||||
* '404':
|
||||
* description: Note not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processNoteOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { noteId } = req.params;
|
||||
const { language = 'eng', forceReprocess = false } = req.body || {};
|
||||
|
||||
if (!noteId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Note ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if OCR is enabled
|
||||
if (!ocrService.isOCREnabled()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'OCR is not enabled in settings'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify note exists
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Note not found'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ocrService.processNoteOCR(noteId, {
|
||||
language,
|
||||
forceReprocess
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Note is not an image or has unsupported format'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error processing OCR for note: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/process-attachment/{attachmentId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific attachment
|
||||
* operationId: ocr-process-attachment
|
||||
* parameters:
|
||||
* - name: attachmentId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the attachment to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: OCR language code (e.g. 'eng', 'fra', 'deu')
|
||||
* default: 'eng'
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or unsupported file type
|
||||
* '404':
|
||||
* description: Attachment not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processAttachmentOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { attachmentId } = req.params;
|
||||
const { language = 'eng', forceReprocess = false } = req.body || {};
|
||||
|
||||
if (!attachmentId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Attachment ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if OCR is enabled
|
||||
if (!ocrService.isOCREnabled()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'OCR is not enabled in settings'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Attachment not found'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ocrService.processAttachmentOCR(attachmentId, {
|
||||
language,
|
||||
forceReprocess
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Attachment is not an image or has unsupported format'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error processing OCR for attachment: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/search:
|
||||
* get:
|
||||
* summary: Search for text in OCR results
|
||||
* operationId: ocr-search
|
||||
* parameters:
|
||||
* - name: q
|
||||
* in: query
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Search query text
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Search results
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* blobId:
|
||||
* type: string
|
||||
* text:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - missing search query
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function searchOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { q: searchText } = req.query;
|
||||
|
||||
if (!searchText || typeof searchText !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Search query is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = ocrService.searchOCRResults(searchText);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error searching OCR results: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-process:
|
||||
* post:
|
||||
* summary: Process OCR for all images without existing OCR results
|
||||
* operationId: ocr-batch-process
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing initiated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or already processing
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function batchProcessOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error initiating batch OCR processing: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-progress:
|
||||
* get:
|
||||
* summary: Get batch OCR processing progress
|
||||
* operationId: ocr-batch-progress
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing progress information
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* inProgress:
|
||||
* type: boolean
|
||||
* total:
|
||||
* type: number
|
||||
* processed:
|
||||
* type: number
|
||||
* percentage:
|
||||
* type: number
|
||||
* startTime:
|
||||
* type: string
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getBatchProgress(req: Request, res: Response) {
|
||||
try {
|
||||
const progress = ocrService.getBatchProgress();
|
||||
res.json(progress);
|
||||
(res as any).triliumResponseHandled = true;
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error getting batch OCR progress: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/stats:
|
||||
* get:
|
||||
* summary: Get OCR processing statistics
|
||||
* operationId: ocr-get-stats
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR statistics
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* stats:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalProcessed:
|
||||
* type: number
|
||||
* imageNotes:
|
||||
* type: number
|
||||
* imageAttachments:
|
||||
* type: number
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getOCRStats(req: Request, res: Response) {
|
||||
try {
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error getting OCR stats: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/delete/{blobId}:
|
||||
* delete:
|
||||
* summary: Delete OCR results for a specific blob
|
||||
* operationId: ocr-delete-results
|
||||
* parameters:
|
||||
* - name: blobId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the blob
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR results deleted successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - invalid parameters
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function deleteOCRResults(req: Request, res: Response) {
|
||||
try {
|
||||
const { blobId } = req.params;
|
||||
|
||||
if (!blobId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Blob ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ocrService.deleteOCRResult(blobId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OCR results deleted for blob ${blobId}`
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error deleting OCR results: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
searchOCR,
|
||||
batchProcessOCR,
|
||||
getBatchProgress,
|
||||
getOCRStats,
|
||||
deleteOCRResults,
|
||||
getNoteOCRText
|
||||
};
|
||||
@@ -108,13 +108,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"ollamaBaseUrl",
|
||||
"ollamaDefaultModel",
|
||||
"mfaEnabled",
|
||||
"mfaMethod",
|
||||
|
||||
// OCR options
|
||||
"ocrEnabled",
|
||||
"ocrLanguage",
|
||||
"ocrAutoProcessImages",
|
||||
"ocrMinConfidence"
|
||||
"mfaMethod"
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
|
||||
@@ -58,7 +58,6 @@ import ollamaRoute from "./api/ollama.js";
|
||||
import openaiRoute from "./api/openai.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import ocrRoute from "./api/ocr.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
@@ -386,16 +385,6 @@ function register(app: express.Application) {
|
||||
asyncApiRoute(GET, "/api/llm/providers/openai/models", openaiRoute.listModels);
|
||||
asyncApiRoute(GET, "/api/llm/providers/anthropic/models", anthropicRoute.listModels);
|
||||
|
||||
// OCR API
|
||||
asyncApiRoute(PST, "/api/ocr/process-note/:noteId", ocrRoute.processNoteOCR);
|
||||
asyncApiRoute(PST, "/api/ocr/process-attachment/:attachmentId", ocrRoute.processAttachmentOCR);
|
||||
asyncApiRoute(GET, "/api/ocr/search", ocrRoute.searchOCR);
|
||||
asyncApiRoute(PST, "/api/ocr/batch-process", ocrRoute.batchProcessOCR);
|
||||
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);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import build from "./build.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
|
||||
const APP_DB_VERSION = 234;
|
||||
const SYNC_VERSION = 37;
|
||||
const APP_DB_VERSION = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -6,9 +6,6 @@ import becca from "../becca/becca.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import hiddenSubtreeService from "./hidden_subtree.js";
|
||||
import oneTimeTimer from "./one_time_timer.js";
|
||||
import ocrService from "./ocr/ocr_service.js";
|
||||
import optionService from "./options.js";
|
||||
import log from "./log.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
|
||||
@@ -140,25 +137,6 @@ eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) =>
|
||||
}
|
||||
} else if (entityName === "notes") {
|
||||
runAttachedRelations(entity, "runOnNoteCreation", entity);
|
||||
|
||||
// Note: OCR processing for images is now handled in image.ts during image processing
|
||||
// OCR processing for files remains here since they don't go through image processing
|
||||
// Only auto-process if both OCR is enabled and auto-processing is enabled
|
||||
if (entity.type === 'file' && ocrService.isOCREnabled() && optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
// Check if the file MIME type is supported by any OCR processor
|
||||
const supportedMimeTypes = ocrService.getAllSupportedMimeTypes();
|
||||
|
||||
if (entity.mime && supportedMimeTypes.includes(entity.mime)) {
|
||||
// Process OCR asynchronously to avoid blocking note creation
|
||||
ocrService.processNoteOCR(entity.noteId).then(result => {
|
||||
if (result) {
|
||||
log.info(`Automatically processed OCR for file note ${entity.noteId} with MIME type ${entity.mime}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
log.error(`Failed to automatically process OCR for file note ${entity.noteId}: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ import sanitizeFilename from "sanitize-filename";
|
||||
import isSvg from "is-svg";
|
||||
import isAnimated from "is-animated";
|
||||
import htmlSanitizer from "./html_sanitizer.js";
|
||||
import ocrService, { type OCRResult } from "./ocr/ocr_service.js";
|
||||
|
||||
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean, noteId?: string) {
|
||||
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) {
|
||||
const compressImages = optionService.getOptionBool("compressImages");
|
||||
const origImageFormat = await getImageType(uploadBuffer);
|
||||
|
||||
@@ -25,42 +24,6 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm
|
||||
shrinkImageSwitch = false;
|
||||
}
|
||||
|
||||
// Schedule OCR processing in the background for best quality
|
||||
// Only auto-process if both OCR is enabled and auto-processing is enabled
|
||||
if (noteId && ocrService.isOCREnabled() && optionService.getOptionBool("ocrAutoProcessImages") && origImageFormat) {
|
||||
const imageMime = getImageMimeFromExtension(origImageFormat.ext);
|
||||
const supportedMimeTypes = ocrService.getAllSupportedMimeTypes();
|
||||
|
||||
if (supportedMimeTypes.includes(imageMime)) {
|
||||
// Process OCR asynchronously without blocking image creation
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const ocrResult = await ocrService.extractTextFromFile(uploadBuffer, imageMime);
|
||||
if (ocrResult) {
|
||||
// We need to get the entity again to get its blobId after it's been saved
|
||||
// noteId could be either a note ID or attachment ID
|
||||
const note = becca.getNote(noteId);
|
||||
const attachment = becca.getAttachment(noteId);
|
||||
|
||||
let blobId: string | undefined;
|
||||
if (note && note.blobId) {
|
||||
blobId = note.blobId;
|
||||
} else if (attachment && attachment.blobId) {
|
||||
blobId = attachment.blobId;
|
||||
}
|
||||
|
||||
if (blobId) {
|
||||
await ocrService.storeOCRResult(blobId, ocrResult);
|
||||
log.info(`Successfully processed OCR for image ${noteId} (${originalName})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for image ${noteId}: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let finalImageBuffer;
|
||||
let imageFormat;
|
||||
|
||||
@@ -109,7 +72,7 @@ function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string)
|
||||
note.setLabel("originalFileName", originalName);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, true, noteId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, true).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
note.save();
|
||||
@@ -145,7 +108,7 @@ function saveImage(parentNoteId: string, uploadBuffer: Buffer, originalName: str
|
||||
note.addLabel("originalFileName", originalName);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, shrinkImageSwitch, note.noteId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
|
||||
@@ -196,7 +159,7 @@ function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalNam
|
||||
}, 5000);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch, attachment.attachmentId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
// re-read, might be changed in the meantime
|
||||
if (!attachment.attachmentId) {
|
||||
|
||||
@@ -1,916 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// Mock Tesseract.js
|
||||
const mockWorker = {
|
||||
recognize: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
reinitialize: vi.fn()
|
||||
};
|
||||
|
||||
const mockTesseract = {
|
||||
createWorker: vi.fn().mockResolvedValue(mockWorker)
|
||||
};
|
||||
|
||||
vi.mock('tesseract.js', () => ({
|
||||
default: mockTesseract
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const mockOptions = {
|
||||
getOptionBool: vi.fn(),
|
||||
getOption: vi.fn()
|
||||
};
|
||||
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
const mockSql = {
|
||||
execute: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
getRows: vi.fn()
|
||||
};
|
||||
|
||||
const mockBecca = {
|
||||
getNote: vi.fn(),
|
||||
getAttachment: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../options.js', () => ({
|
||||
default: mockOptions
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
default: mockLog
|
||||
}));
|
||||
|
||||
vi.mock('../sql.js', () => ({
|
||||
default: mockSql
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
// Import the service after mocking
|
||||
let ocrService: typeof import('./ocr_service.js').default;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockOptions.getOption.mockReturnValue('eng');
|
||||
mockSql.execute.mockImplementation(() => ({ lastInsertRowid: 1 }));
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
|
||||
// Set up createWorker to properly set the worker on the service
|
||||
mockTesseract.createWorker.mockImplementation(async () => {
|
||||
return mockWorker;
|
||||
});
|
||||
|
||||
// Dynamically import the service to ensure mocks are applied
|
||||
const module = await import('./ocr_service.js');
|
||||
ocrService = module.default; // It's an instance, not a class
|
||||
|
||||
// Reset the OCR service state
|
||||
(ocrService as any).isInitialized = false;
|
||||
(ocrService as any).worker = null;
|
||||
(ocrService as any).isProcessing = false;
|
||||
(ocrService as any).batchProcessingState = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OCRService', () => {
|
||||
describe('isOCREnabled', () => {
|
||||
it('should return true when OCR is enabled in options', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(true);
|
||||
expect(mockOptions.getOptionBool).toHaveBeenCalledWith('ocrEnabled');
|
||||
});
|
||||
|
||||
it('should return false when OCR is disabled in options', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(false);
|
||||
expect(mockOptions.getOptionBool).toHaveBeenCalledWith('ocrEnabled');
|
||||
});
|
||||
|
||||
it('should return false when options throws an error', () => {
|
||||
mockOptions.getOptionBool.mockImplementation(() => {
|
||||
throw new Error('Options not available');
|
||||
});
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedMimeType', () => {
|
||||
it('should return true for supported image MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType('image/jpeg')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/jpg')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/png')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/gif')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/bmp')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/tiff')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unsupported MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType('text/plain')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('application/pdf')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('video/mp4')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('audio/mp3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType(null as any)).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType(undefined as any)).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize Tesseract worker successfully', async () => {
|
||||
await ocrService.initialize();
|
||||
|
||||
expect(mockTesseract.createWorker).toHaveBeenCalledWith('eng', 1, {
|
||||
workerPath: expect.any(String),
|
||||
corePath: expect.any(String),
|
||||
logger: expect.any(Function)
|
||||
});
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Initializing OCR service with Tesseract.js...');
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service initialized successfully');
|
||||
});
|
||||
|
||||
it('should not reinitialize if already initialized', async () => {
|
||||
await ocrService.initialize();
|
||||
mockTesseract.createWorker.mockClear();
|
||||
|
||||
await ocrService.initialize();
|
||||
|
||||
expect(mockTesseract.createWorker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
const error = new Error('Tesseract initialization failed');
|
||||
mockTesseract.createWorker.mockRejectedValue(error);
|
||||
|
||||
await expect(ocrService.initialize()).rejects.toThrow('Tesseract initialization failed');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to initialize OCR service: Error: Tesseract initialization failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextFromImage', () => {
|
||||
const mockImageBuffer = Buffer.from('fake-image-data');
|
||||
|
||||
beforeEach(async () => {
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
});
|
||||
|
||||
it('should extract text successfully with default options', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: 'Extracted text from image',
|
||||
confidence: 95
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Extracted text from image',
|
||||
confidence: 0.95,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockWorker.recognize).toHaveBeenCalledWith(mockImageBuffer);
|
||||
});
|
||||
|
||||
it('should extract text with custom language', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: 'French text',
|
||||
confidence: 88
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer, { language: 'fra' });
|
||||
|
||||
expect(result.language).toBe('fra');
|
||||
expect(mockWorker.terminate).toHaveBeenCalled();
|
||||
expect(mockTesseract.createWorker).toHaveBeenCalledWith('fra', 1, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle OCR recognition errors', async () => {
|
||||
const error = new Error('OCR recognition failed');
|
||||
mockWorker.recognize.mockRejectedValue(error);
|
||||
|
||||
await expect(ocrService.extractTextFromImage(mockImageBuffer)).rejects.toThrow('OCR recognition failed');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('OCR text extraction failed: Error: OCR recognition failed');
|
||||
});
|
||||
|
||||
it('should handle empty or low-confidence results', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: ' ',
|
||||
confidence: 15
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer);
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.confidence).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeOCRResult', () => {
|
||||
it('should store OCR result in blob successfully', async () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await ocrService.storeOCRResult('blob123', ocrResult);
|
||||
|
||||
expect(mockSql.execute).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE blobs SET ocr_text = ?'),
|
||||
['Sample text', 'blob123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined blobId gracefully', async () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await ocrService.storeOCRResult(undefined, ocrResult);
|
||||
|
||||
expect(mockSql.execute).not.toHaveBeenCalled();
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Cannot store OCR result: blobId is undefined');
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const error = new Error('Database error');
|
||||
mockSql.execute.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await expect(ocrService.storeOCRResult('blob123', ocrResult)).rejects.toThrow('Database error');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to store OCR result for blob blob123: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processNoteOCR', () => {
|
||||
const mockNote = {
|
||||
noteId: 'note123',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob123',
|
||||
getContent: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockNote.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
it('should process note OCR successfully', async () => {
|
||||
// Ensure getRow returns null for all calls in this test
|
||||
mockSql.getRow.mockImplementation(() => null);
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Note image text',
|
||||
confidence: 90
|
||||
}
|
||||
};
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Note image text',
|
||||
confidence: 0.9,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note123');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return existing OCR result if forceReprocess is false', async () => {
|
||||
const existingResult = {
|
||||
ocr_text: 'Existing text'
|
||||
};
|
||||
mockSql.getRow.mockReturnValue(existingResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Existing text',
|
||||
confidence: 0.95,
|
||||
language: 'eng',
|
||||
extractedAt: expect.any(String)
|
||||
});
|
||||
expect(mockNote.getContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reprocess if forceReprocess is true', async () => {
|
||||
const existingResult = {
|
||||
ocr_text: 'Existing text'
|
||||
};
|
||||
mockSql.getRow.mockResolvedValue(existingResult);
|
||||
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'New processed text',
|
||||
confidence: 95
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123', { forceReprocess: true });
|
||||
|
||||
expect(result?.text).toBe('New processed text');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null for non-existent note', async () => {
|
||||
mockBecca.getNote.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processNoteOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Note nonexistent not found');
|
||||
});
|
||||
|
||||
it('should return null for unsupported MIME type', async () => {
|
||||
mockNote.mime = 'text/plain';
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Note note123 has unsupported MIME type text/plain, skipping OCR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAttachmentOCR', () => {
|
||||
const mockAttachment = {
|
||||
attachmentId: 'attach123',
|
||||
role: 'image',
|
||||
mime: 'image/png',
|
||||
blobId: 'blob456',
|
||||
getContent: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getAttachment.mockReturnValue(mockAttachment);
|
||||
mockAttachment.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
it('should process attachment OCR successfully', async () => {
|
||||
// Ensure getRow returns null for all calls in this test
|
||||
mockSql.getRow.mockImplementation(() => null);
|
||||
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Attachment image text',
|
||||
confidence: 92
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('attach123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Attachment image text',
|
||||
confidence: 0.92,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockBecca.getAttachment).toHaveBeenCalledWith('attach123');
|
||||
});
|
||||
|
||||
it('should return null for non-existent attachment', async () => {
|
||||
mockBecca.getAttachment.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Attachment nonexistent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchOCRResults', () => {
|
||||
it('should search OCR results successfully', () => {
|
||||
const mockResults = [
|
||||
{
|
||||
blobId: 'blob1',
|
||||
ocr_text: 'Sample search text'
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(mockResults);
|
||||
|
||||
const results = ocrService.searchOCRResults('search');
|
||||
|
||||
expect(results).toEqual([{
|
||||
blobId: 'blob1',
|
||||
text: 'Sample search text'
|
||||
}]);
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WHERE ocr_text LIKE ?'),
|
||||
['%search%']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle search errors gracefully', () => {
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
const results = ocrService.searchOCRResults('search');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to search OCR results: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOCRStats', () => {
|
||||
it('should return OCR statistics successfully', () => {
|
||||
const mockStats = {
|
||||
total_processed: 150
|
||||
};
|
||||
const mockNoteStats = {
|
||||
count: 100
|
||||
};
|
||||
const mockAttachmentStats = {
|
||||
count: 50
|
||||
};
|
||||
|
||||
mockSql.getRow.mockReturnValueOnce(mockStats);
|
||||
mockSql.getRow.mockReturnValueOnce(mockNoteStats);
|
||||
mockSql.getRow.mockReturnValueOnce(mockAttachmentStats);
|
||||
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalProcessed: 150,
|
||||
imageNotes: 100,
|
||||
imageAttachments: 50
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing statistics gracefully', () => {
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalProcessed: 0,
|
||||
imageNotes: 0,
|
||||
imageAttachments: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
describe('startBatchProcessing', () => {
|
||||
beforeEach(() => {
|
||||
// Reset batch processing state
|
||||
ocrService.cancelBatchProcessing();
|
||||
});
|
||||
|
||||
it('should start batch processing when images are available', async () => {
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 }); // image notes
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 3 }); // image attachments
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSql.getRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return error if batch processing already in progress', async () => {
|
||||
// Start first batch
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 3 });
|
||||
|
||||
// Mock background processing queries
|
||||
const mockImageNotes = Array.from({length: 5}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Start without awaiting to keep it in progress
|
||||
const firstStart = ocrService.startBatchProcessing();
|
||||
|
||||
// Try to start second batch immediately
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
// Clean up by awaiting the first one
|
||||
await firstStart;
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Batch processing already in progress'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if OCR is disabled', async () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'OCR is disabled'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if no images need processing', async () => {
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 }); // image notes
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 }); // image attachments
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'No images found that need OCR processing'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const error = new Error('Database connection failed');
|
||||
mockSql.getRow.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Database connection failed'
|
||||
});
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
'Failed to start batch processing: Database connection failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatchProgress', () => {
|
||||
it('should return initial progress state', () => {
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
expect(progress.inProgress).toBe(false);
|
||||
expect(progress.total).toBe(0);
|
||||
expect(progress.processed).toBe(0);
|
||||
});
|
||||
|
||||
it('should return progress with percentage when total > 0', async () => {
|
||||
// Start batch processing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 10 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Mock the background processing queries to return items that will take time to process
|
||||
const mockImageNotes = Array.from({length: 10}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes); // image notes query
|
||||
mockSql.getRows.mockReturnValueOnce([]); // image attachments query
|
||||
|
||||
const startPromise = ocrService.startBatchProcessing();
|
||||
|
||||
// Check progress immediately after starting (before awaiting)
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
await startPromise;
|
||||
|
||||
expect(progress.inProgress).toBe(true);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.processed).toBe(0);
|
||||
expect(progress.percentage).toBe(0);
|
||||
expect(progress.startTime).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelBatchProcessing', () => {
|
||||
it('should cancel ongoing batch processing', async () => {
|
||||
// Start batch processing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Mock background processing queries
|
||||
const mockImageNotes = Array.from({length: 5}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
const startPromise = ocrService.startBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(true);
|
||||
|
||||
await startPromise;
|
||||
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(false);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
|
||||
it('should do nothing if no batch processing is running', () => {
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(mockLog.info).not.toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processBatchInBackground', () => {
|
||||
beforeEach(async () => {
|
||||
await ocrService.initialize();
|
||||
});
|
||||
|
||||
it('should process image notes and attachments in sequence', async () => {
|
||||
// Clear all mocks at the start of this test to ensure clean state
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reinitialize OCR service after clearing mocks
|
||||
await ocrService.initialize();
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
// Mock data for batch processing
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' },
|
||||
{ noteId: 'note2', mime: 'image/png', blobId: 'blob2' }
|
||||
];
|
||||
const imageAttachments = [
|
||||
{ attachmentId: 'attach1', mime: 'image/gif', blobId: 'blob3' }
|
||||
];
|
||||
|
||||
// Setup mocks for startBatchProcessing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 }); // image notes count
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 1 }); // image attachments count
|
||||
|
||||
// Setup mocks for background processing
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes); // image notes query
|
||||
mockSql.getRows.mockReturnValueOnce(imageAttachments); // image attachments query
|
||||
|
||||
// Mock successful OCR processing
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: { text: 'Test text', confidence: 95 }
|
||||
});
|
||||
|
||||
// Mock notes and attachments
|
||||
const mockNote1 = {
|
||||
noteId: 'note1',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob1',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
const mockNote2 = {
|
||||
noteId: 'note2',
|
||||
type: 'image',
|
||||
mime: 'image/png',
|
||||
blobId: 'blob2',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
const mockAttachment = {
|
||||
attachmentId: 'attach1',
|
||||
role: 'image',
|
||||
mime: 'image/gif',
|
||||
blobId: 'blob3',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
|
||||
mockBecca.getNote.mockImplementation((noteId) => {
|
||||
if (noteId === 'note1') return mockNote1;
|
||||
if (noteId === 'note2') return mockNote2;
|
||||
return null;
|
||||
});
|
||||
mockBecca.getAttachment.mockReturnValue(mockAttachment);
|
||||
mockSql.getRow.mockReturnValue(null); // No existing OCR results
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
// Need to wait longer since there's a 500ms delay between each item in batch processing
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Verify notes and attachments were processed
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note1');
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note2');
|
||||
expect(mockBecca.getAttachment).toHaveBeenCalledWith('attach1');
|
||||
});
|
||||
|
||||
it('should handle processing errors gracefully', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' }
|
||||
];
|
||||
|
||||
// Setup mocks for startBatchProcessing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 1 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Setup mocks for background processing
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Mock note that will cause an error
|
||||
const mockNote = {
|
||||
noteId: 'note1',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob1',
|
||||
getContent: vi.fn().mockImplementation(() => { throw new Error('Failed to get content'); })
|
||||
};
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify error was logged but processing continued
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to process OCR for note note1')
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop processing when cancelled', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' },
|
||||
{ noteId: 'note2', mime: 'image/png', blobId: 'blob2' }
|
||||
];
|
||||
|
||||
// Setup mocks
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Cancel immediately
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify processing was stopped early
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip unsupported MIME types', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'text/plain', blobId: 'blob1' }, // unsupported
|
||||
{ noteId: 'note2', mime: 'image/jpeg', blobId: 'blob2' } // supported
|
||||
];
|
||||
|
||||
// Setup mocks
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
const mockNote = {
|
||||
noteId: 'note2',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob2',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: { text: 'Test text', confidence: 95 }
|
||||
});
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify only supported MIME type was processed
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note2');
|
||||
expect(mockBecca.getNote).not.toHaveBeenCalledWith('note1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOCRResult', () => {
|
||||
it('should delete OCR result successfully', () => {
|
||||
ocrService.deleteOCRResult('blob123');
|
||||
|
||||
expect(mockSql.execute).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE blobs SET ocr_text = NULL'),
|
||||
['blob123']
|
||||
);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Deleted OCR result for blob blob123');
|
||||
});
|
||||
|
||||
it('should handle deletion errors', () => {
|
||||
mockSql.execute.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
expect(() => ocrService.deleteOCRResult('blob123')).toThrow('Database error');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to delete OCR result for blob blob123: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCurrentlyProcessing', () => {
|
||||
it('should return false initially', () => {
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true during processing', async () => {
|
||||
mockBecca.getNote.mockReturnValue({
|
||||
noteId: 'note123',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob123',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
});
|
||||
mockSql.getRow.mockResolvedValue(null);
|
||||
|
||||
await ocrService.initialize();
|
||||
mockWorker.recognize.mockImplementation(() => {
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(true);
|
||||
return Promise.resolve({
|
||||
data: { text: 'test', confidence: 90 }
|
||||
});
|
||||
});
|
||||
|
||||
await ocrService.processNoteOCR('note123');
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should terminate worker on cleanup', async () => {
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
await ocrService.cleanup();
|
||||
|
||||
expect(mockWorker.terminate).toHaveBeenCalled();
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service cleaned up');
|
||||
});
|
||||
|
||||
it('should handle cleanup when worker is not initialized', async () => {
|
||||
await ocrService.cleanup();
|
||||
|
||||
expect(mockWorker.terminate).not.toHaveBeenCalled();
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service cleaned up');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,752 +0,0 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import log from '../log.js';
|
||||
import sql from '../sql.js';
|
||||
import becca from '../../becca/becca.js';
|
||||
import options from '../options.js';
|
||||
import { ImageProcessor } from './processors/image_processor.js';
|
||||
import { PDFProcessor } from './processors/pdf_processor.js';
|
||||
import { TIFFProcessor } from './processors/tiff_processor.js';
|
||||
import { OfficeProcessor } from './processors/office_processor.js';
|
||||
import { FileProcessor } from './processors/file_processor.js';
|
||||
|
||||
export interface OCRResult {
|
||||
text: string;
|
||||
confidence: number;
|
||||
extractedAt: string;
|
||||
language?: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
export interface OCRProcessingOptions {
|
||||
language?: string;
|
||||
forceReprocess?: boolean;
|
||||
confidence?: number;
|
||||
enablePDFTextExtraction?: boolean;
|
||||
}
|
||||
|
||||
interface OCRBlobRow {
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
ocr_last_processed?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR Service for extracting text from images and other OCR-able objects
|
||||
* Uses Tesseract.js for text recognition
|
||||
*/
|
||||
class OCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private isProcessing = false;
|
||||
private processors: Map<string, FileProcessor> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize file processors
|
||||
this.processors.set('image', new ImageProcessor());
|
||||
this.processors.set('pdf', new PDFProcessor());
|
||||
this.processors.set('tiff', new TIFFProcessor());
|
||||
this.processors.set('office', new OfficeProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OCR is enabled in settings
|
||||
*/
|
||||
isOCREnabled(): boolean {
|
||||
try {
|
||||
return options.getOptionBool('ocrEnabled');
|
||||
} catch (error) {
|
||||
log.error(`Failed to check OCR enabled status: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is supported for OCR
|
||||
*/
|
||||
isSupportedMimeType(mimeType: string): boolean {
|
||||
if (!mimeType || typeof mimeType !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const supportedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
];
|
||||
return supportedTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from file buffer using appropriate processor
|
||||
*/
|
||||
async extractTextFromFile(fileBuffer: Buffer, mimeType: string, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info(`Starting OCR text extraction for MIME type: ${mimeType}`);
|
||||
this.isProcessing = true;
|
||||
|
||||
// Find appropriate processor
|
||||
const processor = this.getProcessorForMimeType(mimeType);
|
||||
if (!processor) {
|
||||
throw new Error(`No processor found for MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const result = await processor.extractText(fileBuffer, options);
|
||||
|
||||
log.info(`OCR extraction completed. Confidence: ${result.confidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`OCR text extraction failed: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for a note (image type)
|
||||
*/
|
||||
async processNoteOCR(noteId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled in settings');
|
||||
return null;
|
||||
}
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
log.error(`Note ${noteId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if note type and MIME type are supported for OCR
|
||||
if (note.type === 'image') {
|
||||
if (!this.isSupportedMimeType(note.mime)) {
|
||||
log.info(`Image note ${noteId} has unsupported MIME type ${note.mime}, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
} else if (note.type === 'file') {
|
||||
// Check if file MIME type is supported by any processor
|
||||
const processor = this.getProcessorForMimeType(note.mime);
|
||||
if (!processor) {
|
||||
log.info(`File note ${noteId} has unsupported MIME type ${note.mime} for OCR, skipping`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info(`Note ${noteId} is not an image or file note, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if OCR already exists and is up-to-date
|
||||
const existingOCR = this.getStoredOCRResult(note.blobId);
|
||||
if (existingOCR && !options.forceReprocess && note.blobId && !this.needsReprocessing(note.blobId)) {
|
||||
log.info(`OCR already exists and is up-to-date for note ${noteId}, returning cached result`);
|
||||
return existingOCR;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = note.getContent();
|
||||
if (!content || !(content instanceof Buffer)) {
|
||||
throw new Error(`Cannot get image content for note ${noteId}`);
|
||||
}
|
||||
|
||||
const ocrResult = await this.extractTextFromFile(content, note.mime, options);
|
||||
|
||||
// Store OCR result in blob
|
||||
await this.storeOCRResult(note.blobId, ocrResult);
|
||||
|
||||
return ocrResult;
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for note ${noteId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for an attachment
|
||||
*/
|
||||
async processAttachmentOCR(attachmentId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled in settings');
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
log.error(`Attachment ${attachmentId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if attachment role and MIME type are supported for OCR
|
||||
if (attachment.role === 'image') {
|
||||
if (!this.isSupportedMimeType(attachment.mime)) {
|
||||
log.info(`Image attachment ${attachmentId} has unsupported MIME type ${attachment.mime}, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
} else if (attachment.role === 'file') {
|
||||
// Check if file MIME type is supported by any processor
|
||||
const processor = this.getProcessorForMimeType(attachment.mime);
|
||||
if (!processor) {
|
||||
log.info(`File attachment ${attachmentId} has unsupported MIME type ${attachment.mime} for OCR, skipping`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info(`Attachment ${attachmentId} is not an image or file, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if OCR already exists and is up-to-date
|
||||
const existingOCR = this.getStoredOCRResult(attachment.blobId);
|
||||
if (existingOCR && !options.forceReprocess && attachment.blobId && !this.needsReprocessing(attachment.blobId)) {
|
||||
log.info(`OCR already exists and is up-to-date for attachment ${attachmentId}, returning cached result`);
|
||||
return existingOCR;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = attachment.getContent();
|
||||
if (!content || !(content instanceof Buffer)) {
|
||||
throw new Error(`Cannot get image content for attachment ${attachmentId}`);
|
||||
}
|
||||
|
||||
const ocrResult = await this.extractTextFromFile(content, attachment.mime, options);
|
||||
|
||||
// Store OCR result in blob
|
||||
await this.storeOCRResult(attachment.blobId, ocrResult);
|
||||
|
||||
return ocrResult;
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for attachment ${attachmentId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OCR result in blob
|
||||
*/
|
||||
async storeOCRResult(blobId: string | undefined, ocrResult: OCRResult): Promise<void> {
|
||||
if (!blobId) {
|
||||
log.error('Cannot store OCR result: blobId is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store OCR text and timestamp in blobs table
|
||||
sql.execute(`
|
||||
UPDATE blobs SET
|
||||
ocr_text = ?,
|
||||
ocr_last_processed = ?
|
||||
WHERE blobId = ?
|
||||
`, [
|
||||
ocrResult.text,
|
||||
new Date().toISOString(),
|
||||
blobId
|
||||
]);
|
||||
|
||||
log.info(`Stored OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to store OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OCR result from blob
|
||||
*/
|
||||
private getStoredOCRResult(blobId: string | undefined): OCRResult | null {
|
||||
if (!blobId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sql.getRow<{
|
||||
ocr_text: string | null;
|
||||
}>(`
|
||||
SELECT ocr_text
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
if (!row || !row.ocr_text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return basic OCR result from stored text
|
||||
// Note: we lose confidence, language, and extractedAt metadata
|
||||
// but gain simplicity by storing directly in blob
|
||||
return {
|
||||
text: row.ocr_text,
|
||||
confidence: 0.95, // Default high confidence for existing OCR
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: 'eng'
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Failed to get OCR result for blob ${blobId}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for text in OCR results
|
||||
*/
|
||||
searchOCRResults(searchText: string): Array<{ blobId: string; text: string }> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT blobId, ocr_text
|
||||
FROM blobs
|
||||
WHERE ocr_text LIKE ?
|
||||
AND ocr_text IS NOT NULL
|
||||
`;
|
||||
const params = [`%${searchText}%`];
|
||||
|
||||
const rows = sql.getRows<OCRBlobRow>(query, params);
|
||||
|
||||
return rows.map(row => ({
|
||||
blobId: row.blobId,
|
||||
text: row.ocr_text
|
||||
}));
|
||||
} catch (error) {
|
||||
log.error(`Failed to search OCR results: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete OCR results for a blob
|
||||
*/
|
||||
deleteOCRResult(blobId: string): void {
|
||||
try {
|
||||
sql.execute(`
|
||||
UPDATE blobs SET ocr_text = NULL
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
log.info(`Deleted OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for all files that don't have OCR results yet or need reprocessing
|
||||
*/
|
||||
async processAllImages(): Promise<void> {
|
||||
return this.processAllBlobsNeedingOCR();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OCR statistics
|
||||
*/
|
||||
getOCRStats(): { totalProcessed: number; imageNotes: number; imageAttachments: number } {
|
||||
try {
|
||||
const stats = sql.getRow<{
|
||||
total_processed: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as total_processed
|
||||
FROM blobs
|
||||
WHERE ocr_text IS NOT NULL AND ocr_text != ''
|
||||
`);
|
||||
|
||||
// Count image notes with OCR
|
||||
const noteStats = sql.getRow<{
|
||||
count: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM notes n
|
||||
JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type = 'image'
|
||||
AND n.isDeleted = 0
|
||||
AND b.ocr_text IS NOT NULL AND b.ocr_text != ''
|
||||
`);
|
||||
|
||||
// Count image attachments with OCR
|
||||
const attachmentStats = sql.getRow<{
|
||||
count: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM attachments a
|
||||
JOIN blobs b ON a.blobId = b.blobId
|
||||
WHERE a.role = 'image'
|
||||
AND a.isDeleted = 0
|
||||
AND b.ocr_text IS NOT NULL AND b.ocr_text != ''
|
||||
`);
|
||||
|
||||
return {
|
||||
totalProcessed: stats?.total_processed || 0,
|
||||
imageNotes: noteStats?.count || 0,
|
||||
imageAttachments: attachmentStats?.count || 0
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Failed to get OCR stats: ${error}`);
|
||||
return { totalProcessed: 0, imageNotes: 0, imageAttachments: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up OCR service
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
log.info('OCR service cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently processing
|
||||
*/
|
||||
isCurrentlyProcessing(): boolean {
|
||||
return this.isProcessing;
|
||||
}
|
||||
|
||||
// Batch processing state
|
||||
private batchProcessingState: {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
startTime?: Date;
|
||||
} = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Start batch OCR processing with progress tracking
|
||||
*/
|
||||
async startBatchProcessing(): Promise<{ success: boolean; message?: string }> {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
return { success: false, message: 'Batch processing already in progress' };
|
||||
}
|
||||
|
||||
if (!this.isOCREnabled()) {
|
||||
return { success: false, message: 'OCR is disabled' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Count total blobs needing OCR processing
|
||||
const blobsNeedingOCR = this.getBlobsNeedingOCR();
|
||||
const totalCount = blobsNeedingOCR.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return { success: false, message: 'No images found that need OCR processing' };
|
||||
}
|
||||
|
||||
// Initialize batch processing state
|
||||
this.batchProcessingState = {
|
||||
inProgress: true,
|
||||
total: totalCount,
|
||||
processed: 0,
|
||||
startTime: new Date()
|
||||
};
|
||||
|
||||
// Start processing in background
|
||||
this.processBatchInBackground(blobsNeedingOCR).catch(error => {
|
||||
log.error(`Batch processing failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.batchProcessingState.inProgress = false;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error(`Failed to start batch processing: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { success: false, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch processing progress
|
||||
*/
|
||||
getBatchProgress(): { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } {
|
||||
const result: { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } = { ...this.batchProcessingState };
|
||||
if (result.total > 0) {
|
||||
result.percentage = (result.processed / result.total) * 100;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process batch OCR in background with progress tracking
|
||||
*/
|
||||
private async processBatchInBackground(blobsToProcess: Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }>): Promise<void> {
|
||||
try {
|
||||
log.info('Starting batch OCR processing...');
|
||||
|
||||
for (const blobInfo of blobsToProcess) {
|
||||
if (!this.batchProcessingState.inProgress) {
|
||||
break; // Stop if processing was cancelled
|
||||
}
|
||||
|
||||
try {
|
||||
if (blobInfo.entityType === 'note') {
|
||||
await this.processNoteOCR(blobInfo.entityId);
|
||||
} else {
|
||||
await this.processAttachmentOCR(blobInfo.entityId);
|
||||
}
|
||||
this.batchProcessingState.processed++;
|
||||
// Add small delay to prevent overwhelming the system
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for ${blobInfo.entityType} ${blobInfo.entityId}: ${error}`);
|
||||
this.batchProcessingState.processed++; // Count as processed even if failed
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
this.batchProcessingState.inProgress = false;
|
||||
log.info(`Batch OCR processing completed. Processed ${this.batchProcessingState.processed} files.`);
|
||||
} catch (error) {
|
||||
log.error(`Batch OCR processing failed: ${error}`);
|
||||
this.batchProcessingState.inProgress = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel batch processing
|
||||
*/
|
||||
cancelBatchProcessing(): void {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
this.batchProcessingState.inProgress = false;
|
||||
log.info('Batch OCR processing cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processor for a given MIME type
|
||||
*/
|
||||
private getProcessorForMimeType(mimeType: string): FileProcessor | null {
|
||||
for (const processor of this.processors.values()) {
|
||||
if (processor.canProcess(mimeType)) {
|
||||
return processor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MIME types supported by all registered processors
|
||||
*/
|
||||
getAllSupportedMimeTypes(): string[] {
|
||||
const supportedTypes = new Set<string>();
|
||||
|
||||
// Gather MIME types from all registered processors
|
||||
for (const processor of this.processors.values()) {
|
||||
const processorTypes = processor.getSupportedMimeTypes();
|
||||
processorTypes.forEach(type => supportedTypes.add(type));
|
||||
}
|
||||
|
||||
return Array.from(supportedTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is supported by any processor
|
||||
*/
|
||||
isSupportedByAnyProcessor(mimeType: string): boolean {
|
||||
if (!mimeType) return false;
|
||||
|
||||
// Check if any processor can handle this MIME type
|
||||
const processor = this.getProcessorForMimeType(mimeType);
|
||||
return processor !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if blob needs OCR re-processing due to content changes
|
||||
*/
|
||||
needsReprocessing(blobId: string): boolean {
|
||||
if (!blobId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const blobInfo = sql.getRow<{
|
||||
utcDateModified: string;
|
||||
ocr_last_processed: string | null;
|
||||
}>(`
|
||||
SELECT utcDateModified, ocr_last_processed
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
if (!blobInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If OCR was never processed, it needs processing
|
||||
if (!blobInfo.ocr_last_processed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If blob was modified after last OCR processing, it needs re-processing
|
||||
const blobModified = new Date(blobInfo.utcDateModified);
|
||||
const lastOcrProcessed = new Date(blobInfo.ocr_last_processed);
|
||||
|
||||
return blobModified > lastOcrProcessed;
|
||||
} catch (error) {
|
||||
log.error(`Failed to check if blob ${blobId} needs reprocessing: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate OCR results for a blob (clear ocr_text and ocr_last_processed)
|
||||
*/
|
||||
invalidateOCRResult(blobId: string): void {
|
||||
if (!blobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sql.execute(`
|
||||
UPDATE blobs SET
|
||||
ocr_text = NULL,
|
||||
ocr_last_processed = NULL
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
log.info(`Invalidated OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to invalidate OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blobs that need OCR processing (modified after last OCR or never processed)
|
||||
*/
|
||||
getBlobsNeedingOCR(): Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }> {
|
||||
try {
|
||||
// Get notes with blobs that need OCR (both image notes and file notes with supported MIME types)
|
||||
const noteBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT n.blobId, n.mime as mimeType, n.noteId as entityId
|
||||
FROM notes n
|
||||
JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE (
|
||||
n.type = 'image'
|
||||
OR (
|
||||
n.type = 'file'
|
||||
AND n.mime IN (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/rtf',
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND n.isDeleted = 0
|
||||
AND n.blobId IS NOT NULL
|
||||
AND (
|
||||
b.ocr_last_processed IS NULL
|
||||
OR b.utcDateModified > b.ocr_last_processed
|
||||
)
|
||||
`);
|
||||
|
||||
// Get attachments with blobs that need OCR (both image and file attachments with supported MIME types)
|
||||
const attachmentBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT a.blobId, a.mime as mimeType, a.attachmentId as entityId
|
||||
FROM attachments a
|
||||
JOIN blobs b ON a.blobId = b.blobId
|
||||
WHERE (
|
||||
a.role = 'image'
|
||||
OR (
|
||||
a.role = 'file'
|
||||
AND a.mime IN (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/rtf',
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND a.isDeleted = 0
|
||||
AND a.blobId IS NOT NULL
|
||||
AND (
|
||||
b.ocr_last_processed IS NULL
|
||||
OR b.utcDateModified > b.ocr_last_processed
|
||||
)
|
||||
`);
|
||||
|
||||
// Combine results
|
||||
const result = [
|
||||
...noteBlobs.map(blob => ({ ...blob, entityType: 'note' as const })),
|
||||
...attachmentBlobs.map(blob => ({ ...blob, entityType: 'attachment' as const }))
|
||||
];
|
||||
|
||||
// Return all results (no need to filter by MIME type as we already did in the query)
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get blobs needing OCR: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for all blobs that need it (auto-processing)
|
||||
*/
|
||||
async processAllBlobsNeedingOCR(): Promise<void> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled, skipping auto-processing');
|
||||
return;
|
||||
}
|
||||
|
||||
const blobsNeedingOCR = this.getBlobsNeedingOCR();
|
||||
if (blobsNeedingOCR.length === 0) {
|
||||
log.info('No blobs need OCR processing');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Auto-processing OCR for ${blobsNeedingOCR.length} blobs...`);
|
||||
|
||||
for (const blobInfo of blobsNeedingOCR) {
|
||||
try {
|
||||
if (blobInfo.entityType === 'note') {
|
||||
await this.processNoteOCR(blobInfo.entityId);
|
||||
} else {
|
||||
await this.processAttachmentOCR(blobInfo.entityId);
|
||||
}
|
||||
|
||||
// Add small delay to prevent overwhelming the system
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
log.error(`Failed to auto-process OCR for ${blobInfo.entityType} ${blobInfo.entityId}: ${error}`);
|
||||
// Continue with other blobs
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Auto-processing OCR completed');
|
||||
}
|
||||
}
|
||||
|
||||
export default new OCRService();
|
||||
@@ -1,33 +0,0 @@
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
|
||||
/**
|
||||
* Base class for file processors that extract text from different file types
|
||||
*/
|
||||
export abstract class FileProcessor {
|
||||
/**
|
||||
* Check if this processor can handle the given MIME type
|
||||
*/
|
||||
abstract canProcess(mimeType: string): boolean;
|
||||
|
||||
/**
|
||||
* Extract text from the given file buffer
|
||||
*/
|
||||
abstract extractText(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult>;
|
||||
|
||||
/**
|
||||
* Get the processing type identifier
|
||||
*/
|
||||
abstract getProcessingType(): string;
|
||||
|
||||
/**
|
||||
* Get list of MIME types supported by this processor
|
||||
*/
|
||||
abstract getSupportedMimeTypes(): string[];
|
||||
|
||||
/**
|
||||
* Clean up any resources
|
||||
*/
|
||||
cleanup(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import log from '../../log.js';
|
||||
import options from '../../options.js';
|
||||
|
||||
/**
|
||||
* Image processor for extracting text from image files using Tesseract
|
||||
*/
|
||||
export class ImageProcessor extends FileProcessor {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private isInitialized = false;
|
||||
private readonly supportedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return this.supportedTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.worker) {
|
||||
throw new Error('Image processor worker not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Starting image OCR text extraction...');
|
||||
|
||||
// Set language if specified and different from current
|
||||
// Support multi-language format like 'ron+eng'
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
|
||||
// Validate language format
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
if (language !== 'eng') {
|
||||
// For different languages, create a new worker
|
||||
await this.worker.terminate();
|
||||
log.info(`Initializing Tesseract worker for language(s): ${language}`);
|
||||
this.worker = await Tesseract.createWorker(language, 1, {
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
log.info(`Image OCR progress (${language}): ${Math.round(m.progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.worker.recognize(buffer);
|
||||
|
||||
// Filter text based on minimum confidence threshold
|
||||
const { filteredText, overallConfidence } = this.filterTextByConfidence(result.data, options);
|
||||
|
||||
const ocrResult: OCRResult = {
|
||||
text: filteredText,
|
||||
confidence: overallConfidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: 1
|
||||
};
|
||||
|
||||
log.info(`Image OCR extraction completed. Confidence: ${ocrResult.confidence}%, Text length: ${ocrResult.text.length}`);
|
||||
return ocrResult;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Image OCR text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Initializing image OCR processor with Tesseract.js...');
|
||||
|
||||
// Configure proper paths for Node.js environment
|
||||
const tesseractDir = require.resolve('tesseract.js').replace('/src/index.js', '');
|
||||
const workerPath = require.resolve('tesseract.js/src/worker-script/node/index.js');
|
||||
const corePath = require.resolve('tesseract.js-core/tesseract-core.wasm.js');
|
||||
|
||||
log.info(`Using worker path: ${workerPath}`);
|
||||
log.info(`Using core path: ${corePath}`);
|
||||
|
||||
this.worker = await Tesseract.createWorker(this.getDefaultOCRLanguage(), 1, {
|
||||
workerPath,
|
||||
corePath,
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
log.info(`Image OCR progress: ${Math.round(m.progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.isInitialized = true;
|
||||
log.info('Image OCR processor initialized successfully');
|
||||
} catch (error) {
|
||||
log.error(`Failed to initialize image OCR processor: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
log.info('Image OCR processor cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter text based on minimum confidence threshold
|
||||
*/
|
||||
private filterTextByConfidence(data: any, options: OCRProcessingOptions): { filteredText: string; overallConfidence: number } {
|
||||
const minConfidence = this.getMinConfidenceThreshold();
|
||||
|
||||
// If no minimum confidence set, return original text
|
||||
if (minConfidence <= 0) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence: data.confidence / 100
|
||||
};
|
||||
}
|
||||
|
||||
let filteredWords: string[] = [];
|
||||
let validConfidences: number[] = [];
|
||||
|
||||
// Tesseract provides word-level data
|
||||
if (data.words && Array.isArray(data.words)) {
|
||||
for (const word of data.words) {
|
||||
const wordConfidence = word.confidence / 100; // Convert to decimal
|
||||
|
||||
if (wordConfidence >= minConfidence) {
|
||||
filteredWords.push(word.text);
|
||||
validConfidences.push(wordConfidence);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: if word-level data not available, use overall confidence
|
||||
const overallConfidence = data.confidence / 100;
|
||||
if (overallConfidence >= minConfidence) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence
|
||||
};
|
||||
} else {
|
||||
log.info(`Entire text filtered out due to low confidence ${overallConfidence} (below threshold ${minConfidence})`);
|
||||
return {
|
||||
filteredText: '',
|
||||
overallConfidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average confidence of accepted words
|
||||
const averageConfidence = validConfidences.length > 0
|
||||
? validConfidences.reduce((sum, conf) => sum + conf, 0) / validConfidences.length
|
||||
: 0;
|
||||
|
||||
const filteredText = filteredWords.join(' ').trim();
|
||||
|
||||
log.info(`Filtered OCR text: ${filteredWords.length} words kept out of ${data.words?.length || 0} total words (min confidence: ${minConfidence})`);
|
||||
|
||||
return {
|
||||
filteredText,
|
||||
overallConfidence: averageConfidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum confidence threshold from options
|
||||
*/
|
||||
private getMinConfidenceThreshold(): number {
|
||||
const minConfidence = options.getOption('ocrMinConfidence') ?? 0;
|
||||
return parseFloat(minConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as officeParser from 'officeparser';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* Office document processor for extracting text and images from DOCX/XLSX/PPTX files
|
||||
*/
|
||||
export class OfficeProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // XLSX
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PPTX
|
||||
'application/msword', // DOC
|
||||
'application/vnd.ms-excel', // XLS
|
||||
'application/vnd.ms-powerpoint', // PPT
|
||||
'application/rtf' // RTF
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return this.supportedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting Office document text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// Extract text from Office document
|
||||
const data = await this.parseOfficeDocument(buffer);
|
||||
|
||||
// Extract text from Office document
|
||||
const combinedText = data.data && data.data.trim().length > 0 ? data.data.trim() : '';
|
||||
const confidence = combinedText.length > 0 ? 0.99 : 0; // High confidence for direct text extraction
|
||||
|
||||
const result: OCRResult = {
|
||||
text: combinedText,
|
||||
confidence: confidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: language,
|
||||
pageCount: 1 // Office documents are treated as single logical document
|
||||
};
|
||||
|
||||
log.info(`Office document text extraction completed. Confidence: ${confidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Office document text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseOfficeDocument(buffer: Buffer): Promise<{ data: string }> {
|
||||
try {
|
||||
// Use promise-based API directly
|
||||
const data = await officeParser.parseOfficeAsync(buffer, {
|
||||
outputErrorToConsole: false,
|
||||
newlineDelimiter: '\n',
|
||||
ignoreNotes: false,
|
||||
putNotesAtLast: false
|
||||
});
|
||||
|
||||
return {
|
||||
data: data || ''
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Office document parsing failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'office';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import * as pdfParse from 'pdf-parse';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
/**
|
||||
* PDF processor for extracting text from PDF files
|
||||
* First tries to extract existing text, then falls back to OCR on images
|
||||
*/
|
||||
export class PDFProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = ['application/pdf'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase() === 'application/pdf';
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting PDF text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// First try to extract existing text from PDF
|
||||
if (options.enablePDFTextExtraction !== false) {
|
||||
const textResult = await this.extractTextFromPDF(buffer, options);
|
||||
if (textResult.text.trim().length > 0) {
|
||||
log.info(`PDF text extraction successful. Length: ${textResult.text.length}`);
|
||||
return textResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to OCR if no text found or PDF text extraction is disabled
|
||||
log.info('No text found in PDF or text extraction disabled, falling back to OCR...');
|
||||
return await this.extractTextViaOCR(buffer, options);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`PDF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async extractTextFromPDF(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult> {
|
||||
try {
|
||||
const data = await pdfParse(buffer);
|
||||
|
||||
return {
|
||||
text: data.text.trim(),
|
||||
confidence: 0.99, // High confidence for direct text extraction
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: data.numpages
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`PDF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async extractTextViaOCR(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult> {
|
||||
try {
|
||||
// Convert PDF to images and OCR each page
|
||||
// For now, we'll use a simple approach - convert first page to image
|
||||
// In a full implementation, we'd convert all pages
|
||||
|
||||
// This is a simplified implementation
|
||||
// In practice, you might want to use pdf2pic or similar library
|
||||
// to convert PDF pages to images for OCR
|
||||
|
||||
// For now, we'll return a placeholder result
|
||||
// indicating that OCR on PDF is not fully implemented
|
||||
log.info('PDF to image conversion not fully implemented, returning placeholder');
|
||||
|
||||
return {
|
||||
text: '[PDF OCR not fully implemented - would convert PDF pages to images and OCR each page]',
|
||||
confidence: 0.0,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: 1
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`PDF OCR extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import sharp from 'sharp';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* TIFF processor for extracting text from multi-page TIFF files
|
||||
*/
|
||||
export class TIFFProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = ['image/tiff', 'image/tif'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase() === 'image/tiff' || mimeType.toLowerCase() === 'image/tif';
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting TIFF text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// Check if this is a multi-page TIFF
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const pageCount = metadata.pages || 1;
|
||||
|
||||
let combinedText = '';
|
||||
let totalConfidence = 0;
|
||||
|
||||
// Process each page
|
||||
for (let page = 0; page < pageCount; page++) {
|
||||
try {
|
||||
log.info(`Processing TIFF page ${page + 1}/${pageCount}...`);
|
||||
|
||||
// Extract page as PNG buffer
|
||||
const pageBuffer = await sharp(buffer, { page })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// OCR the page
|
||||
const pageResult = await this.imageProcessor.extractText(pageBuffer, options);
|
||||
|
||||
if (pageResult.text.trim().length > 0) {
|
||||
if (combinedText.length > 0) {
|
||||
combinedText += '\n\n--- Page ' + (page + 1) + ' ---\n';
|
||||
}
|
||||
combinedText += pageResult.text;
|
||||
totalConfidence += pageResult.confidence;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to process TIFF page ${page + 1}: ${error}`);
|
||||
// Continue with other pages
|
||||
}
|
||||
}
|
||||
|
||||
const averageConfidence = pageCount > 0 ? totalConfidence / pageCount : 0;
|
||||
|
||||
const result: OCRResult = {
|
||||
text: combinedText.trim(),
|
||||
confidence: averageConfidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: pageCount
|
||||
};
|
||||
|
||||
log.info(`TIFF text extraction completed. Pages: ${pageCount}, Confidence: ${averageConfidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`TIFF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'tiff';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -211,12 +211,6 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "aiTemperature", value: "0.7", isSynced: true },
|
||||
{ name: "aiSystemPrompt", value: "", isSynced: true },
|
||||
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
|
||||
|
||||
// OCR options
|
||||
{ name: "ocrEnabled", value: "false", isSynced: true },
|
||||
{ name: "ocrLanguage", value: "eng", isSynced: true },
|
||||
{ name: "ocrAutoProcessImages", value: "true", isSynced: true },
|
||||
{ name: "ocrMinConfidence", value: "0.55", isSynced: true },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import Expression from "./expression.js";
|
||||
import SearchContext from "../search_context.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import sql from "../../sql.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
|
||||
/**
|
||||
* Search expression for finding text within OCR-extracted content from images
|
||||
*/
|
||||
export default class OCRContentExpression extends Expression {
|
||||
private searchText: string;
|
||||
|
||||
constructor(searchText: string) {
|
||||
super();
|
||||
this.searchText = searchText;
|
||||
}
|
||||
|
||||
execute(inputNoteSet: NoteSet, executionContext: object, searchContext: SearchContext): NoteSet {
|
||||
// Don't search OCR content if it's not enabled
|
||||
if (!this.isOCRSearchEnabled()) {
|
||||
return new NoteSet();
|
||||
}
|
||||
|
||||
const resultNoteSet = new NoteSet();
|
||||
const ocrResults = this.searchOCRContent(this.searchText);
|
||||
|
||||
for (const ocrResult of ocrResults) {
|
||||
// Find notes that use this blob
|
||||
const notes = sql.getRows<{noteId: string}>(`
|
||||
SELECT noteId FROM notes
|
||||
WHERE blobId = ? AND isDeleted = 0
|
||||
`, [ocrResult.blobId]);
|
||||
|
||||
for (const noteRow of notes) {
|
||||
const note = becca.getNote(noteRow.noteId);
|
||||
if (note && !note.isDeleted && inputNoteSet.hasNoteId(note.noteId)) {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
// Find attachments that use this blob and their parent notes
|
||||
const attachments = sql.getRows<{ownerId: string}>(`
|
||||
SELECT ownerId FROM attachments
|
||||
WHERE blobId = ? AND isDeleted = 0
|
||||
`, [ocrResult.blobId]);
|
||||
|
||||
for (const attachmentRow of attachments) {
|
||||
const note = becca.getNote(attachmentRow.ownerId);
|
||||
if (note && !note.isDeleted && inputNoteSet.hasNoteId(note.noteId)) {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add highlight tokens for OCR matches
|
||||
if (ocrResults.length > 0) {
|
||||
const tokens = this.extractHighlightTokens(this.searchText);
|
||||
searchContext.highlightedTokens.push(...tokens);
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
private isOCRSearchEnabled(): boolean {
|
||||
try {
|
||||
const optionService = require('../../options.js').default;
|
||||
return optionService.getOptionBool('ocrEnabled');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private searchOCRContent(searchText: string): Array<{
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
}> {
|
||||
try {
|
||||
// Search in blobs table for OCR text
|
||||
const query = `
|
||||
SELECT blobId, ocr_text
|
||||
FROM blobs
|
||||
WHERE ocr_text LIKE ?
|
||||
AND ocr_text IS NOT NULL
|
||||
AND ocr_text != ''
|
||||
LIMIT 50
|
||||
`;
|
||||
const params = [`%${searchText}%`];
|
||||
|
||||
return sql.getRows<{
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
}>(query, params);
|
||||
} catch (error) {
|
||||
console.error('Error searching OCR content:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extractHighlightTokens(searchText: string): string[] {
|
||||
// Split search text into words and return them as highlight tokens
|
||||
return searchText
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 2)
|
||||
.map(token => token.toLowerCase());
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `OCRContent('${this.searchText}')`;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import sql from "../sql.js";
|
||||
import options from "../options.js";
|
||||
|
||||
class SearchResult {
|
||||
notePathArray: string[];
|
||||
@@ -50,9 +48,6 @@ class SearchResult {
|
||||
this.addScoreForStrings(tokens, note.title, 2.0); // Increased to give more weight to title matches
|
||||
this.addScoreForStrings(tokens, this.notePathTitle, 0.3); // Reduced to further de-emphasize path matches
|
||||
|
||||
// Add OCR scoring - weight between title and content matches
|
||||
this.addOCRScore(tokens, 1.5);
|
||||
|
||||
if (note.isInHiddenSubtree()) {
|
||||
this.score = this.score / 3; // Increased penalty for hidden notes
|
||||
}
|
||||
@@ -75,37 +70,6 @@ class SearchResult {
|
||||
}
|
||||
this.score += tokenScore;
|
||||
}
|
||||
|
||||
addOCRScore(tokens: string[], factor: number) {
|
||||
try {
|
||||
// Check if OCR is enabled
|
||||
if (!options.getOptionBool('ocrEnabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for OCR results for this note and its attachments
|
||||
const ocrResults = sql.getRows(`
|
||||
SELECT b.ocr_text
|
||||
FROM blobs b
|
||||
WHERE b.ocr_text IS NOT NULL
|
||||
AND b.ocr_text != ''
|
||||
AND (
|
||||
b.blobId = (SELECT blobId FROM notes WHERE noteId = ? AND isDeleted = 0)
|
||||
OR b.blobId IN (
|
||||
SELECT blobId FROM attachments WHERE ownerId = ? AND isDeleted = 0
|
||||
)
|
||||
)
|
||||
`, [this.noteId, this.noteId]);
|
||||
|
||||
for (const ocrResult of ocrResults as Array<{ocr_text: string}>) {
|
||||
// Add score for OCR text matches
|
||||
this.addScoreForStrings(tokens, ocrResult.ocr_text, factor);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR service is not available
|
||||
console.debug('OCR scoring failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchResult;
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
const mockSql = {
|
||||
getRows: vi.fn()
|
||||
};
|
||||
|
||||
const mockOptions = {
|
||||
getOptionBool: vi.fn()
|
||||
};
|
||||
|
||||
const mockBecca = {
|
||||
notes: {},
|
||||
getNote: vi.fn()
|
||||
};
|
||||
|
||||
const mockBeccaService = {
|
||||
getNoteTitleForPath: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../sql.js', () => ({
|
||||
default: mockSql
|
||||
}));
|
||||
|
||||
vi.mock('../options.js', () => ({
|
||||
default: mockOptions
|
||||
}));
|
||||
|
||||
// The SearchResult now uses proper ES imports which are mocked above
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca_service.js', () => ({
|
||||
default: mockBeccaService
|
||||
}));
|
||||
|
||||
// Import SearchResult after mocking
|
||||
let SearchResult: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
mockBeccaService.getNoteTitleForPath.mockReturnValue('Test Note Title');
|
||||
|
||||
// Setup mock note
|
||||
const mockNote = {
|
||||
noteId: 'test123',
|
||||
title: 'Test Note',
|
||||
isInHiddenSubtree: vi.fn().mockReturnValue(false)
|
||||
};
|
||||
mockBecca.notes['test123'] = mockNote;
|
||||
|
||||
// Dynamically import SearchResult
|
||||
const module = await import('./search_result.js');
|
||||
SearchResult = module.default;
|
||||
});
|
||||
|
||||
describe('SearchResult', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with note path array', () => {
|
||||
const searchResult = new SearchResult(['root', 'folder', 'test123']);
|
||||
|
||||
expect(searchResult.notePathArray).toEqual(['root', 'folder', 'test123']);
|
||||
expect(searchResult.noteId).toBe('test123');
|
||||
expect(searchResult.notePath).toBe('root/folder/test123');
|
||||
expect(searchResult.score).toBe(0);
|
||||
expect(mockBeccaService.getNoteTitleForPath).toHaveBeenCalledWith(['root', 'folder', 'test123']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeScore', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
describe('basic scoring', () => {
|
||||
it('should give highest score for exact note ID match', () => {
|
||||
searchResult.computeScore('test123', ['test123']);
|
||||
expect(searchResult.score).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('should give high score for exact title match', () => {
|
||||
searchResult.computeScore('test note', ['test', 'note']);
|
||||
expect(searchResult.score).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('should give medium score for title prefix match', () => {
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it('should give lower score for title word match', () => {
|
||||
mockBecca.notes['test123'].title = 'This is a test note';
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCR scoring integration', () => {
|
||||
beforeEach(() => {
|
||||
// Mock OCR-enabled
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should add OCR score when OCR results exist', () => {
|
||||
const mockOCRResults = [
|
||||
{
|
||||
extracted_text: 'sample text from image',
|
||||
confidence: 0.95
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(mockOCRResults);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM ocr_results'),
|
||||
['test123', 'test123']
|
||||
);
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply confidence weighting to OCR scores', () => {
|
||||
const highConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.95
|
||||
}
|
||||
];
|
||||
const lowConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.30
|
||||
}
|
||||
];
|
||||
|
||||
// Test high confidence
|
||||
mockSql.getRows.mockReturnValue(highConfidenceResult);
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
const highConfidenceScore = searchResult.score;
|
||||
|
||||
// Reset and test low confidence
|
||||
searchResult.score = 0;
|
||||
mockSql.getRows.mockReturnValue(lowConfidenceResult);
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
const lowConfidenceScore = searchResult.score;
|
||||
|
||||
expect(highConfidenceScore).toBeGreaterThan(lowConfidenceScore);
|
||||
});
|
||||
|
||||
it('should handle multiple OCR results', () => {
|
||||
const multipleResults = [
|
||||
{
|
||||
extracted_text: 'first sample text',
|
||||
confidence: 0.90
|
||||
},
|
||||
{
|
||||
extracted_text: 'second sample document',
|
||||
confidence: 0.85
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(multipleResults);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
// Score should account for multiple matches
|
||||
});
|
||||
|
||||
it('should skip OCR scoring when OCR is disabled', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle OCR scoring errors gracefully', () => {
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
}).not.toThrow();
|
||||
|
||||
// Score should still be calculated from other factors
|
||||
expect(searchResult.score).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden notes penalty', () => {
|
||||
it('should apply penalty for hidden notes', () => {
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(true);
|
||||
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const hiddenScore = searchResult.score;
|
||||
|
||||
// Reset and test non-hidden
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(false);
|
||||
searchResult.score = 0;
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const normalScore = searchResult.score;
|
||||
|
||||
expect(normalScore).toBeGreaterThan(hiddenScore);
|
||||
expect(hiddenScore).toBe(normalScore / 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addScoreForStrings', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
it('should give highest score for exact token match', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const exactScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sampling text', 1.0);
|
||||
const prefixScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'text sample text', 1.0);
|
||||
const partialScore = searchResult.score;
|
||||
|
||||
expect(exactScore).toBeGreaterThan(prefixScore);
|
||||
expect(exactScore).toBeGreaterThanOrEqual(partialScore);
|
||||
});
|
||||
|
||||
it('should apply factor multiplier correctly', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 2.0);
|
||||
const doubleFactorScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const singleFactorScore = searchResult.score;
|
||||
|
||||
expect(doubleFactorScore).toBe(singleFactorScore * 2);
|
||||
});
|
||||
|
||||
it('should handle multiple tokens', () => {
|
||||
searchResult.addScoreForStrings(['hello', 'world'], 'hello world test', 1.0);
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const lowerCaseScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'SAMPLE text', 1.0);
|
||||
const upperCaseScore = searchResult.score;
|
||||
|
||||
expect(upperCaseScore).toEqual(lowerCaseScore);
|
||||
expect(upperCaseScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOCRScore', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
it('should query for both note and attachment OCR results', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM ocr_results'),
|
||||
['test123', 'test123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply minimum confidence multiplier', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
const lowConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.1 // Very low confidence
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(lowConfidenceResult);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.0);
|
||||
|
||||
// Should still get some score due to minimum 0.5x multiplier
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle database query errors', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should skip when OCR is disabled', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle options service errors', () => {
|
||||
mockOptions.getOptionBool.mockImplementation(() => {
|
||||
throw new Error('Options service unavailable');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import ValueExtractor from "../value_extractor.js";
|
||||
import { removeDiacritic } from "../../utils.js";
|
||||
import TrueExp from "../expressions/true.js";
|
||||
import IsHiddenExp from "../expressions/is_hidden.js";
|
||||
import OCRContentExpression from "../expressions/ocr_content.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
import type { TokenData, TokenStructure } from "./types.js";
|
||||
import type Expression from "../expressions/expression.js";
|
||||
@@ -34,20 +33,11 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchExpressions: Expression[] = [
|
||||
new NoteFlatTextExp(tokens)
|
||||
];
|
||||
|
||||
if (!searchContext.fastSearch) {
|
||||
searchExpressions.push(new NoteContentFulltextExp("*=*", { tokens, flatText: true }));
|
||||
|
||||
// Add OCR content search for each token
|
||||
for (const token of tokens) {
|
||||
searchExpressions.push(new OCRContentExpression(token));
|
||||
}
|
||||
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp("*=*", { tokens, flatText: true })]);
|
||||
} else {
|
||||
return new NoteFlatTextExp(tokens);
|
||||
}
|
||||
|
||||
return new OrExp(searchExpressions);
|
||||
}
|
||||
|
||||
const OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", ">", ">=", "<", "<=", "%="]);
|
||||
|
||||
96
docs/Release Notes/!!!meta.json
vendored
96
docs/Release Notes/!!!meta.json
vendored
@@ -61,6 +61,32 @@
|
||||
"attachments": [],
|
||||
"dirFileName": "Release Notes",
|
||||
"children": [
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "lvOuiWsLDv8F",
|
||||
"notePath": [
|
||||
"hD3V4hiu2VW4",
|
||||
"lvOuiWsLDv8F"
|
||||
],
|
||||
"title": "v0.97.2",
|
||||
"notePosition": 10,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "template",
|
||||
"value": "wyurrlcDl416",
|
||||
"isInheritable": false,
|
||||
"position": 60
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "v0.97.2.md",
|
||||
"attachments": []
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "OtFZ6Nd9vM3n",
|
||||
@@ -69,7 +95,7 @@
|
||||
"OtFZ6Nd9vM3n"
|
||||
],
|
||||
"title": "v0.97.1",
|
||||
"notePosition": 10,
|
||||
"notePosition": 20,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -95,7 +121,7 @@
|
||||
"SJZ5PwfzHSQ1"
|
||||
],
|
||||
"title": "v0.97.0",
|
||||
"notePosition": 20,
|
||||
"notePosition": 30,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -121,7 +147,7 @@
|
||||
"mYXFde3LuNR7"
|
||||
],
|
||||
"title": "v0.96.0",
|
||||
"notePosition": 30,
|
||||
"notePosition": 40,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -147,7 +173,7 @@
|
||||
"jthwbL0FdaeU"
|
||||
],
|
||||
"title": "v0.95.0",
|
||||
"notePosition": 40,
|
||||
"notePosition": 50,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -173,7 +199,7 @@
|
||||
"7HGYsJbLuhnv"
|
||||
],
|
||||
"title": "v0.94.1",
|
||||
"notePosition": 50,
|
||||
"notePosition": 60,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -199,7 +225,7 @@
|
||||
"Neq53ujRGBqv"
|
||||
],
|
||||
"title": "v0.94.0",
|
||||
"notePosition": 60,
|
||||
"notePosition": 70,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -225,7 +251,7 @@
|
||||
"VN3xnce1vLkX"
|
||||
],
|
||||
"title": "v0.93.0",
|
||||
"notePosition": 70,
|
||||
"notePosition": 80,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -243,7 +269,7 @@
|
||||
"WRaBfQqPr6qo"
|
||||
],
|
||||
"title": "v0.92.7",
|
||||
"notePosition": 80,
|
||||
"notePosition": 90,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -269,7 +295,7 @@
|
||||
"a2rwfKNmUFU1"
|
||||
],
|
||||
"title": "v0.92.6",
|
||||
"notePosition": 90,
|
||||
"notePosition": 100,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -287,7 +313,7 @@
|
||||
"fEJ8qErr0BKL"
|
||||
],
|
||||
"title": "v0.92.5-beta",
|
||||
"notePosition": 100,
|
||||
"notePosition": 110,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -305,7 +331,7 @@
|
||||
"kkkZQQGSXjwy"
|
||||
],
|
||||
"title": "v0.92.4",
|
||||
"notePosition": 110,
|
||||
"notePosition": 120,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -323,7 +349,7 @@
|
||||
"vAroNixiezaH"
|
||||
],
|
||||
"title": "v0.92.3-beta",
|
||||
"notePosition": 120,
|
||||
"notePosition": 130,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -341,7 +367,7 @@
|
||||
"mHEq1wxAKNZd"
|
||||
],
|
||||
"title": "v0.92.2-beta",
|
||||
"notePosition": 130,
|
||||
"notePosition": 140,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -359,7 +385,7 @@
|
||||
"IykjoAmBpc61"
|
||||
],
|
||||
"title": "v0.92.1-beta",
|
||||
"notePosition": 140,
|
||||
"notePosition": 150,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -377,7 +403,7 @@
|
||||
"dq2AJ9vSBX4Y"
|
||||
],
|
||||
"title": "v0.92.0-beta",
|
||||
"notePosition": 150,
|
||||
"notePosition": 160,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -395,7 +421,7 @@
|
||||
"3a8aMe4jz4yM"
|
||||
],
|
||||
"title": "v0.91.6",
|
||||
"notePosition": 160,
|
||||
"notePosition": 170,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -413,7 +439,7 @@
|
||||
"8djQjkiDGESe"
|
||||
],
|
||||
"title": "v0.91.5",
|
||||
"notePosition": 170,
|
||||
"notePosition": 180,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -431,7 +457,7 @@
|
||||
"OylxVoVJqNmr"
|
||||
],
|
||||
"title": "v0.91.4-beta",
|
||||
"notePosition": 180,
|
||||
"notePosition": 190,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -449,7 +475,7 @@
|
||||
"tANGQDvnyhrj"
|
||||
],
|
||||
"title": "v0.91.3-beta",
|
||||
"notePosition": 190,
|
||||
"notePosition": 200,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -467,7 +493,7 @@
|
||||
"hMoBfwSoj1SC"
|
||||
],
|
||||
"title": "v0.91.2-beta",
|
||||
"notePosition": 200,
|
||||
"notePosition": 210,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -485,7 +511,7 @@
|
||||
"a2XMSKROCl9z"
|
||||
],
|
||||
"title": "v0.91.1-beta",
|
||||
"notePosition": 210,
|
||||
"notePosition": 220,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -503,7 +529,7 @@
|
||||
"yqXFvWbLkuMD"
|
||||
],
|
||||
"title": "v0.90.12",
|
||||
"notePosition": 220,
|
||||
"notePosition": 230,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -521,7 +547,7 @@
|
||||
"veS7pg311yJP"
|
||||
],
|
||||
"title": "v0.90.11-beta",
|
||||
"notePosition": 230,
|
||||
"notePosition": 240,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -539,7 +565,7 @@
|
||||
"sq5W9TQxRqMq"
|
||||
],
|
||||
"title": "v0.90.10-beta",
|
||||
"notePosition": 240,
|
||||
"notePosition": 250,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -557,7 +583,7 @@
|
||||
"yFEGVCUM9tPx"
|
||||
],
|
||||
"title": "v0.90.9-beta",
|
||||
"notePosition": 250,
|
||||
"notePosition": 260,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -575,7 +601,7 @@
|
||||
"o4wAGqOQuJtV"
|
||||
],
|
||||
"title": "v0.90.8",
|
||||
"notePosition": 260,
|
||||
"notePosition": 270,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -608,7 +634,7 @@
|
||||
"i4A5g9iOg9I0"
|
||||
],
|
||||
"title": "v0.90.7-beta",
|
||||
"notePosition": 270,
|
||||
"notePosition": 280,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -626,7 +652,7 @@
|
||||
"ThNf2GaKgXUs"
|
||||
],
|
||||
"title": "v0.90.6-beta",
|
||||
"notePosition": 280,
|
||||
"notePosition": 290,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -644,7 +670,7 @@
|
||||
"G4PAi554kQUr"
|
||||
],
|
||||
"title": "v0.90.5-beta",
|
||||
"notePosition": 290,
|
||||
"notePosition": 300,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -671,7 +697,7 @@
|
||||
"zATRobGRCmBn"
|
||||
],
|
||||
"title": "v0.90.4",
|
||||
"notePosition": 300,
|
||||
"notePosition": 310,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -689,7 +715,7 @@
|
||||
"sCDLf8IKn3Iz"
|
||||
],
|
||||
"title": "v0.90.3",
|
||||
"notePosition": 310,
|
||||
"notePosition": 320,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -707,7 +733,7 @@
|
||||
"VqqyBu4AuTjC"
|
||||
],
|
||||
"title": "v0.90.2-beta",
|
||||
"notePosition": 320,
|
||||
"notePosition": 330,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -725,7 +751,7 @@
|
||||
"RX3Nl7wInLsA"
|
||||
],
|
||||
"title": "v0.90.1-beta",
|
||||
"notePosition": 330,
|
||||
"notePosition": 340,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -743,7 +769,7 @@
|
||||
"GyueACukPWjk"
|
||||
],
|
||||
"title": "v0.90.0-beta",
|
||||
"notePosition": 340,
|
||||
"notePosition": 350,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -761,7 +787,7 @@
|
||||
"wyurrlcDl416"
|
||||
],
|
||||
"title": "Release Template",
|
||||
"notePosition": 350,
|
||||
"notePosition": 360,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
|
||||
61
docs/Release Notes/Release Notes/v0.97.2.md
vendored
Normal file
61
docs/Release Notes/Release Notes/v0.97.2.md
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# v0.97.2
|
||||
> [!NOTE]
|
||||
> Translations are now easily editable online via Weblate. If you wish to contribute to Trilium by translating to your native language, head on over to [our Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you enjoyed this release, consider showing a token of appreciation by:
|
||||
>
|
||||
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Notes) (top-right).
|
||||
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
|
||||
|
||||
## 💡 Key highlights
|
||||
|
||||
* A new collection type has been added: a board in which child notes are grouped in columns.
|
||||
* Geo map now comes with a vector map by default.
|
||||
* The vector styles will provide for a smoother experience, consult the documentation for more information.
|
||||
* Apart from that, there are multiple styles to choose from in the _Collection properties_ section in the ribbon, including dark themes.
|
||||
* Apart from that, added an option to display a scale on the map.
|
||||
* Jump to note was enhanced to allow triggering commands (such as the ones that can have a keyboard shortcut assigned to them) quickly.
|
||||
* Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to test it out.
|
||||
* The feature was renamed to simply “Jump to…” to better accommodate the new role.
|
||||
* For more information, check the user guide for “Jump to…”.
|
||||
|
||||
## 🐞 Bugfixes
|
||||
|
||||
* [Shared note dark mode text doesn't change](https://github.com/TriliumNext/Trilium/issues/6427)
|
||||
* [Markdown export of simple tables results in HTML, not Markdown](https://github.com/TriliumNext/Trilium/issues/6366)
|
||||
* [Cannot edit ReadOnly note in quick edit](https://github.com/TriliumNext/Trilium/issues/6425)
|
||||
* [Documentation for collections is empty](https://github.com/TriliumNext/Trilium/issues/6420)
|
||||
* [Laggy "Mermaid Diagram" note type refresh during editing](https://github.com/TriliumNext/Trilium/issues/6443)
|
||||
* Geomap not reacting to marker icon changes.
|
||||
* [Checkbox Inputs (in Canvas Node > Export image...) have broken styling](https://github.com/TriliumNext/Trilium/issues/6463)
|
||||
* [Rename Book note type to Collection](https://github.com/TriliumNext/Trilium/issues/6471)
|
||||
* [Table view persistence not working in protected notes](https://github.com/TriliumNext/Trilium/issues/6473#issuecomment-3120029185)
|
||||
* [Table caption print issue](https://github.com/TriliumNext/Trilium/issues/6483)
|
||||
* [Commonmark import sub and sup tags not working](https://github.com/TriliumNext/Trilium/issues/4307)
|
||||
* [Migration failing if there is a protected geomap](https://github.com/TriliumNext/Trilium/issues/6489)
|
||||
* Window refreshing when sorting notes via the dialog.
|
||||
* [Odd behavior in Safari on macOS (random refreshing+new note creation+user guide opens)](https://github.com/TriliumNext/Trilium/issues/6218)
|
||||
* Note tooltip showing up in note list
|
||||
* Copy to clipboard button also opening into note list
|
||||
|
||||
## ✨ Improvements
|
||||
|
||||
* [Show inline mermaid diagram in share view instead of mermaid diagram code](https://github.com/TriliumNext/Trilium/issues/5438)
|
||||
* Canvas improvements:
|
||||
* Add grid to canvas by @Papierkorb2292
|
||||
* Improve style of toolbars and dropdowns on the Next theme.
|
||||
* New type for promoted attributes: color.
|
||||
* Web view note preview (in note list):
|
||||
* The `#webViewSrc` is now hidden
|
||||
* Dedicated button to open the link externally.
|
||||
* [User Guide pages should be searchable](https://github.com/TriliumNext/Trilium/issues/6515)
|
||||
|
||||
## 🌍 Internationalization
|
||||
|
||||
* 100% translation coverage for Romanian.
|
||||
* Spanish language improvements by @Aitanuqui
|
||||
|
||||
## 🛠️ Technical updates
|
||||
|
||||
* The shortcut keys management was completely rewritten as it was based on an older library. **Please raise any issues you might have with your keyboard shortcuts.**
|
||||
BIN
eng.traineddata
BIN
eng.traineddata
Binary file not shown.
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/source",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
@@ -27,16 +27,16 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "4.0.1",
|
||||
"@nx/devkit": "21.3.9",
|
||||
"@nx/esbuild": "21.3.9",
|
||||
"@nx/eslint": "21.3.9",
|
||||
"@nx/eslint-plugin": "21.3.9",
|
||||
"@nx/express": "21.3.9",
|
||||
"@nx/js": "21.3.9",
|
||||
"@nx/node": "21.3.9",
|
||||
"@nx/playwright": "21.3.9",
|
||||
"@nx/vite": "21.3.9",
|
||||
"@nx/web": "21.3.9",
|
||||
"@nx/devkit": "21.3.11",
|
||||
"@nx/esbuild": "21.3.11",
|
||||
"@nx/eslint": "21.3.11",
|
||||
"@nx/eslint-plugin": "21.3.11",
|
||||
"@nx/express": "21.3.11",
|
||||
"@nx/js": "21.3.11",
|
||||
"@nx/node": "21.3.11",
|
||||
"@nx/playwright": "21.3.11",
|
||||
"@nx/vite": "21.3.11",
|
||||
"@nx/web": "21.3.11",
|
||||
"@playwright/test": "^1.36.0",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "^5.0.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"jiti": "2.5.1",
|
||||
"jsdom": "~26.1.0",
|
||||
"jsonc-eslint-parser": "^2.1.0",
|
||||
"nx": "21.3.9",
|
||||
"nx": "21.3.11",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rollup-plugin-webpack-stats": "2.1.1",
|
||||
"tslib": "^2.3.0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"url": "https://github.com/TriliumNext/Notes/issues"
|
||||
},
|
||||
"homepage": "https://github.com/TriliumNext/Notes#readme",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.3.3",
|
||||
"@codemirror/lang-markdown": "6.3.4",
|
||||
"@codemirror/lang-php": "6.0.2",
|
||||
"@codemirror/lang-vue": "0.1.3",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/commons",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -146,12 +146,6 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
|
||||
codeOpenAiModel: string;
|
||||
aiSelectedProvider: string;
|
||||
|
||||
// OCR options
|
||||
ocrEnabled: boolean;
|
||||
ocrLanguage: string;
|
||||
ocrAutoProcessImages: boolean;
|
||||
ocrMinConfidence: string;
|
||||
|
||||
}
|
||||
|
||||
export type OptionNames = keyof OptionDefinitions;
|
||||
|
||||
@@ -70,7 +70,6 @@ export interface BlobRow {
|
||||
blobId: string;
|
||||
content: string | Buffer;
|
||||
contentLength: number;
|
||||
ocr_text?: string | null;
|
||||
dateModified: string;
|
||||
utcDateModified: string;
|
||||
}
|
||||
|
||||
1172
pnpm-lock.yaml
generated
1172
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
ron.traineddata
BIN
ron.traineddata
Binary file not shown.
Reference in New Issue
Block a user