Compare commits
132 Commits
renovate/c
...
feat/add-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9cef158d8 | ||
|
|
1dfe27d3df | ||
|
|
cda8fc7146 | ||
|
|
acb16f751b | ||
|
|
a1ac276be5 | ||
|
|
54e3ab5139 | ||
|
|
baf341b312 | ||
|
|
5b074c2e22 | ||
|
|
11d086ef12 | ||
|
|
0e6b10e400 | ||
|
|
0240222998 | ||
|
|
7fc739487f | ||
|
|
f6e275709f | ||
|
|
7e01dfd220 | ||
|
|
27c7888628 | ||
|
|
b4de37a9f4 | ||
|
|
1c5ebb54f8 | ||
|
|
f3e69dd6bd | ||
|
|
66364f5ce0 | ||
|
|
f25a1fb865 | ||
|
|
62c5b8b1fc | ||
|
|
2b0de37fc0 | ||
|
|
23ef73fe2f | ||
|
|
92ac3ee4ef | ||
|
|
a3ba5ca109 | ||
|
|
5b4e81cf18 | ||
|
|
772e6f5ebc | ||
|
|
60a9428b8b | ||
|
|
a7752a8421 | ||
|
|
aefa2315b7 | ||
|
|
37a79aeeab | ||
|
|
5bc4bdaeef | ||
|
|
5e28df883d | ||
|
|
0a57748075 | ||
|
|
45e3eee642 | ||
|
|
d724a80c2a | ||
|
|
5ea8c94d18 | ||
|
|
769bc760b3 | ||
|
|
f04f45ea62 | ||
|
|
a5cab6a2a2 | ||
|
|
138611beaf | ||
|
|
e1b608057a | ||
|
|
fed6d8329f | ||
|
|
9d03d52f28 | ||
|
|
055e11174d | ||
|
|
8fda2dd7f1 | ||
|
|
ea03695c75 | ||
|
|
17b206fc72 | ||
|
|
4ec8c5963a | ||
|
|
ab2d8accf5 | ||
|
|
de8b7e9ebe | ||
|
|
18d11523a6 | ||
|
|
7a0ab3c025 | ||
|
|
3575a7dc93 | ||
|
|
bb9e7b1c6e | ||
|
|
115e9e0202 | ||
|
|
e341de70c0 | ||
|
|
1d1a0ac4fd | ||
|
|
d48470ffb1 | ||
|
|
6574ca42a3 | ||
|
|
303ff35a76 | ||
|
|
e0850958b0 | ||
|
|
13115b9ed1 | ||
|
|
933a11e9db | ||
|
|
6915993a35 | ||
|
|
237a4e9a74 | ||
|
|
1565a0fd80 | ||
|
|
e8b16287e0 | ||
|
|
c09e124805 | ||
|
|
b6f55b0e1a | ||
|
|
964bc74b83 | ||
|
|
fa9b142cb7 | ||
|
|
7e3f412c84 | ||
|
|
82e16a5624 | ||
|
|
757488a95b | ||
|
|
d7f154cfd1 | ||
|
|
3517715aab | ||
|
|
d10bbdd7a7 | ||
|
|
c4ec27bb1e | ||
|
|
0b24553ace | ||
|
|
793867269b | ||
|
|
9508e92676 | ||
|
|
89378eae7b | ||
|
|
ace166a925 | ||
|
|
d59d544c0f | ||
|
|
37461d0eb3 | ||
|
|
126152ff63 | ||
|
|
60e19de0d1 | ||
|
|
3247a9facc | ||
|
|
7b114bed26 | ||
|
|
30ffbc760e | ||
|
|
4420913049 | ||
|
|
3762690c5f | ||
|
|
d684ac40d8 | ||
|
|
d217379644 | ||
|
|
d5f7fa2fe5 | ||
|
|
3e0ef10b25 | ||
|
|
5ec6141369 | ||
|
|
55ac1e01f2 | ||
|
|
65b58c3668 | ||
|
|
2cb4e5e8dc | ||
|
|
72cea245f1 | ||
|
|
08ca86c68a | ||
|
|
925c9c1e7b | ||
|
|
6212ea0304 | ||
|
|
f295592134 | ||
|
|
69b0973e6d | ||
|
|
422d318dac | ||
|
|
c55aa6ee88 | ||
|
|
090b175152 | ||
|
|
11e9b097a2 | ||
|
|
2adfc1d32b | ||
|
|
99fa5d89e7 | ||
|
|
ca8cbf8ccf | ||
|
|
6722d2d266 | ||
|
|
508cbeaa1b | ||
|
|
e040865905 | ||
|
|
a7878dd2c6 | ||
|
|
02980834ad | ||
|
|
2a8c8871c4 | ||
|
|
893be24c1d | ||
|
|
9029f59410 | ||
|
|
4b5e8d33a6 | ||
|
|
09196c045f | ||
|
|
7868ebec1e | ||
|
|
80a9182f05 | ||
|
|
d20b3d854f | ||
|
|
f1356228a3 | ||
|
|
a4adc51e50 | ||
|
|
864543e4f9 | ||
|
|
33a549202b | ||
|
|
c4a0219b18 |
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.5 and pnpm as the package manager.
|
||||
You are in an nx workspace using Nx 21.3.7 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
@@ -35,7 +35,6 @@ 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
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@playwright/test": "1.54.1",
|
||||
"@stylistic/eslint-plugin": "5.2.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/node": "22.17.0",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.32.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.7",
|
||||
"typedoc": "0.28.8",
|
||||
"typedoc-plugin-missing-exports": "4.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@fullcalendar/list": "6.1.18",
|
||||
"@fullcalendar/multimonth": "6.1.18",
|
||||
"@fullcalendar/timegrid": "6.1.18",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.2",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
@@ -52,7 +52,7 @@
|
||||
"mind-elixir": "5.0.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.26.9",
|
||||
"preact": "10.27.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -133,6 +133,8 @@ export type CommandMappings = {
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -173,7 +175,7 @@ export type CommandMappings = {
|
||||
deleteNotes: ContextMenuCommandData;
|
||||
importIntoNote: ContextMenuCommandData;
|
||||
exportNote: ContextMenuCommandData;
|
||||
searchInSubtree: ContextMenuCommandData;
|
||||
searchInSubtree: CommandData & { notePath: string; };
|
||||
moveNoteUp: ContextMenuCommandData;
|
||||
moveNoteDown: ContextMenuCommandData;
|
||||
moveNoteUpInHierarchy: ContextMenuCommandData;
|
||||
@@ -262,6 +264,7 @@ export type CommandMappings = {
|
||||
closeThisNoteSplit: CommandData;
|
||||
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
|
||||
jumpToNote: CommandData;
|
||||
commandPalette: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
|
||||
@@ -113,7 +113,9 @@ export default class Entrypoints extends Component {
|
||||
if (win.isFullScreenable()) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
}
|
||||
} // outside of electron this is handled by the browser
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
reloadFrontendAppCommand() {
|
||||
|
||||
@@ -146,6 +146,19 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async showNoteOCRTextCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "ocr"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentsCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null;
|
||||
|
||||
// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator,
|
||||
// so they need to be added manually.
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog" | "searchInSubtree";
|
||||
|
||||
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
|
||||
private treeWidget: NoteTreeWidget;
|
||||
@@ -129,7 +129,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
|
||||
@@ -79,7 +79,19 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b
|
||||
return $container;
|
||||
}
|
||||
|
||||
const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"];
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
"originalFileName",
|
||||
"fileSize",
|
||||
"template",
|
||||
"inherit",
|
||||
"cssClass",
|
||||
"iconClass",
|
||||
"pageSize",
|
||||
"viewType",
|
||||
"geolocation",
|
||||
"docName",
|
||||
"webViewSrc"
|
||||
];
|
||||
|
||||
async function renderNormalAttributes(note: FNote) {
|
||||
const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();
|
||||
|
||||
295
apps/client/src/services/command_registry.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import { t, translationsInitializedPromise } from "./i18n.js";
|
||||
import keyboardActions from "./keyboard_actions.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
export interface CommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
commandName?: CommandNames;
|
||||
handler?: () => Promise<unknown> | null | undefined | void;
|
||||
aliases?: string[];
|
||||
source?: "manual" | "keyboard-action";
|
||||
/** Reference to the original keyboard action for scope checking. */
|
||||
keyboardAction?: ActionKeyboardShortcut;
|
||||
}
|
||||
|
||||
class CommandRegistry {
|
||||
private commands: Map<string, CommandDefinition> = new Map();
|
||||
private aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.loadCommands();
|
||||
}
|
||||
|
||||
private async loadCommands() {
|
||||
await translationsInitializedPromise;
|
||||
this.registerDefaultCommands();
|
||||
await this.loadKeyboardActionsAsync();
|
||||
}
|
||||
|
||||
private registerDefaultCommands() {
|
||||
this.register({
|
||||
id: "export-note",
|
||||
name: t("command_palette.export_note_title"),
|
||||
description: t("command_palette.export_note_description"),
|
||||
icon: "bx bx-export",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("showExportDialog", {
|
||||
notePath,
|
||||
defaultType: "single"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-attachments",
|
||||
name: t("command_palette.show_attachments_title"),
|
||||
description: t("command_palette.show_attachments_description"),
|
||||
icon: "bx bx-paperclip",
|
||||
handler: () => appContext.triggerCommand("showAttachments")
|
||||
});
|
||||
|
||||
// Special search commands with custom logic
|
||||
this.register({
|
||||
id: "search-notes",
|
||||
name: t("command_palette.search_notes_title"),
|
||||
description: t("command_palette.search_notes_description"),
|
||||
icon: "bx bx-search",
|
||||
handler: () => appContext.triggerCommand("searchNotes", {})
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "search-in-subtree",
|
||||
name: t("command_palette.search_subtree_title"),
|
||||
description: t("command_palette.search_subtree_description"),
|
||||
icon: "bx bx-search-alt",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("searchInSubtree", { notePath });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-search-history",
|
||||
name: t("command_palette.search_history_title"),
|
||||
description: t("command_palette.search_history_description"),
|
||||
icon: "bx bx-history",
|
||||
handler: () => appContext.triggerCommand("showSearchHistory")
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-launch-bar",
|
||||
name: t("command_palette.configure_launch_bar_title"),
|
||||
description: t("command_palette.configure_launch_bar_description"),
|
||||
icon: "bx bx-sidebar",
|
||||
handler: () => appContext.triggerCommand("showLaunchBarSubtree")
|
||||
});
|
||||
}
|
||||
|
||||
private async loadKeyboardActionsAsync() {
|
||||
try {
|
||||
const actions = await keyboardActions.getActions();
|
||||
this.registerKeyboardActions(actions);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keyboard actions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private registerKeyboardActions(actions: ActionKeyboardShortcut[]) {
|
||||
for (const action of actions) {
|
||||
// Skip actions that we've already manually registered
|
||||
if (this.commands.has(action.actionName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that don't have a description (likely separators)
|
||||
if (!action.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Electron-only actions if not in Electron environment
|
||||
if (action.isElectronOnly && !utils.isElectron()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that should not appear in the command palette
|
||||
if (action.ignoreFromCommandPalette) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the primary shortcut (first one in the list)
|
||||
const primaryShortcut = action.effectiveShortcuts?.[0];
|
||||
|
||||
let name = action.friendlyName;
|
||||
if (action.scope === "note-tree") {
|
||||
name = t("command_palette.tree-action-name", { name: action.friendlyName });
|
||||
}
|
||||
|
||||
// Create a command definition from the keyboard action
|
||||
const commandDef: CommandDefinition = {
|
||||
id: action.actionName,
|
||||
name,
|
||||
description: action.description,
|
||||
icon: action.iconClass,
|
||||
shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined,
|
||||
commandName: action.actionName as CommandNames,
|
||||
source: "keyboard-action",
|
||||
keyboardAction: action
|
||||
};
|
||||
|
||||
this.register(commandDef);
|
||||
}
|
||||
}
|
||||
|
||||
private formatShortcut(shortcut: string): string {
|
||||
// Convert electron accelerator format to display format
|
||||
return shortcut
|
||||
.replace(/CommandOrControl/g, 'Ctrl')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
register(command: CommandDefinition) {
|
||||
this.commands.set(command.id, command);
|
||||
|
||||
// Register aliases
|
||||
if (command.aliases) {
|
||||
for (const alias of command.aliases) {
|
||||
this.aliases.set(alias.toLowerCase(), command.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCommand(id: string): CommandDefinition | undefined {
|
||||
return this.commands.get(id);
|
||||
}
|
||||
|
||||
getAllCommands(): CommandDefinition[] {
|
||||
const commands = Array.from(this.commands.values());
|
||||
|
||||
// Sort commands by name
|
||||
commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
searchCommands(query: string): CommandDefinition[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const results: { command: CommandDefinition; score: number }[] = [];
|
||||
|
||||
for (const command of this.commands.values()) {
|
||||
let score = 0;
|
||||
|
||||
// Exact match on name
|
||||
if (command.name.toLowerCase() === normalizedQuery) {
|
||||
score = 100;
|
||||
}
|
||||
// Name starts with query
|
||||
else if (command.name.toLowerCase().startsWith(normalizedQuery)) {
|
||||
score = 80;
|
||||
}
|
||||
// Name contains query
|
||||
else if (command.name.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 60;
|
||||
}
|
||||
// Description contains query
|
||||
else if (command.description?.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 40;
|
||||
}
|
||||
// Check aliases
|
||||
else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) {
|
||||
score = 50;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ command, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (highest first) and then by name
|
||||
results.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.command.name.localeCompare(b.command.name);
|
||||
});
|
||||
|
||||
return results.map(r => r.command);
|
||||
}
|
||||
|
||||
async executeCommand(commandId: string) {
|
||||
const command = this.getCommand(commandId);
|
||||
if (!command) {
|
||||
console.error(`Command not found: ${commandId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute custom handler if provided
|
||||
if (command.handler) {
|
||||
await command.handler();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keyboard action with scope-aware execution
|
||||
if (command.keyboardAction && command.commandName) {
|
||||
if (command.keyboardAction.scope === "note-tree") {
|
||||
this.executeWithNoteTreeFocus(command.commandName);
|
||||
} else if (command.keyboardAction.scope === "text-detail") {
|
||||
this.executeWithTextDetail(command.commandName);
|
||||
} else {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for commands without keyboard action reference
|
||||
if (command.commandName) {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Command ${commandId} has no handler or commandName`);
|
||||
}
|
||||
|
||||
private executeWithNoteTreeFocus(actionName: CommandNames) {
|
||||
const tree = document.querySelector(".tree-wrapper") as HTMLElement;
|
||||
if (!tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget;
|
||||
const activeNode = treeComponent.getActiveNode();
|
||||
treeComponent.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId,
|
||||
node: activeNode
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithTextDetail(actionName: CommandNames) {
|
||||
const typeWidget = await appContext.tabManager.getActiveContext()?.getTypeWidget();
|
||||
if (!typeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
typeWidget.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
export default commandRegistry;
|
||||
@@ -23,6 +23,7 @@ interface Options {
|
||||
tooltip?: boolean;
|
||||
trim?: boolean;
|
||||
imageHasZoom?: boolean;
|
||||
showOcrText?: boolean;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
@@ -46,9 +47,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)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
await renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
renderFile(entity, type, $renderedContent);
|
||||
await renderFile(entity, type, $renderedContent, options);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
@@ -65,6 +66,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
|
||||
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
|
||||
} else if (entity instanceof FNote) {
|
||||
$renderedContent
|
||||
.css("display", "flex")
|
||||
.css("flex-direction", "column");
|
||||
$renderedContent.append(
|
||||
$("<div>")
|
||||
.css("display", "flex")
|
||||
@@ -72,8 +76,33 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
.css("align-items", "center")
|
||||
.css("height", "100%")
|
||||
.css("font-size", "500%")
|
||||
.css("flex-grow", "1")
|
||||
.append($("<span>").addClass(entity.getIcon()))
|
||||
);
|
||||
|
||||
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
|
||||
const $footer = $("<footer>")
|
||||
.addClass("webview-footer");
|
||||
const $openButton = $(`
|
||||
<button class="file-open btn btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
${t("content_renderer.open_externally")}
|
||||
</button>
|
||||
`)
|
||||
.appendTo($footer)
|
||||
.on("click", () => {
|
||||
const webViewSrc = entity.getLabelValue("webViewSrc");
|
||||
if (webViewSrc) {
|
||||
if (utils.isElectron()) {
|
||||
const electron = utils.dynamicRequire("electron");
|
||||
electron.shell.openExternal(webViewSrc);
|
||||
} else {
|
||||
window.open(webViewSrc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
});
|
||||
$footer.appendTo($renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -133,7 +162,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
const encodedTitle = encodeURIComponent(entity.title);
|
||||
|
||||
let url;
|
||||
@@ -173,9 +202,39 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
}
|
||||
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
|
||||
// Add OCR text display for image notes
|
||||
if (entity instanceof FNote && options.showOcrText) {
|
||||
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
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 = {}) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -211,6 +270,11 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
|
||||
$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
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { Locale } from "@triliumnext/commons";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = (options.get("locale") as string) || "en";
|
||||
|
||||
@@ -19,6 +24,8 @@ export async function initLocale() {
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
translationsInitializedPromise.resolve();
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
|
||||
@@ -2,21 +2,15 @@ import server from "./server.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
const keyboardActionRepo: Record<string, Action> = {};
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
export interface Action {
|
||||
actionName: CommandNames;
|
||||
effectiveShortcuts: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => {
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
actions = actions.filter((a) => !!a.actionName); // filter out separators
|
||||
|
||||
for (const action of actions) {
|
||||
action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
action.effectiveShortcuts = (action.effectiveShortcuts ?? []).filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
|
||||
keyboardActionRepo[action.actionName] = action;
|
||||
}
|
||||
@@ -38,7 +32,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
const actions = await getActionsForScope(scope);
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +40,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +74,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
const action = await getAction(actionName, true);
|
||||
|
||||
if (action) {
|
||||
const keyboardActions = action.effectiveShortcuts.join(", ");
|
||||
const keyboardActions = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (keyboardActions || $(el).text() !== "not set") {
|
||||
$(el).text(keyboardActions);
|
||||
@@ -99,7 +93,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
|
||||
if (action) {
|
||||
const title = $(el).attr("title");
|
||||
const shortcuts = action.effectiveShortcuts.join(", ");
|
||||
const shortcuts = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (title?.includes(shortcuts)) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import appContext from "../components/app_context.js";
|
||||
import noteCreateService from "./note_create.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import commandRegistry from "./command_registry.js";
|
||||
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
|
||||
|
||||
// this key needs to have this value, so it's hit by the tooltip
|
||||
@@ -29,9 +30,12 @@ export interface Suggestion {
|
||||
notePathTitle?: string;
|
||||
notePath?: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
action?: string | "create-note" | "search-notes" | "external-link";
|
||||
action?: string | "create-note" | "search-notes" | "external-link" | "command";
|
||||
parentNoteId?: string;
|
||||
icon?: string;
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
@@ -44,6 +48,8 @@ interface Options {
|
||||
hideGoToSelectedNoteButton?: boolean;
|
||||
/** If set, hides all right-side buttons in the autocomplete dropdown */
|
||||
hideAllButtons?: boolean;
|
||||
/** If set, enables command palette mode */
|
||||
isCommandPalette?: boolean;
|
||||
}
|
||||
|
||||
async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
@@ -73,6 +79,31 @@ async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
}
|
||||
|
||||
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) {
|
||||
// Check if we're in command mode
|
||||
if (options.isCommandPalette && term.startsWith(">")) {
|
||||
const commandQuery = term.substring(1).trim();
|
||||
|
||||
// Get commands (all if no query, filtered if query provided)
|
||||
const commands = commandQuery.length === 0
|
||||
? commandRegistry.getAllCommands()
|
||||
: commandRegistry.searchCommands(commandQuery);
|
||||
|
||||
// Convert commands to suggestions
|
||||
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
|
||||
action: "command",
|
||||
commandId: cmd.id,
|
||||
noteTitle: cmd.name,
|
||||
notePathTitle: `>${cmd.name}`,
|
||||
highlightedNotePathTitle: cmd.name,
|
||||
commandDescription: cmd.description,
|
||||
commandShortcut: cmd.shortcut,
|
||||
icon: cmd.icon
|
||||
}));
|
||||
|
||||
cb(commandSuggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
const fastSearch = options.fastSearch === false ? false : true;
|
||||
if (fastSearch === false) {
|
||||
if (term.trim().length === 0) {
|
||||
@@ -146,6 +177,12 @@ function showRecentNotes($el: JQuery<HTMLElement>) {
|
||||
$el.trigger("focus");
|
||||
}
|
||||
|
||||
function showAllCommands($el: JQuery<HTMLElement>) {
|
||||
searchDelay = 0;
|
||||
$el.setSelectedNotePath("");
|
||||
$el.autocomplete("val", ">").autocomplete("open");
|
||||
}
|
||||
|
||||
function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
|
||||
const searchString = $el.autocomplete("val") as unknown as string;
|
||||
if (options.fastSearch === false || searchString?.trim().length === 0) {
|
||||
@@ -270,7 +307,24 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
},
|
||||
displayKey: "notePathTitle",
|
||||
templates: {
|
||||
suggestion: (suggestion) => `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`
|
||||
suggestion: (suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
let html = `<div class="command-suggestion">`;
|
||||
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
|
||||
html += `<div class="command-content">`;
|
||||
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
|
||||
if (suggestion.commandDescription) {
|
||||
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (suggestion.commandShortcut) {
|
||||
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
cache: false
|
||||
@@ -280,6 +334,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
|
||||
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
|
||||
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
$el.autocomplete("close");
|
||||
$el.trigger("autocomplete:commandselected", [suggestion]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestion.action === "external-link") {
|
||||
$el.setSelectedNotePath(null);
|
||||
$el.setSelectedExternalLink(suggestion.externalLink);
|
||||
@@ -396,6 +456,7 @@ export default {
|
||||
autocompleteSourceForCKEditor,
|
||||
initNoteAutocomplete,
|
||||
showRecentNotes,
|
||||
showAllCommands,
|
||||
setText,
|
||||
init
|
||||
};
|
||||
|
||||
@@ -320,3 +320,8 @@ h6 {
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
figure.table {
|
||||
/* Workaround for https://github.com/ckeditor/ckeditor5/issues/18903. Remove once official fix is released */
|
||||
display: table !important;
|
||||
}
|
||||
@@ -1780,6 +1780,54 @@ textarea {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
.jump-to-note-dialog .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .aa-cursor .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion:hover .command-suggestion {
|
||||
border-left-color: var(--link-color);
|
||||
background-color: var(--hover-background-color);
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-icon {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog kbd.command-shortcut {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.empty-table-placeholder {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
@@ -1889,12 +1937,14 @@ body.zen .note-title-widget input {
|
||||
|
||||
/* Content renderer */
|
||||
|
||||
footer.file-footer {
|
||||
footer.file-footer,
|
||||
footer.webview-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer.file-footer button {
|
||||
footer.file-footer button,
|
||||
footer.webview-footer button {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
@@ -2201,3 +2251,26 @@ footer.file-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;
|
||||
}
|
||||
@@ -458,6 +458,11 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,15 @@ div.tn-tool-dialog {
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
padding: unset !important;
|
||||
padding-bottom: 26px !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 26px 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-footer {
|
||||
padding-top: 26px;
|
||||
}
|
||||
|
||||
/* Search box wrapper */
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
"okButton": "确定"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "按笔记名称搜索",
|
||||
"search_placeholder": "",
|
||||
"close": "关闭",
|
||||
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>"
|
||||
},
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Suche nach einer Notiz anhand ihres Namens",
|
||||
"search_placeholder": "",
|
||||
"close": "Schließen",
|
||||
"search_button": "Suche im Volltext: <kbd>Strg+Eingabetaste</kbd>"
|
||||
},
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "search for note by its name",
|
||||
"search_placeholder": "Search for note by its name or type > for commands...",
|
||||
"close": "Close",
|
||||
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
@@ -674,6 +674,7 @@
|
||||
"search_in_note": "Search in note",
|
||||
"note_source": "Note source",
|
||||
"note_attachments": "Note attachments",
|
||||
"view_ocr_text": "View OCR text",
|
||||
"open_note_externally": "Open note externally",
|
||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||
"open_note_custom": "Open note custom",
|
||||
@@ -1303,7 +1304,22 @@
|
||||
"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)"
|
||||
"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}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Attachment Erasure Timeout",
|
||||
@@ -1987,5 +2003,37 @@
|
||||
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "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",
|
||||
"export_note_description": "Export current note",
|
||||
"show_attachments_title": "Show Attachments",
|
||||
"show_attachments_description": "View note attachments",
|
||||
"search_notes_title": "Search Notes",
|
||||
"search_notes_description": "Open advanced search",
|
||||
"search_subtree_title": "Search in Subtree",
|
||||
"search_subtree_description": "Search within current subtree",
|
||||
"search_history_title": "Show Search History",
|
||||
"search_history_description": "View previous searches",
|
||||
"configure_launch_bar_title": "Configure Launch Bar",
|
||||
"configure_launch_bar_description": "Open the launch bar configuration, to add or remove items."
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Open externally"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
"okButton": "Aceptar"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "buscar nota por su nombre",
|
||||
"search_placeholder": "",
|
||||
"close": "Cerrar",
|
||||
"search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "rechercher une note par son nom",
|
||||
"search_placeholder": "",
|
||||
"close": "Fermer",
|
||||
"search_button": "Rechercher dans le texte intégral <kbd>Ctrl+Entrée</kbd>"
|
||||
},
|
||||
|
||||
@@ -755,7 +755,7 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Caută în întregul conținut <kbd>Ctrl+Enter</kbd>",
|
||||
"search_placeholder": "căutați o notiță după denumirea ei",
|
||||
"search_placeholder": "",
|
||||
"close": "Închide"
|
||||
},
|
||||
"left_pane_toggle": {
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
"okButton": "確定"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "按筆記名稱搜尋",
|
||||
"search_placeholder": "",
|
||||
"search_button": "全文搜尋 <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
|
||||
@@ -90,6 +90,10 @@ 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>
|
||||
|
||||
@@ -117,6 +121,7 @@ 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>;
|
||||
@@ -143,6 +148,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
|
||||
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
|
||||
this.$showSourceButton = this.$widget.find(".show-source-button");
|
||||
this.$showOCRTextButton = this.$widget.find(".show-ocr-text-button");
|
||||
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
|
||||
this.$renderNoteButton = this.$widget.find(".render-note-button");
|
||||
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
|
||||
@@ -190,6 +196,9 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
|
||||
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
|
||||
|
||||
// Show OCR text button for notes that could have OCR data (images and files)
|
||||
this.toggleDisabled(this.$showOCRTextButton, ["image", "file"].includes(note.type));
|
||||
|
||||
const canPrint = ["text", "code"].includes(note.type);
|
||||
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
|
||||
|
||||
@@ -6,6 +6,7 @@ import BasicWidget from "../basic_widget.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import commandRegistry from "../../services/command_registry.js";
|
||||
|
||||
const TPL = /*html*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
@@ -34,7 +35,8 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
private modal!: bootstrap.Modal;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $results!: JQuery<HTMLElement>;
|
||||
private $showInFullTextButton!: JQuery<HTMLElement>;
|
||||
private $modalFooter!: JQuery<HTMLElement>;
|
||||
private isCommandMode: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -48,13 +50,44 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
|
||||
this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete");
|
||||
this.$results = this.$widget.find(".jump-to-note-results");
|
||||
this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
|
||||
this.$showInFullTextButton.on("click", (e) => this.showInFullText(e));
|
||||
this.$modalFooter = this.$widget.find(".modal-footer");
|
||||
this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e));
|
||||
|
||||
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
|
||||
|
||||
// Monitor input changes to detect command mode switches
|
||||
this.$autoComplete.on("input", () => {
|
||||
this.updateCommandModeState();
|
||||
});
|
||||
}
|
||||
|
||||
private updateCommandModeState() {
|
||||
const currentValue = String(this.$autoComplete.val() || "");
|
||||
const newCommandMode = currentValue.startsWith(">");
|
||||
|
||||
if (newCommandMode !== this.isCommandMode) {
|
||||
this.isCommandMode = newCommandMode;
|
||||
this.updateButtonVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private updateButtonVisibility() {
|
||||
if (this.isCommandMode) {
|
||||
this.$modalFooter.hide();
|
||||
} else {
|
||||
this.$modalFooter.show();
|
||||
}
|
||||
}
|
||||
|
||||
async jumpToNoteEvent() {
|
||||
await this.openDialog();
|
||||
}
|
||||
|
||||
async commandPaletteEvent() {
|
||||
await this.openDialog(true);
|
||||
}
|
||||
|
||||
private async openDialog(commandMode = false) {
|
||||
const dialogPromise = openDialog(this.$widget);
|
||||
if (utils.isMobile()) {
|
||||
dialogPromise.then(($dialog) => {
|
||||
@@ -81,42 +114,76 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
}
|
||||
|
||||
// first open dialog, then refresh since refresh is doing focus which should be visible
|
||||
this.refresh();
|
||||
this.refresh(commandMode);
|
||||
|
||||
this.lastOpenedTs = Date.now();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
async refresh(commandMode = false) {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
container: this.$results[0]
|
||||
container: this.$results[0],
|
||||
isCommandPalette: true
|
||||
})
|
||||
// clear any event listener added in previous invocation of this function
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
|
||||
if (!suggestion.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
})
|
||||
.on("autocomplete:commandselected", async (event, suggestion, dataset) => {
|
||||
if (!suggestion.commandId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.modal.hide();
|
||||
await commandRegistry.executeCommand(suggestion.commandId);
|
||||
});
|
||||
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
if (commandMode) {
|
||||
// Start in command mode - manually trigger command search
|
||||
this.$autoComplete.autocomplete("val", ">");
|
||||
this.isCommandMode = true;
|
||||
this.updateButtonVisibility();
|
||||
|
||||
// Manually populate with all commands immediately
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
|
||||
this.$autoComplete.trigger("focus");
|
||||
} else {
|
||||
this.$autoComplete
|
||||
// hack, the actual search value is stored in <pre> element next to the search input
|
||||
// this is important because the search input value is replaced with the suggestion note's title
|
||||
.autocomplete("val", this.$autoComplete.next().text())
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
|
||||
this.isCommandMode = false;
|
||||
this.updateButtonVisibility();
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
} else {
|
||||
this.$autoComplete
|
||||
// hack, the actual search value is stored in <pre> element next to the search input
|
||||
// this is important because the search input value is replaced with the suggestion note's title
|
||||
.autocomplete("val", this.$autoComplete.next().text())
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
|
||||
// Update command mode state based on the restored value
|
||||
this.updateCommandModeState();
|
||||
|
||||
// If we restored a command mode value, manually trigger command display
|
||||
if (this.isCommandMode) {
|
||||
// Clear the value first, then set it to ">" to trigger a proper change
|
||||
this.$autoComplete.autocomplete("val", "");
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +192,11 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't perform full text search in command mode
|
||||
if (this.isCommandMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchString = String(this.$autoComplete.val());
|
||||
|
||||
this.triggerCommand("searchNotes", { searchString });
|
||||
|
||||
@@ -88,7 +88,9 @@ export default class SortChildNotesDialog extends BasicWidget {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".sort-child-notes-form");
|
||||
|
||||
this.$form.on("submit", async () => {
|
||||
this.$form.on("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const sortBy = this.$form.find("input[name='sort-by']:checked").val();
|
||||
const sortDirection = this.$form.find("input[name='sort-direction']:checked").val();
|
||||
const foldersFirst = this.$form.find("input[name='sort-folders-first']").is(":checked");
|
||||
|
||||
@@ -28,6 +28,7 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
||||
import ReadOnlyOCRTextWidget from "./type_widgets/read_only_ocr_text.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type TypeWidget from "./type_widgets/type_widget.js";
|
||||
@@ -55,6 +56,7 @@ const typeWidgetClasses = {
|
||||
readOnlyText: ReadOnlyTextTypeWidget,
|
||||
editableCode: EditableCodeTypeWidget,
|
||||
readOnlyCode: ReadOnlyCodeTypeWidget,
|
||||
readOnlyOCRText: ReadOnlyOCRTextWidget,
|
||||
file: FileTypeWidget,
|
||||
image: ImageTypeWidget,
|
||||
search: NoneTypeWidget,
|
||||
@@ -85,6 +87,7 @@ type ExtendedNoteType =
|
||||
| "empty"
|
||||
| "readOnlyCode"
|
||||
| "readOnlyText"
|
||||
| "readOnlyOCRText"
|
||||
| "editableText"
|
||||
| "editableCode"
|
||||
| "attachmentDetail"
|
||||
@@ -223,6 +226,8 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
if (viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (viewScope?.viewMode === "ocr") {
|
||||
resultingType = "readOnlyOCRText";
|
||||
} else if (viewScope && viewScope.viewMode === "attachments") {
|
||||
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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">
|
||||
@@ -9,6 +11,43 @@ 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>
|
||||
@@ -44,6 +83,123 @@ 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>
|
||||
`;
|
||||
|
||||
@@ -55,9 +211,22 @@ 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");
|
||||
|
||||
@@ -76,16 +245,49 @@ 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() {
|
||||
@@ -95,4 +297,134 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import utils from "../../../services/utils.js";
|
||||
import dialogService from "../../../services/dialog.js";
|
||||
import OptionsWidget from "./options_widget.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import type { OptionNames, KeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { OptionNames, KeyboardShortcut, KeyboardShortcutWithRequiredActionName } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section shortcuts-options-section tn-no-card">
|
||||
@@ -75,10 +75,10 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
for (const action of actions) {
|
||||
const $tr = $("<tr>");
|
||||
|
||||
if (action.separator) {
|
||||
if ("separator" in action) {
|
||||
$tr.append($('<td class="separator" colspan="4">').attr("style", "background-color: var(--accented-background-color); font-weight: bold;").text(action.separator));
|
||||
} else if (action.defaultShortcuts && action.actionName) {
|
||||
$tr.append($("<td>").text(action.actionName))
|
||||
$tr.append($("<td>").text(action.friendlyName))
|
||||
.append(
|
||||
$("<td>").append(
|
||||
$(`<input type="text" class="form-control">`)
|
||||
@@ -145,9 +145,9 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = globActions.find((act) => act.actionName === actionName);
|
||||
const action = globActions.find((act) => "actionName" in act && act.actionName === actionName) as KeyboardShortcutWithRequiredActionName;
|
||||
|
||||
if (!action || !action.actionName) {
|
||||
if (!action) {
|
||||
this.$widget.find(el).hide();
|
||||
return;
|
||||
}
|
||||
@@ -157,6 +157,7 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
.toggle(
|
||||
!!(
|
||||
action.actionName.toLowerCase().includes(filter) ||
|
||||
(action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) ||
|
||||
(action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
|
||||
(action.effectiveShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
|
||||
(action.description && action.description.toLowerCase().includes(filter))
|
||||
|
||||
215
apps/client/src/widgets/type_widgets/read_only_ocr_text.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,8 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
|
||||
try {
|
||||
const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, {
|
||||
trim: this.viewType === "grid" // for grid only short content is needed
|
||||
trim: this.viewType === "grid", // for grid only short content is needed
|
||||
showOcrText: this.parentNote.type === "search" // show OCR text only in search results
|
||||
});
|
||||
|
||||
if (this.highlightRegex) {
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.4",
|
||||
"@electron-forge/cli": "7.8.1",
|
||||
"@electron-forge/maker-deb": "7.8.1",
|
||||
"@electron-forge/maker-dmg": "7.8.1",
|
||||
"@electron-forge/maker-flatpak": "7.8.1",
|
||||
"@electron-forge/maker-rpm": "7.8.1",
|
||||
"@electron-forge/maker-squirrel": "7.8.1",
|
||||
"@electron-forge/maker-zip": "7.8.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.8.1",
|
||||
"@electron-forge/cli": "7.8.2",
|
||||
"@electron-forge/maker-deb": "7.8.2",
|
||||
"@electron-forge/maker-dmg": "7.8.2",
|
||||
"@electron-forge/maker-flatpak": "7.8.2",
|
||||
"@electron-forge/maker-rpm": "7.8.2",
|
||||
"@electron-forge/maker-squirrel": "7.8.2",
|
||||
"@electron-forge/maker-zip": "7.8.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.8.2",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -72,6 +72,10 @@ test("Tabs are restored in right order", async ({ page, context }) => {
|
||||
|
||||
// Select the mid one.
|
||||
await app.getTab(1).click();
|
||||
await expect(app.noteTreeActiveNote).toContainText("Text notes");
|
||||
await expect(app.getTab(0)).toContainText("Code notes");
|
||||
await expect(app.getTab(1)).toContainText("Text notes");
|
||||
await expect(app.getTab(2)).toContainText("Mermaid");
|
||||
|
||||
// Refresh the page and check the order.
|
||||
await app.goto( { preserveTabs: true });
|
||||
|
||||
@@ -65,9 +65,12 @@ export default class App {
|
||||
async goToNoteInNewTab(noteTitle: string) {
|
||||
const autocomplete = this.currentNoteSplit.locator(".note-autocomplete");
|
||||
await autocomplete.fill(noteTitle);
|
||||
await expect(this.currentNoteSplit.locator(".note-detail-empty-results")).toContainText(noteTitle);
|
||||
await autocomplete.press("ArrowDown");
|
||||
await autocomplete.press("Enter");
|
||||
|
||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(noteTitle);
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: noteTitle })
|
||||
.nth(1) // Select the second one, as the first one is "Create a new note"
|
||||
.click();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@types/debounce": "1.2.4",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/express-http-proxy": "1.6.6",
|
||||
"@types/express-http-proxy": "1.6.7",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@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",
|
||||
@@ -102,12 +103,16 @@
|
||||
"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"
|
||||
"yauzl": "3.2.0",
|
||||
"officeparser": "5.2.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"sharp": "0.34.3"
|
||||
},
|
||||
"nx": {
|
||||
"name": "server",
|
||||
@@ -354,6 +359,9 @@
|
||||
"build"
|
||||
],
|
||||
"command": "vitest --config {projectRoot}/vitest.build.config.mts"
|
||||
},
|
||||
"circular-deps": {
|
||||
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -107,6 +107,8 @@ 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`)
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 317 B |
@@ -1,33 +0,0 @@
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991"
|
||||
height="403">
|
||||
</figure>
|
||||
<p>The <em>Jump to Note</em> function allows easy navigation between notes
|
||||
by searching for their title. In addition to that, it can also trigger
|
||||
a full search or create notes.</p>
|
||||
<h2>Entering jump to note</h2>
|
||||
<ul>
|
||||
<li>In the <a class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>,
|
||||
press
|
||||
<img src="1_Jump to Note_image.png">button.</li>
|
||||
<li>Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.</li>
|
||||
</ul>
|
||||
<h2>Recent notes</h2>
|
||||
<p>Jump to note also has the ability to show the list of recently viewed
|
||||
/ edited notes and quickly jump to it.</p>
|
||||
<p>To access this functionality, click on <code>Jump to</code> button on the
|
||||
top. By default, (when nothing is entered into autocomplete), this dialog
|
||||
will show the list of recent notes.</p>
|
||||
<p>Alternatively you can click on the "time" icon on the right.</p>
|
||||
<img src="Jump to Note_recent-notes.gif"
|
||||
width="812" height="585">
|
||||
|
||||
<h2>Interaction</h2>
|
||||
<ul>
|
||||
<li>By default, when there is no text entered it will display the most recent
|
||||
notes.</li>
|
||||
<li>Using the keyboard, use the up or down arrow keys to navigate between
|
||||
items. Press <kbd>Enter</kbd> to open the desired note.</li>
|
||||
<li>If the note doesn't exist, it's possible to create it by typing the desired
|
||||
note title and selecting the <em>Create and link child note</em> option.</li>
|
||||
</ul>
|
||||
|
Before Width: | Height: | Size: 265 KiB |
78
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to.html
generated
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:991/403;" src="1_Jump to_image.png" width="991"
|
||||
height="403">
|
||||
</figure>
|
||||
<h2>Jump to Note</h2>
|
||||
<p>The <em>Jump to Note</em> function allows easy navigation between notes
|
||||
by searching for their title. In addition to that, it can also trigger
|
||||
a full search or create notes.</p>
|
||||
<p>To enter the “Jump to” dialog:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e32758a67e793732cf0a2b23559bf47e2">In the <a class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>,
|
||||
press
|
||||
<img src="2_Jump to_image.png">button.</li>
|
||||
<li data-list-item-id="ef176c16aa548f5b60f83fbd571f8b2db">Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.</li>
|
||||
</ul>
|
||||
<p>In addition to searching for notes, it is also possible to search for
|
||||
commands. See the dedicated section below for more information.</p>
|
||||
<h3>Interaction</h3>
|
||||
<ul>
|
||||
<li data-list-item-id="e557396da361d4edc191507782cc3b0ec">By default, when there is no text entered it will display the most recent
|
||||
notes.</li>
|
||||
<li data-list-item-id="ead4c4587b1fcb9758a09696dc25da645">Using the keyboard, use the up or down arrow keys to navigate between
|
||||
items. Press <kbd>Enter</kbd> to open the desired note.</li>
|
||||
<li data-list-item-id="ed4a932ed462ca4a089abc4a268e21aad">If the note doesn't exist, it's possible to create it by typing the desired
|
||||
note title and selecting the <em>Create and link child note</em> option.</li>
|
||||
</ul>
|
||||
<h2>Recent notes</h2>
|
||||
<p>Jump to note also has the ability to show the list of recently viewed
|
||||
/ edited notes and quickly jump to it.</p>
|
||||
<p>To access this functionality, click on <code>Jump to</code> button on the
|
||||
top. By default, (when nothing is entered into autocomplete), this dialog
|
||||
will show the list of recent notes.</p>
|
||||
<p>Alternatively you can click on the "time" icon on the right.</p>
|
||||
<h2>Command Palette</h2>
|
||||
<figure class="image image-style-align-center">
|
||||
<img style="aspect-ratio:982/524;" src="Jump to_image.png" width="982"
|
||||
height="524">
|
||||
</figure>
|
||||
<p>The command palette is a feature which allows easy execution of various
|
||||
commands that can be found throughout the application, such as from menus
|
||||
or keyboard shortcuts. This feature integrates directly into the “Jump
|
||||
to” dialog.</p>
|
||||
<h3>Interaction</h3>
|
||||
<p>To trigger the command palette:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eace3b93758628f9dd7b554e6536d9e0f">Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to display the command
|
||||
palette directly.</li>
|
||||
<li data-list-item-id="e848cc1df0264b1e4e766d6d35fd69336">If in the “Jump to” dialog, type <code>></code> in the search to switch
|
||||
to the command palette.</li>
|
||||
</ul>
|
||||
<p>Interaction:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e53759821c15fdd85ec616172434c1bfe">Type a few words to filter between commands.</li>
|
||||
<li data-list-item-id="e344bd271c6fc35f48b1fed1137ac8725">Use the up and down arrows on the keyboard or the mouse to select a command.</li>
|
||||
<li
|
||||
data-list-item-id="ea66008550eb3c827f2880c85b68bf861">Press <kbd>Enter</kbd> to execute the command.</li>
|
||||
</ul>
|
||||
<p>To exit the command palette:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ea31cb4776c2e8b13b0a19eea437512fe">Remove the <code>></code> in the search to go back to the note search.</li>
|
||||
<li
|
||||
data-list-item-id="e6b92147aba5dc5b1e329ad22a3336703">Press <kbd>Esc</kbd> to dismiss the dialog entirely.</li>
|
||||
</ul>
|
||||
<h3>Options available</h3>
|
||||
<p>Currently the following options are displayed:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eac227ab112677ff1f1900322df6d9ce9">Most of the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a> have
|
||||
an entry, with the exception of those that are too specific to be run from
|
||||
a dialog.</li>
|
||||
<li data-list-item-id="e3e1230fdeeb3101a2b1fb08d8d578f4b">Some additional options which are not yet available as keyboard shortcuts,
|
||||
but can be accessed from various menus such as: exporting a note, showing
|
||||
attachments, searching for notes or configuring the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>.</li>
|
||||
</ul>
|
||||
<h3>Limitations</h3>
|
||||
<p>Currently it's not possible to define custom actions that are displayed
|
||||
in the command palette. In the future this might change by integrating
|
||||
the options in the launch bar, which can be customized if needed.</p>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 69 KiB |
38
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html
generated
vendored
@@ -4,31 +4,29 @@
|
||||
child notes into one continuous view. This makes it ideal for reading extensive
|
||||
information broken into smaller, manageable segments.</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e7f3117635b8c3e905c71f2839e331942"><a class="reference-link" href="#root/_help_8QqnMzx393bx">Grid View</a> which
|
||||
<li><a class="reference-link" href="#root/_help_8QqnMzx393bx">Grid View</a> which
|
||||
is the default presentation method for child notes (see <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a>), where the notes are displayed
|
||||
as tiles with their title and content being visible.</li>
|
||||
<li data-list-item-id="e27a2ec6976e44512c9c52ba8d7a2ef76"><a class="reference-link" href="#root/_help_mULW0Q3VojwY">List View</a> is
|
||||
<li><a class="reference-link" href="#root/_help_mULW0Q3VojwY">List View</a> is
|
||||
similar to <a class="reference-link" href="#root/_help_8QqnMzx393bx">Grid View</a>,
|
||||
but it displays the notes one under the other with the content being expandable/collapsible,
|
||||
but also works recursively.</li>
|
||||
</ul>
|
||||
<p>More specialized collections were introduced, such as the:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eae5c2f56f9a75e7ed813fe419d4a05a4"><a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a> which
|
||||
<li><a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a> which
|
||||
displays a week, month or year calendar with the notes being shown as events.
|
||||
New events can be added easily by dragging across the calendar.</li>
|
||||
<li
|
||||
data-list-item-id="e24642a7a4c2443497fd814d78f1ca784"><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a> which
|
||||
<li><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a> which
|
||||
displays a geographical map in which the notes are represented as markers/pins
|
||||
on the map. New events can be easily added by pointing on the map.</li>
|
||||
<li
|
||||
data-list-item-id="eaa80d024c07145aa8f4443d86b01295e"><a class="reference-link" href="#root/_help_2FvYrpmOXm29">Table View</a> displays
|
||||
each note as a row in a table, with <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> being
|
||||
shown as well. This makes it easy to visualize attributes of notes, as
|
||||
well as making them easily editable.</li>
|
||||
<li data-list-item-id="ec7243729048b57d173e3f7dfb232adca"><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/GTwFsgaA0lCt/_help_CtBQqbwXDx1w">Board View</a> (Kanban)
|
||||
displays notes in columns, grouped by the value of a label.</li>
|
||||
<li><a class="reference-link" href="#root/_help_2FvYrpmOXm29">Table View</a> displays
|
||||
each note as a row in a table, with <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> being
|
||||
shown as well. This makes it easy to visualize attributes of notes, as
|
||||
well as making them easily editable.</li>
|
||||
<li><a class="reference-link" href="#root/_help_CtBQqbwXDx1w">Board View</a> (Kanban)
|
||||
displays notes in columns, grouped by the value of a label.</li>
|
||||
</ul>
|
||||
<p>For a quick presentation of all the supported view types, see the child
|
||||
notes of this help page, including screenshots.</p>
|
||||
@@ -39,13 +37,13 @@
|
||||
<h2>Use cases</h2>
|
||||
<h3>Creating a new collection</h3>
|
||||
<p>To create a new collections, right click in the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a> and
|
||||
look for the <em>Collections</em> entry and select the desired type.</p>
|
||||
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a> and look for the <em>Collections</em> entry
|
||||
and select the desired type.</p>
|
||||
<h3>Adding a description to a collection</h3>
|
||||
<p>To add a text before the collection, for example to describe it:</p>
|
||||
<ol>
|
||||
<li data-list-item-id="e7eaebf201ba06033f72b0c556aa7bfb9">Create a new collection.</li>
|
||||
<li data-list-item-id="ecf21cdac7d4b10dbf7015fa4811cae6a">In the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
<li>Create a new collection.</li>
|
||||
<li>In the <a class="reference-link" href="#root/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
go to <em>Basic Properties</em> and change the note type from <em>Collection</em> to <em>Text</em>.</li>
|
||||
</ol>
|
||||
<p>Now the text will be displayed above while still maintaining the collection
|
||||
@@ -60,13 +58,13 @@
|
||||
<p>By default, collections come with a default configuration and sometimes
|
||||
even sample notes. To create a collection completely from scratch:</p>
|
||||
<ol>
|
||||
<li data-list-item-id="ec1bab7b7f39744ea42e187c10b7d7216">Create a new note of type <em>Text</em> (or any type).</li>
|
||||
<li data-list-item-id="edcd0acc96fef97ba37695cd69c19a1b6">In the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
<li>Create a new note of type <em>Text</em> (or any type).</li>
|
||||
<li>In the <a class="reference-link" href="#root/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
go to <em>Basic Properties</em> and select <em>Collection</em> as the note
|
||||
type.</li>
|
||||
<li data-list-item-id="e48769aec6e389d541c275f59220f032e">Still in the ribbon, go to <em>Collection Properties</em> and select the
|
||||
<li>Still in the ribbon, go to <em>Collection Properties</em> and select the
|
||||
desired view type.</li>
|
||||
<li data-list-item-id="e5d79e0729f8daaae2c32cce773898464">Consult the help page of the corresponding view type in order to understand
|
||||
<li>Consult the help page of the corresponding view type in order to understand
|
||||
how to configure them.</li>
|
||||
</ol>
|
||||
<h2>Under the hood</h2>
|
||||
|
||||
@@ -11,58 +11,57 @@
|
||||
then groups each note by the value of the status attribute.</p>
|
||||
<p>Notes are displayed recursively, so even the child notes of the child
|
||||
notes will be displayed. However, unlike the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/GTwFsgaA0lCt/_help_2FvYrpmOXm29">Table View</a>,
|
||||
the notes are not displayed in a hierarchy.</p>
|
||||
href="#root/_help_2FvYrpmOXm29">Table View</a>, the notes are not displayed
|
||||
in a hierarchy.</p>
|
||||
<h2>Interaction with columns</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="e6753be6b26c46c035671af86289cf196">Create a new column by pressing <em>Add Column</em> near the last column.
|
||||
<li>Create a new column by pressing <em>Add Column</em> near the last column.
|
||||
<ul>
|
||||
<li data-list-item-id="ea187c3f44d40774c710edf7d741a236c">Once pressed, a text box will be displayed to set the name of the column.
|
||||
<li>Once pressed, a text box will be displayed to set the name of the column.
|
||||
Press Enter to confirm.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e71e0e606cc30bcaac9c20ac2dd03a8a5">To reorder a column, simply hold the mouse over the title and drag it
|
||||
<li>To reorder a column, simply hold the mouse over the title and drag it
|
||||
to the desired position.</li>
|
||||
<li data-list-item-id="eacc6f67c9dc1d0a1e7ac4ee510f7ed25">To delete a column, right click on its title and select <em>Delete column</em>.</li>
|
||||
<li
|
||||
data-list-item-id="e37fbd5727f5291bdec059d3fd4867ac6">To rename a column, click on the note title.
|
||||
<li>To delete a column, right click on its title and select <em>Delete column</em>.</li>
|
||||
<li>To rename a column, click on the note title.
|
||||
<ul>
|
||||
<li data-list-item-id="e1bfd26a676d955394313f50331408495">Press Enter to confirm.</li>
|
||||
<li data-list-item-id="ec1c42fc21c88b816843f64edd695fde5">Upon renaming a column, the corresponding status attribute of all its
|
||||
<li>Press Enter to confirm.</li>
|
||||
<li>Upon renaming a column, the corresponding status attribute of all its
|
||||
notes will be changed in bulk.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="eb357e9f42fcf948eca6212aeb209a5a1">If there are many columns, use the mouse wheel to scroll.</li>
|
||||
</li>
|
||||
<li>If there are many columns, use the mouse wheel to scroll.</li>
|
||||
</ul>
|
||||
<h2>Interaction with notes</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="e3acf3f583b9ed1a7e71bfe6cc9e1f3c1">Create a new note in any column by pressing <em>New item</em>
|
||||
<li>Create a new note in any column by pressing <em>New item</em>
|
||||
<ul>
|
||||
<li data-list-item-id="eb77dd064c0073c486b6532937eb7d277">Enter the name of the note and press <em>Enter</em>.</li>
|
||||
<li data-list-item-id="e114392d875aa7259c6a4d49de817e659">Doing so will create a new note. The new note will have an attribute (<code>status</code> label
|
||||
<li>Enter the name of the note and press <em>Enter</em>.</li>
|
||||
<li>Doing so will create a new note. The new note will have an attribute (<code>status</code> label
|
||||
by default) set to the name of the column.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e18de462534a4514576912310181a6c5b">To change the state of a note, simply drag a note from one column to the
|
||||
<li>To change the state of a note, simply drag a note from one column to the
|
||||
other to change its state.</li>
|
||||
<li data-list-item-id="eea36d097a82322804e0878e09b83d5dc">The order of the notes in each column corresponds to their position in
|
||||
<li>The order of the notes in each column corresponds to their position in
|
||||
the tree.
|
||||
<ul>
|
||||
<li data-list-item-id="ea374f8fd30bfbdeb07b98b8bb56c8565">It's possible to reorder notes simply by dragging them to the desired
|
||||
<li>It's possible to reorder notes simply by dragging them to the desired
|
||||
position within the same columns.</li>
|
||||
<li data-list-item-id="ea65032b5bb4a4ace6b551877bf695831">It's also possible to drag notes across columns, at the desired position.</li>
|
||||
<li>It's also possible to drag notes across columns, at the desired position.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e17382c245068b6f396d96240628efeaa">For more options, right click on a note to display a context menu with
|
||||
<li>For more options, right click on a note to display a context menu with
|
||||
the following options:
|
||||
<ul>
|
||||
<li data-list-item-id="eae617b894b3dad9d9efa0637eeae3a58">Open the note in a new tab/split/window or quick edit.</li>
|
||||
<li data-list-item-id="e2d699e050e30c9b5f7b105fe613df26d">Move the note to any column.</li>
|
||||
<li data-list-item-id="e10c62ae541b53d1dc224c57cdde27ace">Insert a new note above/below the current one.</li>
|
||||
<li data-list-item-id="e2a66bb1230add7d14d1e7f90507bd138">Delete the current note.</li>
|
||||
<li>Open the note in a new tab/split/window or quick edit.</li>
|
||||
<li>Move the note to any column.</li>
|
||||
<li>Insert a new note above/below the current one.</li>
|
||||
<li>Delete the current note.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e8f9888bf5fcb78fd22511a5d6402e313">If there are many notes within the column, move the mouse over the column
|
||||
<li>If there are many notes within the column, move the mouse over the column
|
||||
and use the mouse wheel to scroll.</li>
|
||||
</ul>
|
||||
<h2>Configuration</h2>
|
||||
@@ -78,7 +77,5 @@ class="admonition note">
|
||||
<h2>Interaction</h2>
|
||||
<h2>Limitations</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="eadd96ee9c3bfbf8fd1ba282fffa0c5f1">It is not possible yet to use group by a relation, only by label.</li>
|
||||
</ul>
|
||||
<p> </p>
|
||||
<p> </p>
|
||||
<li>It is not possible yet to use group by a relation, only by label.</li>
|
||||
</ul>
|
||||
@@ -8,31 +8,31 @@
|
||||
<h2>How it works</h2>
|
||||
<p>The tabular structure is represented as such:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ea0418f143b93a19ec996f3e66444c23a">Each child note is a row in the table.</li>
|
||||
<li data-list-item-id="e37df536f3b11b4294297e7b6134428f9">If child rows also have children, they will be displayed under an expander
|
||||
<li>Each child note is a row in the table.</li>
|
||||
<li>If child rows also have children, they will be displayed under an expander
|
||||
(nested notes).</li>
|
||||
<li data-list-item-id="ebd16c84c37c2d3782567cf4d23701354">Each column is a <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> that
|
||||
<li>Each column is a <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> that
|
||||
is defined on the Collection note.
|
||||
<ul>
|
||||
<li data-list-item-id="ea05f5ec1c0c315b37f05bbb3a045583a">Actually, both promoted and unpromoted attributes are supported, but it's
|
||||
<li>Actually, both promoted and unpromoted attributes are supported, but it's
|
||||
a requirement to use a label/relation definition.</li>
|
||||
<li data-list-item-id="e2ef130cc4d15f788e50daf210847cbbb">The promoted attributes are usually defined as inheritable in order to
|
||||
<li>The promoted attributes are usually defined as inheritable in order to
|
||||
show up in the child notes, but it's not a requirement.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e1cb6afc55de7e7d038e5dc2592604e7d">If there are multiple attribute definitions with the same <code>name</code>,
|
||||
<li>If there are multiple attribute definitions with the same <code>name</code>,
|
||||
only one will be displayed.</li>
|
||||
</ul>
|
||||
<p>There are also a few predefined columns:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e0ee40aa88c6ec67050cf582e1de2a511">The current item number, identified by the <code>#</code> symbol.
|
||||
<li>The current item number, identified by the <code>#</code> symbol.
|
||||
<ul>
|
||||
<li data-list-item-id="e0658c976040c3dd7d7d516f3e81b5dd6">This simply counts the note and is affected by sorting.</li>
|
||||
<li>This simply counts the note and is affected by sorting.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="ee40b5cf3b77d956abee6bbed0781c1aa"><a class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>,
|
||||
<li><a class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>,
|
||||
representing the unique ID used internally by Trilium</li>
|
||||
<li data-list-item-id="e4e24776fc43d49b40924a55037bae164">The title of the note.</li>
|
||||
<li>The title of the note.</li>
|
||||
</ul>
|
||||
<h2>Interaction</h2>
|
||||
<h3>Creating a new table</h3>
|
||||
@@ -43,18 +43,17 @@
|
||||
is defined on the Collection note.</p>
|
||||
<p>To create a new column, either:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e9d4c7b075e3f2fe9f6df45cd06a3a2b2">Press <em>Add new column</em> at the bottom of the table.</li>
|
||||
<li data-list-item-id="ef66e5ebb0af38d4db86743365f6ec3a7">Right click on an existing column and select Add column to the left/right.</li>
|
||||
<li
|
||||
data-list-item-id="ecd0e68e741ce8bb304a40d878e5745a0">Right click on the empty space of the column header and select <em>Label</em> or <em>Relation</em> in
|
||||
<li>Press <em>Add new column</em> at the bottom of the table.</li>
|
||||
<li>Right click on an existing column and select Add column to the left/right.</li>
|
||||
<li>Right click on the empty space of the column header and select <em>Label</em> or <em>Relation</em> in
|
||||
the <em>New column</em> section.</li>
|
||||
</ul>
|
||||
<h3>Adding new rows</h3>
|
||||
<p>Each row is actually a note that is a child of the Collection note.</p>
|
||||
<p>To create a new note, either:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eba76a40653e728455961488b44e2c88a">Press <em>Add new row</em> at the bottom of the table.</li>
|
||||
<li data-list-item-id="efcdd260648c64133366baaedbe9fae5e">Right click on an existing row and select <em>Insert row above, Insert child note</em> or <em>Insert row below</em>.</li>
|
||||
<li>Press <em>Add new row</em> at the bottom of the table.</li>
|
||||
<li>Right click on an existing row and select <em>Insert row above, Insert child note</em> or <em>Insert row below</em>.</li>
|
||||
</ul>
|
||||
<p>By default it will try to edit the title of the newly created note.</p>
|
||||
<p>Alternatively, the note can be created from the <a class="reference-link"
|
||||
@@ -62,28 +61,27 @@
|
||||
<h3>Context menu</h3>
|
||||
<p>There are multiple menus:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e10284169a0dc36c347245a6fc5a4a0d2">Right clicking on a column, allows:
|
||||
<li>Right clicking on a column, allows:
|
||||
<ul>
|
||||
<li data-list-item-id="e959d44a502767362e7fc02e5eadae03e">Sorting by the selected column and resetting the sort.</li>
|
||||
<li data-list-item-id="e457f7bc4d135083c2b4c6b4faaabc7fe">Hiding the selected column or adjusting the visibility of every column.</li>
|
||||
<li
|
||||
data-list-item-id="e64b21e19754d7d2e4b1c4f4099b3bacc">Adding new columns to the left or the right of the column.</li>
|
||||
<li data-list-item-id="edde6b9b4713d8c7c0892be2f1165bde6">Editing the current column.</li>
|
||||
<li data-list-item-id="edd1a26953073beefb0c103c57b513a08">Deleting the current column.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="eb1288eb3e94b2bf5cd6b400039717edf">Right clicking on the space to the right of the columns, allows:
|
||||
<ul>
|
||||
<li data-list-item-id="ee3ff68651c4c766b15470344a2648860">Adjusting the visibility of every column.</li>
|
||||
<li data-list-item-id="e00cc86778fa0fa85734fc01b74e25279">Adding new columns.</li>
|
||||
<li>Sorting by the selected column and resetting the sort.</li>
|
||||
<li>Hiding the selected column or adjusting the visibility of every column.</li>
|
||||
<li>Adding new columns to the left or the right of the column.</li>
|
||||
<li>Editing the current column.</li>
|
||||
<li>Deleting the current column.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e913b95accaf874e109db9905b5b6f3b5">Right clicking on a row, allows:
|
||||
<li>Right clicking on the space to the right of the columns, allows:
|
||||
<ul>
|
||||
<li data-list-item-id="e4e7e38228abfb58c37ffc68616a8da6e">Opening the corresponding note of the row in a new tab, split, window
|
||||
<li>Adjusting the visibility of every column.</li>
|
||||
<li>Adding new columns.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Right clicking on a row, allows:
|
||||
<ul>
|
||||
<li>Opening the corresponding note of the row in a new tab, split, window
|
||||
or quick editing it.</li>
|
||||
<li data-list-item-id="ea949a04a7a9c9f5d7979140684218298">Inserting rows above, below or as a child note.</li>
|
||||
<li data-list-item-id="e8d2f38f590deb56dce4bd880da02e2fa">Deleting the row.</li>
|
||||
<li>Inserting rows above, below or as a child note.</li>
|
||||
<li>Deleting the row.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -92,18 +90,17 @@
|
||||
not only reflect in the table, but also as an attribute of the corresponding
|
||||
note.</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ef5625b0a34c060243caf9297a2c959d8">The editing will respect the type of the promoted attribute, by presenting
|
||||
<li>The editing will respect the type of the promoted attribute, by presenting
|
||||
a normal text box, a number selector or a date selector for example.</li>
|
||||
<li
|
||||
data-list-item-id="eccf0c928672039b793c4566d54ec8419">It also possible to change the title of a note.</li>
|
||||
<li data-list-item-id="e50644ab190ff6911af490ca1ad732a53">Editing relations is also possible
|
||||
<ul>
|
||||
<li data-list-item-id="e6853711436a0f878786636d984df73e4">Simply click on a relation and it will become editable. Enter the text
|
||||
to look for a note and click on it.</li>
|
||||
<li data-list-item-id="e8d9cc912c0025ea78931b15979cbcfc6">To remove a relation, remove the title of the note from the text box and
|
||||
click outside the cell.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>It also possible to change the title of a note.</li>
|
||||
<li>Editing relations is also possible
|
||||
<ul>
|
||||
<li>Simply click on a relation and it will become editable. Enter the text
|
||||
to look for a note and click on it.</li>
|
||||
<li>To remove a relation, remove the title of the note from the text box and
|
||||
click outside the cell.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Editing columns</h3>
|
||||
<p>It is possible to edit a column by right clicking it and selecting <em>Edit column.</em> This
|
||||
@@ -117,19 +114,18 @@
|
||||
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>. However, it is possible
|
||||
to sort the data by the values of a column:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e9866b3e8697d3c7d480bb32d2df2fe4f">To do so, simply click on a column.</li>
|
||||
<li data-list-item-id="e1315c18a23aabbdcc9c36641c4d22ed8">To switch between ascending or descending sort, simply click again on
|
||||
<li>To do so, simply click on a column.</li>
|
||||
<li>To switch between ascending or descending sort, simply click again on
|
||||
the same column. The arrow next to the column will indicate the direction
|
||||
of the sort.</li>
|
||||
<li data-list-item-id="e8de6519ff78c55846e8ad949efdb1749">To disable sorting and fall back to the original order, right click any
|
||||
<li>To disable sorting and fall back to the original order, right click any
|
||||
column on the header and select <em>Clear sorting.</em>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Reordering and hiding columns</h3>
|
||||
<ul>
|
||||
<li data-list-item-id="e9db498cf4b735211bfab6d8a897f0614">Columns can be reordered by dragging the header of the columns.</li>
|
||||
<li
|
||||
data-list-item-id="ec435139e49644765cc9fa36dee3f28ce">Columns can be hidden or shown by right clicking on a column and clicking
|
||||
<li>Columns can be reordered by dragging the header of the columns.</li>
|
||||
<li>Columns can be hidden or shown by right clicking on a column and clicking
|
||||
the item corresponding to the column.</li>
|
||||
</ul>
|
||||
<h3>Reordering rows</h3>
|
||||
@@ -140,12 +136,10 @@
|
||||
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</p>
|
||||
<p>Reordering does have some limitations:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e3ecbfd2eb395e49cf755279ed1ce96bc">If the parent note has <code>#sorted</code>, reordering will be disabled.</li>
|
||||
<li
|
||||
data-list-item-id="e74f74d42cf30cf51d548b25e68397e33">If using nested tables, then reordering will also be disabled.</li>
|
||||
<li
|
||||
data-list-item-id="e750f0e2ba77e7123a1a0390bb6138e51">Currently, it's possible to reorder notes even if column sorting is used,
|
||||
but the result might be inconsistent.</li>
|
||||
<li>If the parent note has <code>#sorted</code>, reordering will be disabled.</li>
|
||||
<li>If using nested tables, then reordering will also be disabled.</li>
|
||||
<li>Currently, it's possible to reorder notes even if column sorting is used,
|
||||
but the result might be inconsistent.</li>
|
||||
</ul>
|
||||
<h3>Nested trees</h3>
|
||||
<p>If the child notes of the collection also have their own child notes,
|
||||
@@ -156,27 +150,27 @@
|
||||
to a certain number of levels or even disable it completely. To do so,
|
||||
either:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e48a95d7d971cadd6f1b53e25d567be7e">Go to <em>Collection Properties</em> in the <a class="reference-link"
|
||||
<li>Go to <em>Collection Properties</em> in the <a class="reference-link"
|
||||
href="#root/_help_BlN9DFI679QC">Ribbon</a> and look for the <em>Max nesting depth</em> section.
|
||||
<ul>
|
||||
<li data-list-item-id="e18de613a0eb1a49d8c1629ea99602d22">To disable nesting, type 0 and press Enter.</li>
|
||||
<li data-list-item-id="e5d184ff1aa8a060900fd5a8e3cb78c56">To limit to a certain depth, type in the desired number (e.g. 2 to only
|
||||
<li>To disable nesting, type 0 and press Enter.</li>
|
||||
<li>To limit to a certain depth, type in the desired number (e.g. 2 to only
|
||||
display children and sub-children).</li>
|
||||
<li data-list-item-id="e9e083d9f55b6658e88651bc005d3519f">To re-enable unlimited nesting, remove the number and press Enter.</li>
|
||||
<li>To re-enable unlimited nesting, remove the number and press Enter.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="e6a4e204e9145abbceebcc96787a61183">Manually set <code>maxNestingDepth</code> to the desired value.</li>
|
||||
<li>Manually set <code>maxNestingDepth</code> to the desired value.</li>
|
||||
</ul>
|
||||
<p>Limitations:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ea837ad3a83df0a8939a2cc0d586cf58f">While in this mode, it's not possible to reorder notes.</li>
|
||||
<li>While in this mode, it's not possible to reorder notes.</li>
|
||||
</ul>
|
||||
<h2>Limitations</h2>
|
||||
<ul>
|
||||
<li data-list-item-id="efcbcbbae267cd2ed3ddac4d512a6c5a1">Multi-value labels and relations are not supported. If a <a class="reference-link"
|
||||
<li>Multi-value labels and relations are not supported. If a <a class="reference-link"
|
||||
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> is defined
|
||||
with a <em>Multi value</em> specificity, they will be ignored.</li>
|
||||
<li data-list-item-id="e8eb7e91706b56513d6f85828a7afa02f">There is no support to filter the rows by a certain criteria. Consider
|
||||
<li>There is no support to filter the rows by a certain criteria. Consider
|
||||
using the table view in search for that use case.</li>
|
||||
</ul>
|
||||
<h2>Use in search</h2>
|
||||
@@ -187,8 +181,8 @@
|
||||
of the <a class="reference-link" href="#root/_help_eIg8jdvaoNNd">Search</a>.</p>
|
||||
<p>However, there are also some limitations:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ee0798fde43ca865be18aef52b5a4adf4">It's not possible to reorder notes.</li>
|
||||
<li data-list-item-id="e9a31c753b79285e3db51c4f71764a8ba">It's not possible to add a new row.</li>
|
||||
<li>It's not possible to reorder notes.</li>
|
||||
<li>It's not possible to add a new row.</li>
|
||||
</ul>
|
||||
<p>Columns are supported, by being defined as <a class="reference-link"
|
||||
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> to the
|
||||
|
||||
@@ -220,7 +220,6 @@
|
||||
"go-to-next-note-title": "跳转到下一条笔记",
|
||||
"new-note-title": "新建笔记",
|
||||
"search-notes-title": "搜索笔记",
|
||||
"jump-to-note-title": "跳转到笔记",
|
||||
"calendar-title": "日历",
|
||||
"recent-changes-title": "最近更改",
|
||||
"bookmarks-title": "书签",
|
||||
|
||||
@@ -212,7 +212,6 @@
|
||||
"go-to-next-note-title": "Zur nächsten Notiz gehen",
|
||||
"new-note-title": "Neue Notiz",
|
||||
"search-notes-title": "Notizen durchsuchen",
|
||||
"jump-to-note-title": "Zur Notiz springen",
|
||||
"calendar-title": "Kalender",
|
||||
"recent-changes-title": "neue Änderungen",
|
||||
"bookmarks-title": "Lesezeichen",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"back-in-note-history": "Navigate to previous note in history",
|
||||
"forward-in-note-history": "Navigate to next note in history",
|
||||
"open-jump-to-note-dialog": "Open \"Jump to note\" dialog",
|
||||
"open-command-palette": "Open command palette",
|
||||
"scroll-to-active-note": "Scroll note tree to active note",
|
||||
"quick-search": "Activate quick search bar",
|
||||
"search-in-subtree": "Search for notes in the active note's subtree",
|
||||
@@ -21,8 +22,8 @@
|
||||
"move-note-down-in-hierarchy": "Move note down in hierarchy",
|
||||
"edit-note-title": "Jump from tree to the note detail and edit title",
|
||||
"edit-branch-prefix": "Show \"Edit branch prefix\" dialog",
|
||||
"cloneNotesTo": "Clone selected notes",
|
||||
"moveNotesTo": "Move selected notes",
|
||||
"clone-notes-to": "Clone selected notes",
|
||||
"move-notes-to": "Move selected notes",
|
||||
"note-clipboard": "Note clipboard",
|
||||
"copy-notes-to-clipboard": "Copy selected notes to the clipboard",
|
||||
"paste-notes-from-clipboard": "Paste notes from the clipboard into active note",
|
||||
@@ -104,6 +105,103 @@
|
||||
"export-as-pdf": "Export the current note as a PDF",
|
||||
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"back-in-note-history": "Back in Note History",
|
||||
"forward-in-note-history": "Forward in Note History",
|
||||
"jump-to-note": "Jump to...",
|
||||
"command-palette": "Command Palette",
|
||||
"scroll-to-active-note": "Scroll to Active Note",
|
||||
"quick-search": "Quick Search",
|
||||
"search-in-subtree": "Search in Subtree",
|
||||
"expand-subtree": "Expand Subtree",
|
||||
"collapse-tree": "Collapse Tree",
|
||||
"collapse-subtree": "Collapse Subtree",
|
||||
"sort-child-notes": "Sort Child Notes",
|
||||
"create-note-after": "Create Note After",
|
||||
"create-note-into": "Create Note Into",
|
||||
"create-note-into-inbox": "Create Note Into Inbox",
|
||||
"delete-notes": "Delete Notes",
|
||||
"move-note-up": "Move Note Up",
|
||||
"move-note-down": "Move Note Down",
|
||||
"move-note-up-in-hierarchy": "Move Note Up in Hierarchy",
|
||||
"move-note-down-in-hierarchy": "Move Note Down in Hierarchy",
|
||||
"edit-note-title": "Edit Note Title",
|
||||
"edit-branch-prefix": "Edit Branch Prefix",
|
||||
"clone-notes-to": "Clone Notes To",
|
||||
"move-notes-to": "Move Notes To",
|
||||
"copy-notes-to-clipboard": "Copy Notes to Clipboard",
|
||||
"paste-notes-from-clipboard": "Paste Notes from Clipboard",
|
||||
"cut-notes-to-clipboard": "Cut Notes to Clipboard",
|
||||
"select-all-notes-in-parent": "Select All Notes in Parent",
|
||||
"add-note-above-to-selection": "Add Note Above to Selection",
|
||||
"add-note-below-to-selection": "Add Note Below to Selection",
|
||||
"duplicate-subtree": "Duplicate Subtree",
|
||||
"open-new-tab": "Open New Tab",
|
||||
"close-active-tab": "Close Active Tab",
|
||||
"reopen-last-tab": "Reopen Last Tab",
|
||||
"activate-next-tab": "Activate Next Tab",
|
||||
"activate-previous-tab": "Activate Previous Tab",
|
||||
"open-new-window": "Open New Window",
|
||||
"toggle-system-tray-icon": "Toggle System Tray Icon",
|
||||
"toggle-zen-mode": "Toggle Zen Mode",
|
||||
"switch-to-first-tab": "Switch to First Tab",
|
||||
"switch-to-second-tab": "Switch to Second Tab",
|
||||
"switch-to-third-tab": "Switch to Third Tab",
|
||||
"switch-to-fourth-tab": "Switch to Fourth Tab",
|
||||
"switch-to-fifth-tab": "Switch to Fifth Tab",
|
||||
"switch-to-sixth-tab": "Switch to Sixth Tab",
|
||||
"switch-to-seventh-tab": "Switch to Seventh Tab",
|
||||
"switch-to-eighth-tab": "Switch to Eighth Tab",
|
||||
"switch-to-ninth-tab": "Switch to Ninth Tab",
|
||||
"switch-to-last-tab": "Switch to Last Tab",
|
||||
"show-note-source": "Show Note Source",
|
||||
"show-options": "Show Options",
|
||||
"show-revisions": "Show Revisions",
|
||||
"show-recent-changes": "Show Recent Changes",
|
||||
"show-sql-console": "Show SQL Console",
|
||||
"show-backend-log": "Show Backend Log",
|
||||
"show-help": "Show Help",
|
||||
"show-cheatsheet": "Show Cheatsheet",
|
||||
"add-link-to-text": "Add Link to Text",
|
||||
"follow-link-under-cursor": "Follow Link Under Cursor",
|
||||
"insert-date-and-time-to-text": "Insert Date and Time to Text",
|
||||
"paste-markdown-into-text": "Paste Markdown into Text",
|
||||
"cut-into-note": "Cut into Note",
|
||||
"add-include-note-to-text": "Add Include Note to Text",
|
||||
"edit-read-only-note": "Edit Read-Only Note",
|
||||
"add-new-label": "Add New Label",
|
||||
"add-new-relation": "Add New Relation",
|
||||
"toggle-ribbon-tab-classic-editor": "Toggle Ribbon Tab Classic Editor",
|
||||
"toggle-ribbon-tab-basic-properties": "Toggle Ribbon Tab Basic Properties",
|
||||
"toggle-ribbon-tab-book-properties": "Toggle Ribbon Tab Book Properties",
|
||||
"toggle-ribbon-tab-file-properties": "Toggle Ribbon Tab File Properties",
|
||||
"toggle-ribbon-tab-image-properties": "Toggle Ribbon Tab Image Properties",
|
||||
"toggle-ribbon-tab-owned-attributes": "Toggle Ribbon Tab Owned Attributes",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Toggle Ribbon Tab Inherited Attributes",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Toggle Ribbon Tab Promoted Attributes",
|
||||
"toggle-ribbon-tab-note-map": "Toggle Ribbon Tab Note Map",
|
||||
"toggle-ribbon-tab-note-info": "Toggle Ribbon Tab Note Info",
|
||||
"toggle-ribbon-tab-note-paths": "Toggle Ribbon Tab Note Paths",
|
||||
"toggle-ribbon-tab-similar-notes": "Toggle Ribbon Tab Similar Notes",
|
||||
"toggle-right-pane": "Toggle Right Pane",
|
||||
"print-active-note": "Print Active Note",
|
||||
"export-active-note-as-pdf": "Export Active Note as PDF",
|
||||
"open-note-externally": "Open Note Externally",
|
||||
"render-active-note": "Render Active Note",
|
||||
"run-active-note": "Run Active Note",
|
||||
"toggle-note-hoisting": "Toggle Note Hoisting",
|
||||
"unhoist-note": "Unhoist Note",
|
||||
"reload-frontend-app": "Reload Frontend App",
|
||||
"open-developer-tools": "Open Developer Tools",
|
||||
"find-in-text": "Find In Text",
|
||||
"toggle-left-pane": "Toggle Left Pane",
|
||||
"toggle-full-screen": "Toggle Full Screen",
|
||||
"zoom-out": "Zoom Out",
|
||||
"zoom-in": "Zoom In",
|
||||
"reset-zoom-level": "Reset Zoom Level",
|
||||
"copy-without-formatting": "Copy Without Formatting",
|
||||
"force-save-revision": "Force Save Revision"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"heading": "Trilium Login",
|
||||
@@ -229,7 +327,7 @@
|
||||
"go-to-next-note-title": "Go to Next Note",
|
||||
"new-note-title": "New Note",
|
||||
"search-notes-title": "Search Notes",
|
||||
"jump-to-note-title": "Jump to Note",
|
||||
"jump-to-note-title": "Jump to...",
|
||||
"calendar-title": "Calendar",
|
||||
"recent-changes-title": "Recent Changes",
|
||||
"bookmarks-title": "Bookmarks",
|
||||
|
||||
@@ -229,7 +229,6 @@
|
||||
"go-to-next-note-title": "Ir a nota siguiente",
|
||||
"new-note-title": "Nueva nota",
|
||||
"search-notes-title": "Buscar notas",
|
||||
"jump-to-note-title": "Saltar a nota",
|
||||
"calendar-title": "Calendario",
|
||||
"recent-changes-title": "Cambios recientes",
|
||||
"bookmarks-title": "Marcadores",
|
||||
|
||||
@@ -216,7 +216,6 @@
|
||||
"go-to-next-note-title": "Aller à la note suivante",
|
||||
"new-note-title": "Nouvelle note",
|
||||
"search-notes-title": "Rechercher des notes",
|
||||
"jump-to-note-title": "Aller à la note",
|
||||
"calendar-title": "Calendrier",
|
||||
"recent-changes-title": "Modifications récentes",
|
||||
"bookmarks-title": "Signets",
|
||||
|
||||
@@ -209,7 +209,6 @@
|
||||
"etapi-title": "ETAPI",
|
||||
"go-to-previous-note-title": "Mergi la notița anterioară",
|
||||
"images-title": "Imagini",
|
||||
"jump-to-note-title": "Sari la notiță",
|
||||
"launch-bar-title": "Bară de lansare",
|
||||
"new-note-title": "Notiță nouă",
|
||||
"note-launcher-title": "Lansator de notițe",
|
||||
|
||||
@@ -14,13 +14,13 @@ import entityConstructor from "../becca/entity_constructor.js";
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import ws from "../services/ws.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
|
||||
const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
const sqlInit = (await import("../services/sql_init.js")).default;
|
||||
export const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
// We have to import async since options init requires keyboard actions which require translations.
|
||||
const options_init = (await import("../services/options_init.js")).default;
|
||||
|
||||
sqlInit.dbReady.then(() => {
|
||||
dbReady.then(() => {
|
||||
cls.init(() => {
|
||||
load();
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
return "blobId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["blobId", "content"];
|
||||
return ["blobId", "content", "ocr_text"];
|
||||
}
|
||||
|
||||
content!: string | Buffer;
|
||||
contentLength!: number;
|
||||
ocr_text?: string | null;
|
||||
|
||||
constructor(row: BlobRow) {
|
||||
super();
|
||||
@@ -25,6 +26,7 @@ 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;
|
||||
}
|
||||
@@ -34,6 +36,7 @@ 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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import becca_loader from "../becca/becca_loader.js";
|
||||
import migration from "./0233__migrate_geo_map_to_collection.js";
|
||||
|
||||
/**
|
||||
* Test suite for migration 0233 which converts geoMap notes to book type with viewConfig attachments.
|
||||
*
|
||||
* This migration:
|
||||
* 1. Changes note type from "geoMap" to "book"
|
||||
* 2. Clears the mime type
|
||||
* 3. Moves the note content to a viewConfig attachment named "geoMap.json"
|
||||
* 4. Clears the note content
|
||||
* 5. Sets a template relation to "_template_geo_map"
|
||||
*
|
||||
* The test simulates the database state before migration by directly inserting
|
||||
* test data into the database, then verifies the migration transforms the data correctly.
|
||||
*/
|
||||
describe("Migration 0233: Migrate geoMap to collection", () => {
|
||||
beforeEach(async () => {
|
||||
// Set up a clean in-memory database for each test
|
||||
sql.rebuildIntegrationTestDatabase();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
cls.init(() => {
|
||||
becca_loader.load();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should migrate geoMap notes to book type with viewConfig attachment", async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
cls.init(() => {
|
||||
// Create a test geoMap note with content
|
||||
const geoMapContent = JSON.stringify({
|
||||
markers: [
|
||||
{ lat: 40.7128, lng: -74.0060, title: "New York" },
|
||||
{ lat: 34.0522, lng: -118.2437, title: "Los Angeles" }
|
||||
],
|
||||
center: { lat: 39.8283, lng: -98.5795 },
|
||||
zoom: 4
|
||||
});
|
||||
|
||||
// Insert test data directly into the database
|
||||
const testNoteId = "test_geo_note_1";
|
||||
const testBlobId = "test_blob_geo_1";
|
||||
|
||||
// Insert note record
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO notes (noteId, title, type, mime, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
||||
`, [testNoteId, "Test GeoMap Note", "geoMap", "application/json", testBlobId]);
|
||||
|
||||
// Insert blob content
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
`, [testBlobId, geoMapContent]);
|
||||
|
||||
// Create a note without content to test edge case
|
||||
const testNoteId2 = "test_geo_note_2";
|
||||
const testBlobId2 = "test_blob_geo_2";
|
||||
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO notes (noteId, title, type, mime, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
||||
`, [testNoteId2, "Empty GeoMap Note", "geoMap", "application/json", testBlobId2]);
|
||||
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
`, [testBlobId2, ""]);
|
||||
|
||||
// Also create a non-geoMap note to ensure it's not affected
|
||||
const regularNoteId = "test_regular_note";
|
||||
const regularBlobId = "test_blob_regular";
|
||||
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO notes (noteId, title, type, mime, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
||||
`, [regularNoteId, "Regular Text Note", "text", "text/html", regularBlobId]);
|
||||
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
`, [regularBlobId, "<p>Regular content</p>"]);
|
||||
|
||||
// Reload becca to include our test data
|
||||
becca_loader.load();
|
||||
|
||||
// Verify initial state
|
||||
const geoMapNote1 = becca.getNote(testNoteId);
|
||||
const geoMapNote2 = becca.getNote(testNoteId2);
|
||||
const regularNote = becca.getNote(regularNoteId);
|
||||
|
||||
expect(geoMapNote1).toBeTruthy();
|
||||
expect(geoMapNote1?.type).toBe("geoMap");
|
||||
expect(geoMapNote2).toBeTruthy();
|
||||
expect(geoMapNote2?.type).toBe("geoMap");
|
||||
expect(regularNote).toBeTruthy();
|
||||
expect(regularNote?.type).toBe("text");
|
||||
|
||||
// Run the migration
|
||||
migration();
|
||||
|
||||
// Reload becca after migration
|
||||
becca_loader.load();
|
||||
|
||||
// Verify migration results
|
||||
const migratedNote1 = becca.getNote(testNoteId);
|
||||
const migratedNote2 = becca.getNote(testNoteId2);
|
||||
const unchangedNote = becca.getNote(regularNoteId);
|
||||
|
||||
// Check that geoMap notes were converted to book type
|
||||
expect(migratedNote1).toBeTruthy();
|
||||
expect(migratedNote1?.type).toBe("book");
|
||||
expect(migratedNote1?.mime).toBe("");
|
||||
|
||||
expect(migratedNote2).toBeTruthy();
|
||||
expect(migratedNote2?.type).toBe("book");
|
||||
expect(migratedNote2?.mime).toBe("");
|
||||
|
||||
// Check that regular note was not affected
|
||||
expect(unchangedNote).toBeTruthy();
|
||||
expect(unchangedNote?.type).toBe("text");
|
||||
|
||||
// Check that content was moved to viewConfig attachment for note with content
|
||||
if (migratedNote1) {
|
||||
const viewConfigAttachments = migratedNote1.getAttachmentsByRole("viewConfig");
|
||||
expect(viewConfigAttachments).toHaveLength(1);
|
||||
|
||||
const attachment = viewConfigAttachments[0];
|
||||
expect(attachment.title).toBe("geoMap.json");
|
||||
expect(attachment.mime).toBe("application/json");
|
||||
expect(attachment.getContent()).toBe(geoMapContent);
|
||||
|
||||
// Check that note content was cleared
|
||||
expect(migratedNote1.getContent()).toBe("");
|
||||
|
||||
// Check that template relation was set
|
||||
const templateRelations = migratedNote1.getRelations("template");
|
||||
expect(templateRelations).toHaveLength(1);
|
||||
expect(templateRelations[0].value).toBe("_template_geo_map");
|
||||
}
|
||||
|
||||
// Check that note without content doesn't have viewConfig attachment
|
||||
if (migratedNote2) {
|
||||
const viewConfigAttachments = migratedNote2.getAttachmentsByRole("viewConfig");
|
||||
expect(viewConfigAttachments).toHaveLength(0);
|
||||
|
||||
// Check that template relation was still set
|
||||
const templateRelations = migratedNote2.getRelations("template");
|
||||
expect(templateRelations).toHaveLength(1);
|
||||
expect(templateRelations[0].value).toBe("_template_geo_map");
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle existing viewConfig attachments with same title", async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
cls.init(() => {
|
||||
const geoMapContent = JSON.stringify({ test: "data" });
|
||||
const testNoteId = "test_geo_note_existing";
|
||||
const testBlobId = "test_blob_geo_existing";
|
||||
|
||||
// Insert note record
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO notes (noteId, title, type, mime, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
||||
`, [testNoteId, "Test GeoMap with Existing Attachment", "geoMap", "application/json", testBlobId]);
|
||||
|
||||
// Insert blob content
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
`, [testBlobId, geoMapContent]);
|
||||
|
||||
// Reload becca
|
||||
becca_loader.load();
|
||||
|
||||
const note = becca.getNote(testNoteId);
|
||||
expect(note).toBeTruthy();
|
||||
|
||||
// Create an existing viewConfig attachment with the same title
|
||||
const existingContent = JSON.stringify({ existing: "data" });
|
||||
note?.saveAttachment({
|
||||
role: "viewConfig",
|
||||
title: "geoMap.json",
|
||||
mime: "application/json",
|
||||
content: existingContent,
|
||||
position: 0
|
||||
});
|
||||
|
||||
// Verify existing attachment was created
|
||||
let attachments = note?.getAttachmentsByRole("viewConfig") || [];
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].getContent()).toBe(existingContent);
|
||||
|
||||
// Run migration
|
||||
migration();
|
||||
|
||||
// Reload becca after migration
|
||||
becca_loader.load();
|
||||
const migratedNote = becca.getNote(testNoteId);
|
||||
|
||||
// Verify that existing attachment was updated, not duplicated
|
||||
if (migratedNote) {
|
||||
const viewConfigAttachments = migratedNote.getAttachmentsByRole("viewConfig");
|
||||
expect(viewConfigAttachments).toHaveLength(1);
|
||||
|
||||
const attachment = viewConfigAttachments[0];
|
||||
expect(attachment.title).toBe("geoMap.json");
|
||||
expect(attachment.getContent()).toBe(geoMapContent); // Should be updated with note content
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle protected geoMap notes appropriately", async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cls.init(() => {
|
||||
const geoMapContent = JSON.stringify({
|
||||
markers: [{ lat: 51.5074, lng: -0.1278, title: "London" }],
|
||||
center: { lat: 51.5074, lng: -0.1278 },
|
||||
zoom: 10
|
||||
});
|
||||
|
||||
const testNoteId = "protected_geo_note";
|
||||
const testBlobId = "protected_blob_geo";
|
||||
|
||||
// Insert protected geoMap note
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO notes (noteId, title, type, mime, blobId, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
||||
`, [testNoteId, "Protected GeoMap Note", "geoMap", "application/json", testBlobId, 1]);
|
||||
|
||||
// Insert encrypted blob content (in reality this would be encrypted, but for test we use plain text)
|
||||
sql.execute(/*sql*/`
|
||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
`, [testBlobId, geoMapContent]);
|
||||
|
||||
// Reload becca
|
||||
becca_loader.load();
|
||||
|
||||
// Verify initial state
|
||||
const protectedNote = becca.getNote(testNoteId);
|
||||
expect(protectedNote).toBeTruthy();
|
||||
expect(protectedNote?.type).toBe("geoMap");
|
||||
expect(protectedNote?.isProtected).toBe(true);
|
||||
|
||||
// Run migration - this should either handle protected notes gracefully or throw an error
|
||||
try {
|
||||
migration();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
// Reload becca after migration attempt
|
||||
becca_loader.load();
|
||||
const noteAfterMigration = becca.getNote(testNoteId);
|
||||
|
||||
// If migration succeeds, verify the transformation
|
||||
expect(noteAfterMigration).toBeTruthy();
|
||||
expect(noteAfterMigration?.type).toBe("book");
|
||||
expect(noteAfterMigration?.mime).toBe("");
|
||||
expect(noteAfterMigration?.isProtected).toBe(true); // Should remain protected
|
||||
|
||||
// Check if content migration worked or was skipped for protected notes
|
||||
const viewConfigAttachments = noteAfterMigration?.getAttachmentsByRole("viewConfig") || [];
|
||||
|
||||
// Document the behavior - either content was migrated or it was skipped
|
||||
if (viewConfigAttachments.length > 0) {
|
||||
const attachment = viewConfigAttachments[0];
|
||||
expect(attachment.title).toBe("geoMap.json");
|
||||
console.log("Protected note content was successfully migrated to attachment");
|
||||
} else {
|
||||
console.log("Protected note content migration was skipped (expected behavior)");
|
||||
}
|
||||
|
||||
// Template relation should still be set regardless
|
||||
const templateRelations = noteAfterMigration?.getRelations("template") || [];
|
||||
expect(templateRelations).toHaveLength(1);
|
||||
expect(templateRelations[0].value).toBe("_template_geo_map");
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -21,25 +21,28 @@ export default () => {
|
||||
note.mime = "";
|
||||
note.save();
|
||||
|
||||
const content = note.getContent();
|
||||
if (content) {
|
||||
const title = "geoMap.json";
|
||||
const existingAttachment = note.getAttachmentsByRole("viewConfig")
|
||||
.filter(a => a.title === title)[0];
|
||||
if (existingAttachment) {
|
||||
existingAttachment.setContent(content);
|
||||
} else {
|
||||
note.saveAttachment({
|
||||
role: "viewConfig",
|
||||
title,
|
||||
mime: "application/json",
|
||||
content,
|
||||
position: 0
|
||||
});
|
||||
}
|
||||
if (!note.isProtected) {
|
||||
const content = note.getContent();
|
||||
if (content) {
|
||||
const title = "geoMap.json";
|
||||
const existingAttachment = note.getAttachmentsByRole("viewConfig")
|
||||
.filter(a => a.title === title)[0];
|
||||
if (existingAttachment) {
|
||||
existingAttachment.setContent(content);
|
||||
} else {
|
||||
note.saveAttachment({
|
||||
role: "viewConfig",
|
||||
title,
|
||||
mime: "application/json",
|
||||
content,
|
||||
position: 0
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
note.setContent("");
|
||||
}
|
||||
note.setContent("");
|
||||
|
||||
note.setRelation("template", "_template_geo_map");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
|
||||
// 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
|
||||
// Reset all mocks for clean state
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import options service to access mock
|
||||
@@ -449,33 +449,10 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming with note mentions", async () => {
|
||||
// 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, {});
|
||||
});
|
||||
|
||||
// This test simply verifies that the endpoint accepts note mentions
|
||||
// and returns the expected success response for streaming initiation
|
||||
const response = await supertest(app)
|
||||
.post(`/api/llm/chat/${testChatId}/messages/stream`)
|
||||
|
||||
.send({
|
||||
content: "Tell me about this note",
|
||||
useAdvancedContext: true,
|
||||
@@ -493,16 +470,6 @@ 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 () => {
|
||||
|
||||
75
apps/server/src/routes/api/ocr.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
612
apps/server/src/routes/api/ocr.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
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,7 +108,13 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"ollamaBaseUrl",
|
||||
"ollamaDefaultModel",
|
||||
"mfaEnabled",
|
||||
"mfaMethod"
|
||||
"mfaMethod",
|
||||
|
||||
// OCR options
|
||||
"ocrEnabled",
|
||||
"ocrLanguage",
|
||||
"ocrAutoProcessImages",
|
||||
"ocrMinConfidence"
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
|
||||
@@ -58,6 +58,7 @@ 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";
|
||||
@@ -385,6 +386,16 @@ 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 = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
const APP_DB_VERSION = 234;
|
||||
const SYNC_VERSION = 37;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -378,4 +378,10 @@ describe("Markdown export", () => {
|
||||
expect(markdownExportService.toMarkdown(html)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves superscript and subscript", () => {
|
||||
const html = /*html*/`<p>Hello <sup><strong>superscript</strong></sup> <sub><strong>subscript</strong></sub></p>`;
|
||||
const expected = `Hello <sup><strong>superscript</strong></sup> <sub><strong>subscript</strong></sub>`;
|
||||
expect(markdownExportService.toMarkdown(html)).toBe(expected);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ function toMarkdown(content: string) {
|
||||
instance.addRule("math", buildMathFilter());
|
||||
instance.addRule("li", buildListItemFilter());
|
||||
instance.use(gfm);
|
||||
instance.keep([ "kbd" ]);
|
||||
instance.keep([ "kbd", "sup", "sub" ]);
|
||||
}
|
||||
|
||||
return instance.turndown(content);
|
||||
|
||||
@@ -6,6 +6,9 @@ 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";
|
||||
@@ -137,6 +140,25 @@ 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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
105
apps/server/src/services/hidden_subtree.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import cls from "./cls.js";
|
||||
import hiddenSubtreeService from "./hidden_subtree.js";
|
||||
import sql_init from "./sql_init.js";
|
||||
import branches from "./branches.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { changeLanguage } from "./i18n.js";
|
||||
import { deferred } from "./utils.js";
|
||||
|
||||
describe("Hidden Subtree", () => {
|
||||
describe("Launcher movement persistence", () => {
|
||||
beforeAll(async () => {
|
||||
sql_init.initializeDb();
|
||||
await sql_init.dbReady;
|
||||
cls.init(() => hiddenSubtreeService.checkHiddenSubtree());
|
||||
});
|
||||
|
||||
it("should persist launcher movement between visible and available after integrity check", () => {
|
||||
// Move backend log to visible launchers.
|
||||
const backendLogBranch = becca.getBranchFromChildAndParent("_lbBackendLog", "_lbAvailableLaunchers");
|
||||
expect(backendLogBranch).toBeDefined();
|
||||
|
||||
// Move launcher to visible launchers.
|
||||
cls.init(() => {
|
||||
branches.moveBranchToNote(backendLogBranch!, "_lbVisibleLaunchers");
|
||||
hiddenSubtreeService.checkHiddenSubtree();
|
||||
});
|
||||
|
||||
// Ensure the launcher is still in visible launchers.
|
||||
const childBranches = backendLogBranch?.childNote.getParentBranches()
|
||||
.filter((b) => !b.isDeleted);
|
||||
expect(childBranches).toBeDefined();
|
||||
expect(childBranches![0].parentNoteId).toStrictEqual("_lbVisibleLaunchers");
|
||||
});
|
||||
|
||||
it("should enforce the correct placement of help", () => {
|
||||
// First, verify the help note exists in its original correct location
|
||||
const originalBranch = becca.getBranchFromChildAndParent("_help_Vc8PjrjAGuOp", "_help_gh7bpGYxajRS");
|
||||
expect(originalBranch).toBeDefined();
|
||||
expect(originalBranch?.parentNoteId).toBe("_help_gh7bpGYxajRS");
|
||||
|
||||
// Move the help note to an incorrect location (_help root instead of its proper parent)
|
||||
cls.init(() => {
|
||||
branches.moveBranchToNote(originalBranch!, "_help");
|
||||
});
|
||||
|
||||
// Verify the note was moved to the wrong location
|
||||
const movedBranches = becca.notes["_help_Vc8PjrjAGuOp"]?.getParentBranches()
|
||||
.filter((b) => !b.isDeleted);
|
||||
expect(movedBranches).toBeDefined();
|
||||
expect(movedBranches![0].parentNoteId).toBe("_help");
|
||||
|
||||
// Run the hidden subtree integrity check
|
||||
cls.init(() => {
|
||||
hiddenSubtreeService.checkHiddenSubtree(true);
|
||||
});
|
||||
|
||||
// Verify that the integrity check moved the help note back to its correct location
|
||||
const correctedBranches = becca.notes["_help_Vc8PjrjAGuOp"]?.getParentBranches()
|
||||
.filter((b) => !b.isDeleted);
|
||||
expect(correctedBranches).toBeDefined();
|
||||
expect(correctedBranches![0].parentNoteId).toBe("_help_gh7bpGYxajRS");
|
||||
|
||||
// Ensure the note is no longer under the incorrect parent
|
||||
const helpRootChildren = becca.notes["_help"]?.getChildNotes();
|
||||
const incorrectChild = helpRootChildren?.find(note => note.noteId === "_help_Vc8PjrjAGuOp");
|
||||
expect(incorrectChild).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces renames of launcher notes", () => {
|
||||
const jumpToNote = becca.getNote("_lbJumpTo");
|
||||
expect(jumpToNote).toBeDefined();
|
||||
jumpToNote!.title = "Renamed";
|
||||
|
||||
cls.init(() => {
|
||||
jumpToNote!.save();
|
||||
hiddenSubtreeService.checkHiddenSubtree(true);
|
||||
});
|
||||
|
||||
const updatedJumpToNote = becca.getNote("_lbJumpTo");
|
||||
expect(updatedJumpToNote).toBeDefined();
|
||||
expect(updatedJumpToNote?.title).not.toBe("Renamed");
|
||||
});
|
||||
|
||||
it("can restore names in all languages", async () => {
|
||||
const done = deferred<void>();
|
||||
cls.wrap(async () => {
|
||||
for (const locale of LOCALES) {
|
||||
if (locale.contentOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await changeLanguage(locale.id);
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
}
|
||||
done.resolve();
|
||||
})();
|
||||
await done;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,15 @@ import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
|
||||
import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
|
||||
import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
|
||||
|
||||
const LBTPL_ROOT = "_lbTplRoot";
|
||||
const LBTPL_BASE = "_lbTplBase";
|
||||
const LBTPL_HEADER = "_lbTplHeader";
|
||||
const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote";
|
||||
const LBTPL_WIDGET = "_lbTplLauncherWidget";
|
||||
const LBTPL_COMMAND = "_lbTplLauncherCommand";
|
||||
const LBTPL_SCRIPT = "_lbTplLauncherScript";
|
||||
const LBTPL_SPACER = "_lbTplSpacer";
|
||||
const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget";
|
||||
export const LBTPL_ROOT = "_lbTplRoot";
|
||||
export const LBTPL_BASE = "_lbTplBase";
|
||||
export const LBTPL_HEADER = "_lbTplHeader";
|
||||
export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote";
|
||||
export const LBTPL_WIDGET = "_lbTplLauncherWidget";
|
||||
export const LBTPL_COMMAND = "_lbTplLauncherCommand";
|
||||
export const LBTPL_SCRIPT = "_lbTplLauncherScript";
|
||||
export const LBTPL_SPACER = "_lbTplSpacer";
|
||||
export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget";
|
||||
|
||||
/*
|
||||
* Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always
|
||||
@@ -369,16 +369,18 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
|
||||
// Clean up any branches that shouldn't exist according to the meta definition
|
||||
// For hidden subtree notes, we want to ensure they only exist in their designated locations
|
||||
const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
|
||||
const currentBranches = note.getParentBranches();
|
||||
if (item.enforceBranches || item.id.startsWith("_help")) {
|
||||
const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
|
||||
const currentBranches = note.getParentBranches();
|
||||
|
||||
for (const currentBranch of currentBranches) {
|
||||
// Only delete branches that are not in the expected locations
|
||||
// and are within the hidden subtree structure (avoid touching user-created clones)
|
||||
if (!expectedParents.includes(currentBranch.parentNoteId) &&
|
||||
isWithinHiddenSubtree(currentBranch.parentNoteId)) {
|
||||
log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`);
|
||||
currentBranch.markAsDeleted();
|
||||
for (const currentBranch of currentBranches) {
|
||||
// Only delete branches that are not in the expected locations
|
||||
// and are within the hidden subtree structure (avoid touching user-created clones)
|
||||
if (!expectedParents.includes(currentBranch.parentNoteId) &&
|
||||
isWithinHiddenSubtree(currentBranch.parentNoteId)) {
|
||||
log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`);
|
||||
currentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,7 +413,8 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
}
|
||||
}
|
||||
|
||||
if ((extraOpts.restoreNames || note.noteId.startsWith("_help")) && note.title !== item.title) {
|
||||
const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb");
|
||||
if (shouldRestoreNames && note.title !== item.title) {
|
||||
note.title = item.title;
|
||||
note.save();
|
||||
}
|
||||
@@ -465,13 +468,5 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
}
|
||||
|
||||
export default {
|
||||
checkHiddenSubtree,
|
||||
LBTPL_ROOT,
|
||||
LBTPL_BASE,
|
||||
LBTPL_COMMAND,
|
||||
LBTPL_NOTE_LAUNCHER,
|
||||
LBTPL_WIDGET,
|
||||
LBTPL_SCRIPT,
|
||||
LBTPL_SPACER,
|
||||
LBTPL_CUSTOM_WIDGET
|
||||
checkHiddenSubtree
|
||||
};
|
||||
|
||||
@@ -12,8 +12,9 @@ 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) {
|
||||
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean, noteId?: string) {
|
||||
const compressImages = optionService.getOptionBool("compressImages");
|
||||
const origImageFormat = await getImageType(uploadBuffer);
|
||||
|
||||
@@ -24,6 +25,42 @@ 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;
|
||||
|
||||
@@ -72,7 +109,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).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, true, noteId).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
note.save();
|
||||
@@ -108,7 +145,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).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, shrinkImageSwitch, note.noteId).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
|
||||
@@ -159,7 +196,7 @@ function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalNam
|
||||
}, 5000);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch, attachment.attachmentId).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
// re-read, might be changed in the meantime
|
||||
if (!attachment.attachmentId) {
|
||||
|
||||
@@ -299,4 +299,10 @@ $$`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("preserves superscript and subscript", () => {
|
||||
const input = `Hello <sup>superscript</sup> <sub>subscript</sub>`;
|
||||
const expected = /*html*/`<p>Hello <sup>superscript</sup> <sub>subscript</sub></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import optionService from "./options.js";
|
||||
import log from "./log.js";
|
||||
import { isElectron, isMac } from "./utils.js";
|
||||
import type { KeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons";
|
||||
import { t } from "i18next";
|
||||
|
||||
function getDefaultKeyboardActions() {
|
||||
@@ -17,6 +17,8 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
{
|
||||
actionName: "backInNoteHistory",
|
||||
friendlyName: t("keyboard_action_names.back-in-note-history"),
|
||||
iconClass: "bx bxs-chevron-left",
|
||||
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
|
||||
defaultShortcuts: isMac ? ["CommandOrControl+Left"] : ["Alt+Left"],
|
||||
description: t("keyboard_actions.back-in-note-history"),
|
||||
@@ -24,6 +26,8 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
{
|
||||
actionName: "forwardInNoteHistory",
|
||||
friendlyName: t("keyboard_action_names.forward-in-note-history"),
|
||||
iconClass: "bx bxs-chevron-right",
|
||||
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
|
||||
defaultShortcuts: isMac ? ["CommandOrControl+Right"] : ["Alt+Right"],
|
||||
description: t("keyboard_actions.forward-in-note-history"),
|
||||
@@ -31,48 +35,72 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
{
|
||||
actionName: "jumpToNote",
|
||||
friendlyName: t("keyboard_action_names.jump-to-note"),
|
||||
defaultShortcuts: ["CommandOrControl+J"],
|
||||
description: t("keyboard_actions.open-jump-to-note-dialog"),
|
||||
scope: "window"
|
||||
scope: "window",
|
||||
ignoreFromCommandPalette: true
|
||||
},
|
||||
{
|
||||
actionName: "commandPalette",
|
||||
friendlyName: t("keyboard_action_names.command-palette"),
|
||||
defaultShortcuts: ["CommandOrControl+Shift+J"],
|
||||
description: t("keyboard_actions.open-command-palette"),
|
||||
scope: "window",
|
||||
ignoreFromCommandPalette: true
|
||||
},
|
||||
{
|
||||
actionName: "scrollToActiveNote",
|
||||
friendlyName: t("keyboard_action_names.scroll-to-active-note"),
|
||||
defaultShortcuts: ["CommandOrControl+."],
|
||||
iconClass: "bx bx-current-location",
|
||||
description: t("keyboard_actions.scroll-to-active-note"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "quickSearch",
|
||||
friendlyName: t("keyboard_action_names.quick-search"),
|
||||
iconClass: "bx bx-search",
|
||||
defaultShortcuts: ["CommandOrControl+S"],
|
||||
description: t("keyboard_actions.quick-search"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "searchInSubtree",
|
||||
friendlyName: t("keyboard_action_names.search-in-subtree"),
|
||||
defaultShortcuts: ["CommandOrControl+Shift+S"],
|
||||
iconClass: "bx bx-search-alt",
|
||||
description: t("keyboard_actions.search-in-subtree"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "expandSubtree",
|
||||
friendlyName: t("keyboard_action_names.expand-subtree"),
|
||||
defaultShortcuts: [],
|
||||
iconClass: "bx bx-layer-plus",
|
||||
description: t("keyboard_actions.expand-subtree"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "collapseTree",
|
||||
friendlyName: t("keyboard_action_names.collapse-tree"),
|
||||
defaultShortcuts: ["Alt+C"],
|
||||
iconClass: "bx bx-layer-minus",
|
||||
description: t("keyboard_actions.collapse-tree"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "collapseSubtree",
|
||||
friendlyName: t("keyboard_action_names.collapse-subtree"),
|
||||
iconClass: "bx bxs-layer-minus",
|
||||
defaultShortcuts: ["Alt+-"],
|
||||
description: t("keyboard_actions.collapse-subtree"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "sortChildNotes",
|
||||
friendlyName: t("keyboard_action_names.sort-child-notes"),
|
||||
iconClass: "bx bx-sort-down",
|
||||
defaultShortcuts: ["Alt+S"],
|
||||
description: t("keyboard_actions.sort-child-notes"),
|
||||
scope: "note-tree"
|
||||
@@ -83,72 +111,96 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
{
|
||||
actionName: "createNoteAfter",
|
||||
friendlyName: t("keyboard_action_names.create-note-after"),
|
||||
iconClass: "bx bx-plus",
|
||||
defaultShortcuts: ["CommandOrControl+O"],
|
||||
description: t("keyboard_actions.create-note-after"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "createNoteInto",
|
||||
friendlyName: t("keyboard_action_names.create-note-into"),
|
||||
iconClass: "bx bx-plus",
|
||||
defaultShortcuts: ["CommandOrControl+P"],
|
||||
description: t("keyboard_actions.create-note-into"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "createNoteIntoInbox",
|
||||
friendlyName: t("keyboard_action_names.create-note-into-inbox"),
|
||||
iconClass: "bx bxs-inbox",
|
||||
defaultShortcuts: ["global:CommandOrControl+Alt+P"],
|
||||
description: t("keyboard_actions.create-note-into-inbox"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "deleteNotes",
|
||||
friendlyName: t("keyboard_action_names.delete-notes"),
|
||||
iconClass: "bx bx-trash",
|
||||
defaultShortcuts: ["Delete"],
|
||||
description: t("keyboard_actions.delete-note"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "moveNoteUp",
|
||||
friendlyName: t("keyboard_action_names.move-note-up"),
|
||||
iconClass: "bx bx-up-arrow-alt",
|
||||
defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"],
|
||||
description: t("keyboard_actions.move-note-up"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "moveNoteDown",
|
||||
friendlyName: t("keyboard_action_names.move-note-down"),
|
||||
iconClass: "bx bx-down-arrow-alt",
|
||||
defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"],
|
||||
description: t("keyboard_actions.move-note-down"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "moveNoteUpInHierarchy",
|
||||
friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"),
|
||||
iconClass: "bx bx-arrow-from-bottom",
|
||||
defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"],
|
||||
description: t("keyboard_actions.move-note-up-in-hierarchy"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "moveNoteDownInHierarchy",
|
||||
friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"),
|
||||
iconClass: "bx bx-arrow-from-top",
|
||||
defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"],
|
||||
description: t("keyboard_actions.move-note-down-in-hierarchy"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "editNoteTitle",
|
||||
friendlyName: t("keyboard_action_names.edit-note-title"),
|
||||
iconClass: "bx bx-rename",
|
||||
defaultShortcuts: ["Enter"],
|
||||
description: t("keyboard_actions.edit-note-title"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "editBranchPrefix",
|
||||
friendlyName: t("keyboard_action_names.edit-branch-prefix"),
|
||||
iconClass: "bx bx-rename",
|
||||
defaultShortcuts: ["F2"],
|
||||
description: t("keyboard_actions.edit-branch-prefix"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "cloneNotesTo",
|
||||
friendlyName: t("keyboard_action_names.clone-notes-to"),
|
||||
iconClass: "bx bx-duplicate",
|
||||
defaultShortcuts: ["CommandOrControl+Shift+C"],
|
||||
description: t("keyboard_actions.clone-notes-to"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "moveNotesTo",
|
||||
friendlyName: t("keyboard_action_names.move-notes-to"),
|
||||
iconClass: "bx bx-transfer",
|
||||
defaultShortcuts: ["CommandOrControl+Shift+X"],
|
||||
description: t("keyboard_actions.move-notes-to"),
|
||||
scope: "window"
|
||||
@@ -160,42 +212,56 @@ function getDefaultKeyboardActions() {
|
||||
|
||||
{
|
||||
actionName: "copyNotesToClipboard",
|
||||
friendlyName: t("keyboard_action_names.copy-notes-to-clipboard"),
|
||||
iconClass: "bx bx-copy",
|
||||
defaultShortcuts: ["CommandOrControl+C"],
|
||||
description: t("keyboard_actions.copy-notes-to-clipboard"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "pasteNotesFromClipboard",
|
||||
friendlyName: t("keyboard_action_names.paste-notes-from-clipboard"),
|
||||
iconClass: "bx bx-paste",
|
||||
defaultShortcuts: ["CommandOrControl+V"],
|
||||
description: t("keyboard_actions.paste-notes-from-clipboard"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "cutNotesToClipboard",
|
||||
friendlyName: t("keyboard_action_names.cut-notes-to-clipboard"),
|
||||
iconClass: "bx bx-cut",
|
||||
defaultShortcuts: ["CommandOrControl+X"],
|
||||
description: t("keyboard_actions.cut-notes-to-clipboard"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "selectAllNotesInParent",
|
||||
friendlyName: t("keyboard_action_names.select-all-notes-in-parent"),
|
||||
iconClass: "bx bx-select-multiple",
|
||||
defaultShortcuts: ["CommandOrControl+A"],
|
||||
description: t("keyboard_actions.select-all-notes-in-parent"),
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "addNoteAboveToSelection",
|
||||
friendlyName: t("keyboard_action_names.add-note-above-to-selection"),
|
||||
defaultShortcuts: ["Shift+Up"],
|
||||
description: t("keyboard_actions.add-note-above-to-the-selection"),
|
||||
scope: "note-tree"
|
||||
scope: "note-tree",
|
||||
ignoreFromCommandPalette: true
|
||||
},
|
||||
{
|
||||
actionName: "addNoteBelowToSelection",
|
||||
friendlyName: t("keyboard_action_names.add-note-below-to-selection"),
|
||||
defaultShortcuts: ["Shift+Down"],
|
||||
description: t("keyboard_actions.add-note-below-to-selection"),
|
||||
scope: "note-tree"
|
||||
scope: "note-tree",
|
||||
ignoreFromCommandPalette: true
|
||||
},
|
||||
{
|
||||
actionName: "duplicateSubtree",
|
||||
friendlyName: t("keyboard_action_names.duplicate-subtree"),
|
||||
iconClass: "bx bx-outline",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.duplicate-subtree"),
|
||||
scope: "note-tree"
|
||||
@@ -206,109 +272,147 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
{
|
||||
actionName: "openNewTab",
|
||||
friendlyName: t("keyboard_action_names.open-new-tab"),
|
||||
iconClass: "bx bx-plus",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [],
|
||||
description: t("keyboard_actions.open-new-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "closeActiveTab",
|
||||
friendlyName: t("keyboard_action_names.close-active-tab"),
|
||||
iconClass: "bx bx-minus",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [],
|
||||
description: t("keyboard_actions.close-active-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "reopenLastTab",
|
||||
friendlyName: t("keyboard_action_names.reopen-last-tab"),
|
||||
iconClass: "bx bx-undo",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [],
|
||||
isElectronOnly: true,
|
||||
description: t("keyboard_actions.reopen-last-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "activateNextTab",
|
||||
friendlyName: t("keyboard_action_names.activate-next-tab"),
|
||||
iconClass: "bx bx-skip-next",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [],
|
||||
description: t("keyboard_actions.activate-next-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "activatePreviousTab",
|
||||
friendlyName: t("keyboard_action_names.activate-previous-tab"),
|
||||
iconClass: "bx bx-skip-previous",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [],
|
||||
description: t("keyboard_actions.activate-previous-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "openNewWindow",
|
||||
friendlyName: t("keyboard_action_names.open-new-window"),
|
||||
iconClass: "bx bx-window-open",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.open-new-window"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleTray",
|
||||
friendlyName: t("keyboard_action_names.toggle-system-tray-icon"),
|
||||
iconClass: "bx bx-show",
|
||||
defaultShortcuts: [],
|
||||
isElectronOnly: true,
|
||||
description: t("keyboard_actions.toggle-tray"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleZenMode",
|
||||
friendlyName: t("keyboard_action_names.toggle-zen-mode"),
|
||||
iconClass: "bx bxs-yin-yang",
|
||||
defaultShortcuts: ["F9"],
|
||||
description: t("keyboard_actions.toggle-zen-mode"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "firstTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-first-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+1"],
|
||||
description: t("keyboard_actions.first-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "secondTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-second-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+2"],
|
||||
description: t("keyboard_actions.second-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "thirdTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-third-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+3"],
|
||||
description: t("keyboard_actions.third-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "fourthTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-fourth-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+4"],
|
||||
description: t("keyboard_actions.fourth-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "fifthTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-fifth-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+5"],
|
||||
description: t("keyboard_actions.fifth-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "sixthTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-sixth-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+6"],
|
||||
description: t("keyboard_actions.sixth-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "seventhTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-seventh-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+7"],
|
||||
description: t("keyboard_actions.seventh-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "eigthTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-eighth-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+8"],
|
||||
description: t("keyboard_actions.eight-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "ninthTab",
|
||||
friendlyName: t("keyboard_action_names.switch-to-ninth-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+9"],
|
||||
description: t("keyboard_actions.ninth-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "lastTab",
|
||||
defaultShortcuts: [],
|
||||
friendlyName: t("keyboard_action_names.switch-to-last-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+0"],
|
||||
description: t("keyboard_actions.last-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
@@ -317,49 +421,65 @@ function getDefaultKeyboardActions() {
|
||||
separator: t("keyboard_actions.dialogs")
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-note-source"),
|
||||
actionName: "showNoteSource",
|
||||
iconClass: "bx bx-code",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.show-note-source"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-options"),
|
||||
actionName: "showOptions",
|
||||
iconClass: "bx bx-cog",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.show-options"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-revisions"),
|
||||
actionName: "showRevisions",
|
||||
iconClass: "bx bx-history",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.show-revisions"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-recent-changes"),
|
||||
actionName: "showRecentChanges",
|
||||
iconClass: "bx bx-history",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.show-recent-changes"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-sql-console"),
|
||||
actionName: "showSQLConsole",
|
||||
iconClass: "bx bx-data",
|
||||
defaultShortcuts: ["Alt+O"],
|
||||
description: t("keyboard_actions.show-sql-console"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-backend-log"),
|
||||
actionName: "showBackendLog",
|
||||
iconClass: "bx bx-detail",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.show-backend-log"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-help"),
|
||||
actionName: "showHelp",
|
||||
iconClass: "bx bx-help-circle",
|
||||
defaultShortcuts: ["F1"],
|
||||
description: t("keyboard_actions.show-help"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.show-cheatsheet"),
|
||||
actionName: "showCheatsheet",
|
||||
iconClass: "bx bxs-keyboard",
|
||||
defaultShortcuts: ["Shift+F1"],
|
||||
description: t("keyboard_actions.show-cheatsheet"),
|
||||
scope: "window"
|
||||
@@ -370,43 +490,57 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.add-link-to-text"),
|
||||
actionName: "addLinkToText",
|
||||
iconClass: "bx bx-link",
|
||||
defaultShortcuts: ["CommandOrControl+L"],
|
||||
description: t("keyboard_actions.add-link-to-text"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.follow-link-under-cursor"),
|
||||
actionName: "followLinkUnderCursor",
|
||||
iconClass: "bx bx-link-external",
|
||||
defaultShortcuts: ["CommandOrControl+Enter"],
|
||||
description: t("keyboard_actions.follow-link-under-cursor"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.insert-date-and-time-to-text"),
|
||||
actionName: "insertDateTimeToText",
|
||||
iconClass: "bx bx-calendar-event",
|
||||
defaultShortcuts: ["Alt+T"],
|
||||
description: t("keyboard_actions.insert-date-and-time-to-text"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.paste-markdown-into-text"),
|
||||
actionName: "pasteMarkdownIntoText",
|
||||
iconClass: "bx bxl-markdown",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.paste-markdown-into-text"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.cut-into-note"),
|
||||
actionName: "cutIntoNote",
|
||||
iconClass: "bx bx-cut",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.cut-into-note"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.add-include-note-to-text"),
|
||||
actionName: "addIncludeNoteToText",
|
||||
iconClass: "bx bx-note",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.add-include-note-to-text"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.edit-read-only-note"),
|
||||
actionName: "editReadOnlyNote",
|
||||
iconClass: "bx bx-edit-alt",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.edit-readonly-note"),
|
||||
scope: "window"
|
||||
@@ -417,13 +551,17 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.add-new-label"),
|
||||
actionName: "addNewLabel",
|
||||
iconClass: "bx bx-hash",
|
||||
defaultShortcuts: ["Alt+L"],
|
||||
description: t("keyboard_actions.add-new-label"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.add-new-relation"),
|
||||
actionName: "addNewRelation",
|
||||
iconClass: "bx bx-transfer",
|
||||
defaultShortcuts: ["Alt+R"],
|
||||
description: t("keyboard_actions.create-new-relation"),
|
||||
scope: "window"
|
||||
@@ -434,43 +572,57 @@ function getDefaultKeyboardActions() {
|
||||
},
|
||||
|
||||
{
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-classic-editor"),
|
||||
actionName: "toggleRibbonTabClassicEditor",
|
||||
iconClass: "bx bx-text",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-classic-editor-toolbar"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabBasicProperties",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-basic-properties"),
|
||||
iconClass: "bx bx-slider",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-basic-properties"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabBookProperties",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-book-properties"),
|
||||
iconClass: "bx bx-book",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-book-properties"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabFileProperties",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-file-properties"),
|
||||
iconClass: "bx bx-file",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-file-properties"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabImageProperties",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-image-properties"),
|
||||
iconClass: "bx bx-image",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-image-properties"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabOwnedAttributes",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-owned-attributes"),
|
||||
iconClass: "bx bx-list-check",
|
||||
defaultShortcuts: ["Alt+A"],
|
||||
description: t("keyboard_actions.toggle-owned-attributes"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabInheritedAttributes",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-inherited-attributes"),
|
||||
iconClass: "bx bx-list-plus",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-inherited-attributes"),
|
||||
scope: "window"
|
||||
@@ -478,30 +630,40 @@ function getDefaultKeyboardActions() {
|
||||
// TODO: Remove or change since promoted attributes have been changed.
|
||||
{
|
||||
actionName: "toggleRibbonTabPromotedAttributes",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-promoted-attributes"),
|
||||
iconClass: "bx bx-star",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-promoted-attributes"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabNoteMap",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-map"),
|
||||
iconClass: "bx bxs-network-chart",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-link-map"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabNoteInfo",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-info"),
|
||||
iconClass: "bx bx-info-circle",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-note-info"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabNotePaths",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-paths"),
|
||||
iconClass: "bx bx-collection",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-note-paths"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleRibbonTabSimilarNotes",
|
||||
friendlyName: t("keyboard_action_names.toggle-ribbon-tab-similar-notes"),
|
||||
iconClass: "bx bx-bar-chart",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-similar-notes"),
|
||||
scope: "window"
|
||||
@@ -513,108 +675,148 @@ function getDefaultKeyboardActions() {
|
||||
|
||||
{
|
||||
actionName: "toggleRightPane",
|
||||
friendlyName: t("keyboard_action_names.toggle-right-pane"),
|
||||
iconClass: "bx bx-dock-right",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-right-pane"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "printActiveNote",
|
||||
friendlyName: t("keyboard_action_names.print-active-note"),
|
||||
iconClass: "bx bx-printer",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.print-active-note"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "exportAsPdf",
|
||||
friendlyName: t("keyboard_action_names.export-active-note-as-pdf"),
|
||||
iconClass: "bx bxs-file-pdf",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.export-as-pdf"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "openNoteExternally",
|
||||
friendlyName: t("keyboard_action_names.open-note-externally"),
|
||||
iconClass: "bx bx-file-find",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.open-note-externally"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "renderActiveNote",
|
||||
friendlyName: t("keyboard_action_names.render-active-note"),
|
||||
iconClass: "bx bx-refresh",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.render-active-note"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "runActiveNote",
|
||||
friendlyName: t("keyboard_action_names.run-active-note"),
|
||||
iconClass: "bx bx-play",
|
||||
defaultShortcuts: ["CommandOrControl+Enter"],
|
||||
description: t("keyboard_actions.run-active-note"),
|
||||
scope: "code-detail"
|
||||
},
|
||||
{
|
||||
actionName: "toggleNoteHoisting",
|
||||
friendlyName: t("keyboard_action_names.toggle-note-hoisting"),
|
||||
iconClass: "bx bx-chevrons-up",
|
||||
defaultShortcuts: ["Alt+H"],
|
||||
description: t("keyboard_actions.toggle-note-hoisting"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "unhoist",
|
||||
friendlyName: t("keyboard_action_names.unhoist-note"),
|
||||
iconClass: "bx bx-door-open",
|
||||
defaultShortcuts: ["Alt+U"],
|
||||
description: t("keyboard_actions.unhoist"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "reloadFrontendApp",
|
||||
friendlyName: t("keyboard_action_names.reload-frontend-app"),
|
||||
iconClass: "bx bx-refresh",
|
||||
defaultShortcuts: ["F5", "CommandOrControl+R"],
|
||||
description: t("keyboard_actions.reload-frontend-app"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "openDevTools",
|
||||
friendlyName: t("keyboard_action_names.open-developer-tools"),
|
||||
iconClass: "bx bx-bug-alt",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [],
|
||||
isElectronOnly: true,
|
||||
description: t("keyboard_actions.open-dev-tools"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "findInText",
|
||||
friendlyName: t("keyboard_action_names.find-in-text"),
|
||||
iconClass: "bx bx-search",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [],
|
||||
description: t("keyboard_actions.find-in-text"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleLeftPane",
|
||||
friendlyName: t("keyboard_action_names.toggle-left-pane"),
|
||||
iconClass: "bx bx-sidebar",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.toggle-left-note-tree-panel"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "toggleFullscreen",
|
||||
friendlyName: t("keyboard_action_names.toggle-full-screen"),
|
||||
iconClass: "bx bx-fullscreen",
|
||||
defaultShortcuts: ["F11"],
|
||||
description: t("keyboard_actions.toggle-full-screen"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "zoomOut",
|
||||
friendlyName: t("keyboard_action_names.zoom-out"),
|
||||
iconClass: "bx bx-zoom-out",
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [],
|
||||
isElectronOnly: true,
|
||||
description: t("keyboard_actions.zoom-out"),
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "zoomIn",
|
||||
friendlyName: t("keyboard_action_names.zoom-in"),
|
||||
iconClass: "bx bx-zoom-in",
|
||||
description: t("keyboard_actions.zoom-in"),
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+="] : [],
|
||||
isElectronOnly: true,
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "zoomReset",
|
||||
friendlyName: t("keyboard_action_names.reset-zoom-level"),
|
||||
iconClass: "bx bx-search-alt",
|
||||
description: t("keyboard_actions.reset-zoom-level"),
|
||||
defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [],
|
||||
isElectronOnly: true,
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
actionName: "copyWithoutFormatting",
|
||||
friendlyName: t("keyboard_action_names.copy-without-formatting"),
|
||||
iconClass: "bx bx-copy-alt",
|
||||
defaultShortcuts: ["CommandOrControl+Alt+C"],
|
||||
description: t("keyboard_actions.copy-without-formatting"),
|
||||
scope: "text-detail"
|
||||
},
|
||||
{
|
||||
actionName: "forceSaveRevision",
|
||||
friendlyName: t("keyboard_action_names.force-save-revision"),
|
||||
iconClass: "bx bx-save",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.force-save-revision"),
|
||||
scope: "window"
|
||||
@@ -627,7 +829,7 @@ function getDefaultKeyboardActions() {
|
||||
const platformModifier = isMac ? "Meta" : "Ctrl";
|
||||
|
||||
for (const action of DEFAULT_KEYBOARD_ACTIONS) {
|
||||
if (action.defaultShortcuts) {
|
||||
if ("defaultShortcuts" in action && action.defaultShortcuts) {
|
||||
action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier));
|
||||
}
|
||||
}
|
||||
@@ -639,7 +841,9 @@ function getKeyboardActions() {
|
||||
const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions()));
|
||||
|
||||
for (const action of actions) {
|
||||
action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : [];
|
||||
if ("effectiveShortcuts" in action && action.effectiveShortcuts) {
|
||||
action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const option of optionService.getOptions()) {
|
||||
@@ -647,7 +851,7 @@ function getKeyboardActions() {
|
||||
let actionName = option.name.substring(17);
|
||||
actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1);
|
||||
|
||||
const action = actions.find((ea) => ea.actionName === actionName);
|
||||
const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut;
|
||||
|
||||
if (action) {
|
||||
try {
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('ChatStorageService', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
chatStorageService = new ChatStorageService();
|
||||
|
||||
|
||||
// Get mocked modules
|
||||
mockNotes = (await import('../notes.js')).default;
|
||||
mockSql = (await import('../sql.js')).default;
|
||||
@@ -177,7 +177,7 @@ describe('ChatStorageService', () => {
|
||||
const result = await chatStorageService.createChat('');
|
||||
|
||||
expect(result.title).toContain('New Chat');
|
||||
expect(result.title).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); // Date pattern
|
||||
expect(result.title).toMatch(/\d{4}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -622,4 +622,4 @@ describe('ChatStorageService', () => {
|
||||
expect(toolExecutions[0].arguments).toEqual({ query: 'existing' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
916
apps/server/src/services/ocr/ocr_service.spec.ts
Normal file
@@ -0,0 +1,916 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
752
apps/server/src/services/ocr/ocr_service.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
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();
|
||||
33
apps/server/src/services/ocr/processors/file_processor.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
237
apps/server/src/services/ocr/processors/image_processor.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
132
apps/server/src/services/ocr/processors/office_processor.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
147
apps/server/src/services/ocr/processors/pdf_processor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
134
apps/server/src/services/ocr/processors/tiff_processor.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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,6 +211,12 @@ 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 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -251,7 +257,7 @@ function initStartupOptions() {
|
||||
}
|
||||
|
||||
function getKeyboardDefaultOptions() {
|
||||
return (keyboardActions.getDefaultKeyboardActions().filter((ka) => !!ka.actionName) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({
|
||||
return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({
|
||||
name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`,
|
||||
value: JSON.stringify(ka.defaultShortcuts),
|
||||
isSynced: false
|
||||
|
||||
111
apps/server/src/services/search/expressions/ocr_content.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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,6 +2,8 @@
|
||||
|
||||
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[];
|
||||
@@ -48,6 +50,9 @@ 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
|
||||
}
|
||||
@@ -70,6 +75,37 @@ 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;
|
||||
|
||||
337
apps/server/src/services/search/search_result_ocr.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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,6 +20,7 @@ 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";
|
||||
@@ -33,11 +34,20 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchExpressions: Expression[] = [
|
||||
new NoteFlatTextExp(tokens)
|
||||
];
|
||||
|
||||
if (!searchContext.fastSearch) {
|
||||
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp("*=*", { tokens, flatText: true })]);
|
||||
} else {
|
||||
return new NoteFlatTextExp(tokens);
|
||||
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(searchExpressions);
|
||||
}
|
||||
|
||||
const OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", ">", ">=", "<", "<=", "%="]);
|
||||
|
||||
@@ -7,10 +7,9 @@ import log from "./log.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import searchService from "./search/services/search.js";
|
||||
import SearchContext from "./search/search_context.js";
|
||||
import hiddenSubtree from "./hidden_subtree.js";
|
||||
import { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
|
||||
import { t } from "i18next";
|
||||
import { BNote } from "./backend_script_entrypoint.js";
|
||||
const { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } = hiddenSubtree;
|
||||
|
||||
function getInboxNote(date: string) {
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
|
||||
@@ -14,12 +14,11 @@ import type { OptionRow } from "@triliumnext/commons";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import zipImportService from "./import/zip.js";
|
||||
import becca_loader from "../becca/becca_loader.js";
|
||||
import password from "./encryption/password.js";
|
||||
import backup from "./backup.js";
|
||||
import eventService from "./events.js";
|
||||
|
||||
const dbReady = deferred<void>();
|
||||
export const dbReady = deferred<void>();
|
||||
|
||||
function schemaExists() {
|
||||
return !!sql.getValue(/*sql*/`SELECT name FROM sqlite_master
|
||||
@@ -83,6 +82,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) {
|
||||
|
||||
// We have to import async since options init requires keyboard actions which require translations.
|
||||
const optionsInitService = (await import("./options_init.js")).default;
|
||||
const becca_loader = (await import("../becca/becca_loader.js")).default;
|
||||
|
||||
sql.transactional(() => {
|
||||
log.info("Creating database schema ...");
|
||||
|
||||
@@ -295,7 +295,7 @@ async function registerGlobalShortcuts() {
|
||||
const allActions = keyboardActionsService.getKeyboardActions();
|
||||
|
||||
for (const action of allActions) {
|
||||
if (!action.effectiveShortcuts) {
|
||||
if (!("effectiveShortcuts" in action) || !action.effectiveShortcuts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
81
docs/User Guide/!!!meta.json
vendored
@@ -3784,7 +3784,7 @@
|
||||
"wArbEsdSae6g",
|
||||
"F1r9QtzQLZqm"
|
||||
],
|
||||
"title": "Jump to Note",
|
||||
"title": "Jump to...",
|
||||
"notePosition": 50,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
@@ -3804,26 +3804,33 @@
|
||||
"value": "bx bx-send",
|
||||
"isInheritable": false,
|
||||
"position": 10
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "A9Oc6YKKc65v",
|
||||
"isInheritable": false,
|
||||
"position": 20
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "Jump to Note.md",
|
||||
"dataFileName": "Jump to.md",
|
||||
"attachments": [
|
||||
{
|
||||
"attachmentId": "7IU5WrneDsfi",
|
||||
"title": "image.png",
|
||||
"role": "image",
|
||||
"mime": "image/png",
|
||||
"position": 10,
|
||||
"dataFileName": "Jump to_image.png"
|
||||
},
|
||||
{
|
||||
"attachmentId": "P9veX5eFZdPp",
|
||||
"title": "image.png",
|
||||
"role": "image",
|
||||
"mime": "image/png",
|
||||
"position": 10,
|
||||
"dataFileName": "Jump to Note_image.png"
|
||||
},
|
||||
{
|
||||
"attachmentId": "xA1F6kynr4YU",
|
||||
"title": "recent-notes.gif",
|
||||
"role": "image",
|
||||
"mime": "image/gif",
|
||||
"position": 10,
|
||||
"dataFileName": "Jump to Note_recent-notes.gif"
|
||||
"dataFileName": "1_Jump to_image.png"
|
||||
},
|
||||
{
|
||||
"attachmentId": "y8yxomaf1Gkz",
|
||||
@@ -3831,7 +3838,7 @@
|
||||
"role": "image",
|
||||
"mime": "image/png",
|
||||
"position": 10,
|
||||
"dataFileName": "1_Jump to Note_image.png"
|
||||
"dataFileName": "2_Jump to_image.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -7869,31 +7876,45 @@
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "BlN9DFI679QC",
|
||||
"value": "CtBQqbwXDx1w",
|
||||
"isInheritable": false,
|
||||
"position": 80
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "m523cpzocqaD",
|
||||
"value": "BlN9DFI679QC",
|
||||
"isInheritable": false,
|
||||
"position": 90
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "KC1HB96bqqHX",
|
||||
"value": "oPVyFC7WL2Lp",
|
||||
"isInheritable": false,
|
||||
"position": 100
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "2mUhVmZK8RF3",
|
||||
"value": "m523cpzocqaD",
|
||||
"isInheritable": false,
|
||||
"position": 110
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "KC1HB96bqqHX",
|
||||
"isInheritable": false,
|
||||
"position": 120
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "2mUhVmZK8RF3",
|
||||
"isInheritable": false,
|
||||
"position": 130
|
||||
},
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
@@ -7907,20 +7928,6 @@
|
||||
"value": "bx bx-book",
|
||||
"isInheritable": false,
|
||||
"position": 20
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "CtBQqbwXDx1w",
|
||||
"isInheritable": false,
|
||||
"position": 120
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "oPVyFC7WL2Lp",
|
||||
"isInheritable": false,
|
||||
"position": 130
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
@@ -8530,19 +8537,19 @@
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "2FvYrpmOXm29",
|
||||
"isInheritable": false,
|
||||
"position": 10
|
||||
},
|
||||
{
|
||||
"type": "label",
|
||||
"name": "iconClass",
|
||||
"value": "bx bx-columns",
|
||||
"isInheritable": false,
|
||||
"position": 10
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "2FvYrpmOXm29",
|
||||
"isInheritable": false,
|
||||
"position": 20
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 317 B |
@@ -1,25 +0,0 @@
|
||||
# Jump to Note
|
||||
<figure class="image image-style-align-center"><img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991" height="403"></figure>
|
||||
|
||||
The _Jump to Note_ function allows easy navigation between notes by searching for their title. In addition to that, it can also trigger a full search or create notes.
|
||||
|
||||
## Entering jump to note
|
||||
|
||||
* In the <a class="reference-link" href="../UI%20Elements/Launch%20Bar.md">Launch Bar</a>, press  button.
|
||||
* Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.
|
||||
|
||||
## Recent notes
|
||||
|
||||
Jump to note also has the ability to show the list of recently viewed / edited notes and quickly jump to it.
|
||||
|
||||
To access this functionality, click on `Jump to` button on the top. By default, (when nothing is entered into autocomplete), this dialog will show the list of recent notes.
|
||||
|
||||
Alternatively you can click on the "time" icon on the right.
|
||||
|
||||
<img src="Jump to Note_recent-notes.gif" width="812" height="585">
|
||||
|
||||
## Interaction
|
||||
|
||||
* By default, when there is no text entered it will display the most recent notes.
|
||||
* Using the keyboard, use the up or down arrow keys to navigate between items. Press <kbd>Enter</kbd> to open the desired note.
|
||||
* If the note doesn't exist, it's possible to create it by typing the desired note title and selecting the _Create and link child note_ option.
|
||||
|
Before Width: | Height: | Size: 265 KiB |
62
docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to.md
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Jump to...
|
||||
<figure class="image image-style-align-center"><img style="aspect-ratio:991/403;" src="1_Jump to_image.png" width="991" height="403"></figure>
|
||||
|
||||
## Jump to Note
|
||||
|
||||
The _Jump to Note_ function allows easy navigation between notes by searching for their title. In addition to that, it can also trigger a full search or create notes.
|
||||
|
||||
To enter the “Jump to” dialog:
|
||||
|
||||
* In the <a class="reference-link" href="../UI%20Elements/Launch%20Bar.md">Launch Bar</a>, press  button.
|
||||
* Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.
|
||||
|
||||
In addition to searching for notes, it is also possible to search for commands. See the dedicated section below for more information.
|
||||
|
||||
### Interaction
|
||||
|
||||
* By default, when there is no text entered it will display the most recent notes.
|
||||
* Using the keyboard, use the up or down arrow keys to navigate between items. Press <kbd>Enter</kbd> to open the desired note.
|
||||
* If the note doesn't exist, it's possible to create it by typing the desired note title and selecting the _Create and link child note_ option.
|
||||
|
||||
## Recent notes
|
||||
|
||||
Jump to note also has the ability to show the list of recently viewed / edited notes and quickly jump to it.
|
||||
|
||||
To access this functionality, click on `Jump to` button on the top. By default, (when nothing is entered into autocomplete), this dialog will show the list of recent notes.
|
||||
|
||||
Alternatively you can click on the "time" icon on the right.
|
||||
|
||||
## Command Palette
|
||||
|
||||
<figure class="image image-style-align-center"><img style="aspect-ratio:982/524;" src="Jump to_image.png" width="982" height="524"></figure>
|
||||
|
||||
The command palette is a feature which allows easy execution of various commands that can be found throughout the application, such as from menus or keyboard shortcuts. This feature integrates directly into the “Jump to” dialog.
|
||||
|
||||
### Interaction
|
||||
|
||||
To trigger the command palette:
|
||||
|
||||
* Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to display the command palette directly.
|
||||
* If in the “Jump to” dialog, type `>` in the search to switch to the command palette.
|
||||
|
||||
Interaction:
|
||||
|
||||
* Type a few words to filter between commands.
|
||||
* Use the up and down arrows on the keyboard or the mouse to select a command.
|
||||
* Press <kbd>Enter</kbd> to execute the command.
|
||||
|
||||
To exit the command palette:
|
||||
|
||||
* Remove the `>` in the search to go back to the note search.
|
||||
* Press <kbd>Esc</kbd> to dismiss the dialog entirely.
|
||||
|
||||
### Options available
|
||||
|
||||
Currently the following options are displayed:
|
||||
|
||||
* Most of the <a class="reference-link" href="../Keyboard%20Shortcuts.md">Keyboard Shortcuts</a> have an entry, with the exception of those that are too specific to be run from a dialog.
|
||||
* Some additional options which are not yet available as keyboard shortcuts, but can be accessed from various menus such as: exporting a note, showing attachments, searching for notes or configuring the <a class="reference-link" href="../UI%20Elements/Launch%20Bar.md">Launch Bar</a>.
|
||||
|
||||
### Limitations
|
||||
|
||||
Currently it's not possible to define custom actions that are displayed in the command palette. In the future this might change by integrating the options in the launch bar, which can be customized if needed.
|
||||
BIN
docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to_image.png
vendored
Normal file
|
After Width: | Height: | Size: 69 KiB |
@@ -13,4 +13,4 @@ This works identically to browser backwards / forwards, it's actually using buil
|
||||
|
||||
This is useful to quickly find and view arbitrary notes - click on `Jump to` button on the top or press <kbd>Ctrl</kbd> + <kbd>J</kbd> . Then type part of the note name and autocomplete will help you pick the desired note.
|
||||
|
||||
See <a class="reference-link" href="Jump%20to%20Note.md">Jump to Note</a> for more information.
|
||||
See <a class="reference-link" href="Jump%20to.md">Jump to Note</a> for more information.
|
||||
@@ -5,7 +5,7 @@ The _Quick search_ function does a full-text search (that is, it searches throug
|
||||
|
||||
The alternative to the quick search is the <a class="reference-link" href="Search.md">Search</a> function, which opens in a dedicated tab and has support for advanced queries.
|
||||
|
||||
For even faster navigation, it's possible to use <a class="reference-link" href="Jump%20to%20Note.md">Jump to Note</a> which will only search through the note titles instead of the content.
|
||||
For even faster navigation, it's possible to use <a class="reference-link" href="Jump%20to.md">Jump to Note</a> which will only search through the note titles instead of the content.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
BIN
eng.traineddata
Normal file
29
package.json
@@ -27,20 +27,20 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "4.0.1",
|
||||
"@nx/devkit": "21.3.7",
|
||||
"@nx/esbuild": "21.3.7",
|
||||
"@nx/eslint": "21.3.7",
|
||||
"@nx/eslint-plugin": "21.3.7",
|
||||
"@nx/express": "21.3.7",
|
||||
"@nx/js": "21.3.7",
|
||||
"@nx/node": "21.3.7",
|
||||
"@nx/playwright": "21.3.7",
|
||||
"@nx/vite": "21.3.7",
|
||||
"@nx/web": "21.3.7",
|
||||
"@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",
|
||||
"@playwright/test": "^1.36.0",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/node": "22.17.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"chalk": "5.4.1",
|
||||
@@ -54,9 +54,9 @@
|
||||
"jiti": "2.5.1",
|
||||
"jsdom": "~26.1.0",
|
||||
"jsonc-eslint-parser": "^2.1.0",
|
||||
"nx": "21.3.7",
|
||||
"nx": "21.3.9",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rollup-plugin-webpack-stats": "2.1.0",
|
||||
"rollup-plugin-webpack-stats": "2.1.1",
|
||||
"tslib": "^2.3.0",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "~5.8.0",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"mermaid": "11.9.0",
|
||||
"preact": "10.26.9",
|
||||
"preact": "10.27.0",
|
||||
"roughjs": "4.6.6",
|
||||
"@types/express-serve-static-core": "5.0.7",
|
||||
"flat@<5.0.1": ">=5.0.1",
|
||||
@@ -99,7 +99,6 @@
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.0.9",
|
||||
"dompurify@<3.2.4": ">=3.2.4",
|
||||
"esbuild@<=0.24.2": ">=0.25.0",
|
||||
"minimatch@<3.0.5": ">=3.0.5",
|
||||
"cookie@<0.7.0": ">=0.7.0",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=2.1.3",
|
||||
"on-headers@<1.1.0": ">=1.1.0",
|
||||
|
||||