mirror of
https://github.com/zadam/trilium.git
synced 2025-12-24 00:59:55 +01:00
Compare commits
9 Commits
feat/add-o
...
feature/fs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6bc65471d | ||
|
|
eb07d4b0ed | ||
|
|
2c096f3080 | ||
|
|
bac95c97e5 | ||
|
|
fe6daac979 | ||
|
|
770281214b | ||
|
|
15bd5aa4e4 | ||
|
|
3da6838395 | ||
|
|
16cdd9e137 |
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@@ -35,6 +35,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
|
||||
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
|
||||
# - run: npx nx-cloud record -- echo Hello World
|
||||
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@playwright/test": "1.54.1",
|
||||
"@stylistic/eslint-plugin": "5.2.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.17.0",
|
||||
"@types/node": "22.16.5",
|
||||
"@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.8",
|
||||
"typedoc": "0.28.7",
|
||||
"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.3",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.2",
|
||||
"@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.27.0",
|
||||
"preact": "10.26.9",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -133,8 +133,6 @@ export type CommandMappings = {
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -175,7 +173,7 @@ export type CommandMappings = {
|
||||
deleteNotes: ContextMenuCommandData;
|
||||
importIntoNote: ContextMenuCommandData;
|
||||
exportNote: ContextMenuCommandData;
|
||||
searchInSubtree: CommandData & { notePath: string; };
|
||||
searchInSubtree: ContextMenuCommandData;
|
||||
moveNoteUp: ContextMenuCommandData;
|
||||
moveNoteDown: ContextMenuCommandData;
|
||||
moveNoteUpInHierarchy: ContextMenuCommandData;
|
||||
@@ -264,7 +262,6 @@ export type CommandMappings = {
|
||||
closeThisNoteSplit: CommandData;
|
||||
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
|
||||
jumpToNote: CommandData;
|
||||
commandPalette: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
|
||||
@@ -113,9 +113,7 @@ export default class Entrypoints extends Component {
|
||||
if (win.isFullScreenable()) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
}
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
} // outside of electron this is handled by the browser
|
||||
}
|
||||
|
||||
reloadFrontendAppCommand() {
|
||||
|
||||
@@ -146,19 +146,6 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async showNoteOCRTextCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "ocr"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentsCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
|
||||
@@ -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" | "searchInSubtree";
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
|
||||
|
||||
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,19 +79,7 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b
|
||||
return $container;
|
||||
}
|
||||
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
"originalFileName",
|
||||
"fileSize",
|
||||
"template",
|
||||
"inherit",
|
||||
"cssClass",
|
||||
"iconClass",
|
||||
"pageSize",
|
||||
"viewType",
|
||||
"geolocation",
|
||||
"docName",
|
||||
"webViewSrc"
|
||||
];
|
||||
const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"];
|
||||
|
||||
async function renderNormalAttributes(note: FNote) {
|
||||
const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
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,7 +23,6 @@ interface Options {
|
||||
tooltip?: boolean;
|
||||
trim?: boolean;
|
||||
imageHasZoom?: boolean;
|
||||
showOcrText?: boolean;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
@@ -47,9 +46,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
await renderImage(entity, $renderedContent, options);
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent, options);
|
||||
renderFile(entity, type, $renderedContent);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
@@ -66,9 +65,6 @@ 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")
|
||||
@@ -76,33 +72,8 @@ 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) {
|
||||
@@ -162,7 +133,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
const encodedTitle = encodeURIComponent(entity.title);
|
||||
|
||||
let url;
|
||||
@@ -202,39 +173,9 @@ async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery
|
||||
}
|
||||
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
|
||||
// Add OCR text display for image notes
|
||||
if (entity instanceof FNote && options.showOcrText) {
|
||||
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
|
||||
try {
|
||||
const response = await fetch(`api/ocr/notes/${note.noteId}/text`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.hasOcr && data.text) {
|
||||
const $ocrSection = $(`
|
||||
<div class="ocr-text-section">
|
||||
<div class="ocr-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
|
||||
</div>
|
||||
<div class="ocr-content"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$ocrSection.find('.ocr-content').text(data.text);
|
||||
$content.append($ocrSection);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR API is not available
|
||||
console.debug('Failed to fetch OCR text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
|
||||
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -270,11 +211,6 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
||||
$content.append($videoPreview);
|
||||
}
|
||||
|
||||
// Add OCR text display for file notes
|
||||
if (entity instanceof FNote && options.showOcrText) {
|
||||
await addOCRTextIfAvailable(entity, $content);
|
||||
}
|
||||
|
||||
if (entityType === "notes" && "noteId" in entity) {
|
||||
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
||||
// in attachment list
|
||||
|
||||
@@ -35,7 +35,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
loadResults.addOption(attributeEntity.name);
|
||||
} else if (ec.entityName === "attachments") {
|
||||
processAttachment(loadResults, ec);
|
||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "file_note_mappings" || ec.entityName === "file_system_mappings") {
|
||||
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||
} else {
|
||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||
|
||||
@@ -6,11 +6,6 @@ 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";
|
||||
|
||||
@@ -24,8 +19,6 @@ export async function initLocale() {
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
translationsInitializedPromise.resolve();
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
|
||||
@@ -2,15 +2,21 @@ 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, ActionKeyboardShortcut> = {};
|
||||
const keyboardActionRepo: Record<string, Action> = {};
|
||||
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
// TODO: Deduplicate with server.
|
||||
export interface Action {
|
||||
actionName: CommandNames;
|
||||
effectiveShortcuts: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const keyboardActionsLoaded = server.get<Action[]>("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;
|
||||
}
|
||||
@@ -32,7 +38,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 }));
|
||||
}
|
||||
}
|
||||
@@ -40,7 +46,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 }));
|
||||
}
|
||||
}
|
||||
@@ -74,7 +80,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);
|
||||
@@ -93,7 +99,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,7 +3,6 @@ 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
|
||||
@@ -30,12 +29,9 @@ export interface Suggestion {
|
||||
notePathTitle?: string;
|
||||
notePath?: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
action?: string | "create-note" | "search-notes" | "external-link" | "command";
|
||||
action?: string | "create-note" | "search-notes" | "external-link";
|
||||
parentNoteId?: string;
|
||||
icon?: string;
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
@@ -48,8 +44,6 @@ 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) {
|
||||
@@ -79,31 +73,6 @@ 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) {
|
||||
@@ -177,12 +146,6 @@ 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) {
|
||||
@@ -307,24 +270,7 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
},
|
||||
displayKey: "notePathTitle",
|
||||
templates: {
|
||||
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}`;
|
||||
}
|
||||
suggestion: (suggestion) => `<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
|
||||
@@ -334,12 +280,6 @@ 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);
|
||||
@@ -456,7 +396,6 @@ export default {
|
||||
autocompleteSourceForCKEditor,
|
||||
initNoteAutocomplete,
|
||||
showRecentNotes,
|
||||
showAllCommands,
|
||||
setText,
|
||||
init
|
||||
};
|
||||
|
||||
@@ -320,8 +320,3 @@ 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,54 +1780,6 @@ 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);
|
||||
@@ -1937,14 +1889,12 @@ body.zen .note-title-widget input {
|
||||
|
||||
/* Content renderer */
|
||||
|
||||
footer.file-footer,
|
||||
footer.webview-footer {
|
||||
footer.file-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer.file-footer button,
|
||||
footer.webview-footer button {
|
||||
footer.file-footer button {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
@@ -2252,25 +2202,188 @@ footer.webview-footer button {
|
||||
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;
|
||||
/* File System Sync Modal Styles */
|
||||
.mapping-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
.mapping-modal .modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1051;
|
||||
}
|
||||
|
||||
.ocr-content {
|
||||
max-height: 150px;
|
||||
.mapping-modal .modal-content {
|
||||
position: relative;
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
z-index: 1052;
|
||||
}
|
||||
|
||||
.mapping-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.mapping-modal .modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mapping-modal .modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted-text-color);
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mapping-modal .modal-close:hover {
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.mapping-modal .modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mapping-modal .modal-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* File System Sync Mapping Cards */
|
||||
.mapping-item.card {
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 5px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.mapping-item.card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mapping-item .mapping-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mapping-item .mapping-details {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mapping-item .mapping-status {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mapping-item .mapping-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mapping-item .mapping-actions .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-badge.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.badge-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.badge-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Path Validation Styles */
|
||||
.path-validation-result {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.path-validation-result .text-success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.path-validation-result .text-warning {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.path-validation-result .text-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Sync Status Section */
|
||||
.sync-status-container {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.sync-status-info .status-item,
|
||||
.sync-status-info .active-mappings-count {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Form Enhancements */
|
||||
.mapping-form .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mapping-form .subtree-options {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.mapping-form .help-block {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -458,11 +458,6 @@ 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,15 +128,10 @@ div.tn-tool-dialog {
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
padding: unset !important;
|
||||
padding-bottom: 26px !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-footer {
|
||||
padding-top: 26px;
|
||||
padding: 26px 0 !important;
|
||||
}
|
||||
|
||||
/* 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": "",
|
||||
"search_placeholder": "Suche nach einer Notiz anhand ihres Namens",
|
||||
"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 or type > for commands...",
|
||||
"search_placeholder": "search for note by its name",
|
||||
"close": "Close",
|
||||
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
@@ -674,7 +674,6 @@
|
||||
"search_in_note": "Search in note",
|
||||
"note_source": "Note source",
|
||||
"note_attachments": "Note attachments",
|
||||
"view_ocr_text": "View OCR text",
|
||||
"open_note_externally": "Open note externally",
|
||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||
"open_note_custom": "Open note custom",
|
||||
@@ -1304,22 +1303,7 @@
|
||||
"enable_image_compression": "Enable image compression",
|
||||
"max_image_dimensions": "Max width / height of an image (image will be resized if it exceeds this setting).",
|
||||
"max_image_dimensions_unit": "pixels",
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)",
|
||||
"ocr_section_title": "Optical Character Recognition (OCR)",
|
||||
"enable_ocr": "Enable OCR for images",
|
||||
"ocr_description": "Automatically extract text from images using OCR technology. This makes image content searchable within your notes.",
|
||||
"ocr_auto_process": "Automatically process new images with OCR",
|
||||
"ocr_language": "OCR Language",
|
||||
"ocr_min_confidence": "Minimum confidence threshold",
|
||||
"ocr_confidence_unit": "(0.0-1.0)",
|
||||
"ocr_confidence_description": "Only extract text with confidence above this threshold. Lower values include more text but may be less accurate.",
|
||||
"batch_ocr_title": "Process Existing Images",
|
||||
"batch_ocr_description": "Process all existing images in your notes with OCR. This may take some time depending on the number of images.",
|
||||
"batch_ocr_start": "Start Batch OCR Processing",
|
||||
"batch_ocr_starting": "Starting batch OCR processing...",
|
||||
"batch_ocr_progress": "Processing {{processed}} of {{total}} images...",
|
||||
"batch_ocr_completed": "Batch OCR completed! Processed {{processed}} images.",
|
||||
"batch_ocr_error": "Error during batch OCR: {{error}}"
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Attachment Erasure Timeout",
|
||||
@@ -2003,37 +1987,5 @@
|
||||
"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": "",
|
||||
"search_placeholder": "buscar nota por su nombre",
|
||||
"close": "Cerrar",
|
||||
"search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "",
|
||||
"search_placeholder": "rechercher une note par son nom",
|
||||
"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": "",
|
||||
"search_placeholder": "căutați o notiță după denumirea ei",
|
||||
"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,10 +90,6 @@ const TPL = /*html*/`
|
||||
<span class="bx bx-code"></span> ${t("note_actions.note_source")}<kbd data-command="showNoteSource"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="showNoteOCRText" class="dropdown-item show-ocr-text-button">
|
||||
<span class="bx bx-text"></span> ${t("note_actions.view_ocr_text")}<kbd data-command="showNoteOCRText"></kbd>
|
||||
</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
@@ -121,7 +117,6 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
private $printActiveNoteButton!: JQuery<HTMLElement>;
|
||||
private $exportAsPdfButton!: JQuery<HTMLElement>;
|
||||
private $showSourceButton!: JQuery<HTMLElement>;
|
||||
private $showOCRTextButton!: JQuery<HTMLElement>;
|
||||
private $showAttachmentsButton!: JQuery<HTMLElement>;
|
||||
private $renderNoteButton!: JQuery<HTMLElement>;
|
||||
private $saveRevisionButton!: JQuery<HTMLElement>;
|
||||
@@ -148,7 +143,6 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
|
||||
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
|
||||
this.$showSourceButton = this.$widget.find(".show-source-button");
|
||||
this.$showOCRTextButton = this.$widget.find(".show-ocr-text-button");
|
||||
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
|
||||
this.$renderNoteButton = this.$widget.find(".render-note-button");
|
||||
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
|
||||
@@ -196,9 +190,6 @@ 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,7 +6,6 @@ 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">
|
||||
@@ -35,8 +34,7 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
private modal!: bootstrap.Modal;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $results!: JQuery<HTMLElement>;
|
||||
private $modalFooter!: JQuery<HTMLElement>;
|
||||
private isCommandMode: boolean = false;
|
||||
private $showInFullTextButton!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -50,44 +48,13 @@ 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.$modalFooter = this.$widget.find(".modal-footer");
|
||||
this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e));
|
||||
this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
|
||||
this.$showInFullTextButton.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) => {
|
||||
@@ -114,76 +81,42 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
}
|
||||
|
||||
// first open dialog, then refresh since refresh is doing focus which should be visible
|
||||
this.refresh(commandMode);
|
||||
this.refresh();
|
||||
|
||||
this.lastOpenedTs = Date.now();
|
||||
}
|
||||
|
||||
async refresh(commandMode = false) {
|
||||
async refresh() {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
container: this.$results[0],
|
||||
isCommandPalette: true
|
||||
container: this.$results[0]
|
||||
})
|
||||
// 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 (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");
|
||||
// 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);
|
||||
} else {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +125,6 @@ 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,9 +88,7 @@ export default class SortChildNotesDialog extends BasicWidget {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".sort-child-notes-form");
|
||||
|
||||
this.$form.on("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
this.$form.on("submit", async () => {
|
||||
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,7 +28,6 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
||||
import ReadOnlyOCRTextWidget from "./type_widgets/read_only_ocr_text.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type TypeWidget from "./type_widgets/type_widget.js";
|
||||
@@ -56,7 +55,6 @@ const typeWidgetClasses = {
|
||||
readOnlyText: ReadOnlyTextTypeWidget,
|
||||
editableCode: EditableCodeTypeWidget,
|
||||
readOnlyCode: ReadOnlyCodeTypeWidget,
|
||||
readOnlyOCRText: ReadOnlyOCRTextWidget,
|
||||
file: FileTypeWidget,
|
||||
image: ImageTypeWidget,
|
||||
search: NoneTypeWidget,
|
||||
@@ -87,7 +85,6 @@ type ExtendedNoteType =
|
||||
| "empty"
|
||||
| "readOnlyCode"
|
||||
| "readOnlyText"
|
||||
| "readOnlyOCRText"
|
||||
| "editableText"
|
||||
| "editableCode"
|
||||
| "attachmentDetail"
|
||||
@@ -226,8 +223,6 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
if (viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (viewScope?.viewMode === "ocr") {
|
||||
resultingType = "readOnlyOCRText";
|
||||
} else if (viewScope && viewScope.viewMode === "attachments") {
|
||||
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import RevisionSnapshotsLimitOptions from "./options/other/revision_snapshots_li
|
||||
import NetworkConnectionsOptions from "./options/other/network_connections.js";
|
||||
import HtmlImportTagsOptions from "./options/other/html_import_tags.js";
|
||||
import AdvancedSyncOptions from "./options/advanced/sync.js";
|
||||
import FileSystemSyncOptions from "./options/advanced/file_system_sync.js";
|
||||
import DatabaseIntegrityCheckOptions from "./options/advanced/database_integrity_check.js";
|
||||
import VacuumDatabaseOptions from "./options/advanced/vacuum_database.js";
|
||||
import DatabaseAnonymizationOptions from "./options/advanced/database_anonymization.js";
|
||||
@@ -138,6 +139,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAw
|
||||
],
|
||||
_optionsAdvanced: [
|
||||
AdvancedSyncOptions,
|
||||
FileSystemSyncOptions,
|
||||
DatabaseIntegrityCheckOptions,
|
||||
DatabaseAnonymizationOptions,
|
||||
VacuumDatabaseOptions
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import noteAutocompleteService from "../../../../services/note_autocomplete.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import type { Suggestion } from "../../../../services/note_autocomplete.js";
|
||||
|
||||
interface FileSystemMapping {
|
||||
mappingId: string;
|
||||
noteId: string;
|
||||
filePath: string;
|
||||
syncDirection: 'bidirectional' | 'trilium_to_disk' | 'disk_to_trilium';
|
||||
isActive: boolean;
|
||||
includeSubtree: boolean;
|
||||
preserveHierarchy: boolean;
|
||||
contentFormat: 'auto' | 'markdown' | 'html' | 'raw';
|
||||
excludePatterns: string[] | null;
|
||||
lastSyncTime: string | null;
|
||||
syncErrors: string[] | null;
|
||||
dateCreated: string;
|
||||
dateModified: string;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
enabled: boolean;
|
||||
initialized: boolean;
|
||||
status?: Record<string, any>;
|
||||
}
|
||||
|
||||
// API Request/Response interfaces
|
||||
interface PathValidationRequest {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface PathValidationResponse {
|
||||
exists: boolean;
|
||||
stats?: {
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateMappingRequest {
|
||||
noteId: string;
|
||||
filePath: string;
|
||||
syncDirection: 'bidirectional' | 'trilium_to_disk' | 'disk_to_trilium';
|
||||
contentFormat: 'auto' | 'markdown' | 'html' | 'raw';
|
||||
includeSubtree: boolean;
|
||||
preserveHierarchy: boolean;
|
||||
excludePatterns: string[] | null;
|
||||
}
|
||||
|
||||
interface UpdateMappingRequest extends CreateMappingRequest {}
|
||||
|
||||
interface SyncMappingResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<style>
|
||||
.modal-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.modal-visible {
|
||||
display: flex !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1050;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
<div class="options-section">
|
||||
<h4>File System Sync</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" class="file-sync-enabled-checkbox">
|
||||
Enable file system synchronization
|
||||
</label>
|
||||
<div class="help-block">
|
||||
Allows bidirectional synchronization between Trilium notes and files on your local file system.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-sync-controls" style="display: none;">
|
||||
<div class="alert alert-info">
|
||||
<strong>Note:</strong> File system sync creates mappings between notes and files/directories.
|
||||
Changes in either location will be synchronized automatically when enabled.
|
||||
</div>
|
||||
|
||||
<div class="sync-status-container">
|
||||
<h5>Sync Status</h5>
|
||||
<div class="sync-status-info">
|
||||
<div class="status-item">
|
||||
<strong>Status:</strong> <span class="sync-status-text">Loading...</span>
|
||||
</div>
|
||||
<div class="active-mappings-count">
|
||||
<strong>Active Mappings:</strong> <span class="mappings-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mappings-section">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5>File System Mappings</h5>
|
||||
<button class="btn btn-primary btn-sm create-mapping-button">
|
||||
<i class="bx bx-plus"></i> Create Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mappings-list">
|
||||
<!-- Mappings will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sync-actions mt-3">
|
||||
<button class="btn btn-secondary refresh-status-button">
|
||||
<i class="bx bx-refresh"></i> Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Mapping Modal -->
|
||||
<div class="mapping-modal modal-hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create File System Mapping</h5>
|
||||
<button type="button" class="modal-close" aria-label="Close">
|
||||
<i class="bx bx-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="mapping-form">
|
||||
<div class="form-group">
|
||||
<label for="note-selector">Note:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="note-selector" class="form-control note-selector"
|
||||
placeholder="Search for a note...">
|
||||
</div>
|
||||
<div class="help-block">Select the note to map to the file system.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="file-path">File/Directory Path:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="file-path" class="form-control file-path-input"
|
||||
placeholder="/path/to/file/or/directory">
|
||||
<div class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary validate-path-button">
|
||||
<i class="bx bx-search"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-validation-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sync-direction">Sync Direction:</label>
|
||||
<select id="sync-direction" class="form-control sync-direction-select">
|
||||
<option value="bidirectional">Bidirectional (default)</option>
|
||||
<option value="trilium_to_disk">Trilium → Disk only</option>
|
||||
<option value="disk_to_trilium">Disk → Trilium only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content-format">Content Format:</label>
|
||||
<select id="content-format" class="form-control content-format-select">
|
||||
<option value="auto">Auto-detect (default)</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="raw">Raw/Binary</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" class="include-subtree-checkbox">
|
||||
Include subtree
|
||||
</label>
|
||||
<div class="help-block">Map entire note subtree to directory structure.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group subtree-options" style="display: none;">
|
||||
<label>
|
||||
<input type="checkbox" class="preserve-hierarchy-checkbox" checked>
|
||||
Preserve directory hierarchy
|
||||
</label>
|
||||
<div class="help-block">Create subdirectories matching note hierarchy.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exclude-patterns">Exclude Patterns (one per line):</label>
|
||||
<textarea id="exclude-patterns" class="form-control exclude-patterns-textarea"
|
||||
rows="3" placeholder="*.tmp node_modules .git"></textarea>
|
||||
<div class="help-block">Files/directories matching these patterns will be ignored.</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary cancel-mapping-button">Cancel</button>
|
||||
<button type="button" class="btn btn-primary save-mapping-button">Save Mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const MAPPING_ITEM_TPL = /*html*/`
|
||||
<div class="mapping-item card mb-2" data-mapping-id="">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="mapping-info">
|
||||
<div class="mapping-path">
|
||||
<strong class="file-path"></strong>
|
||||
</div>
|
||||
<div class="mapping-details text-muted">
|
||||
<span class="note-title"></span> •
|
||||
<span class="sync-direction-text"></span> •
|
||||
<span class="content-format-text"></span>
|
||||
</div>
|
||||
<div class="mapping-status">
|
||||
<span class="status-badge"></span>
|
||||
<span class="last-sync"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-actions">
|
||||
<button class="btn btn-sm btn-secondary sync-mapping-button" title="Sync now">
|
||||
<i class="bx bx-refresh"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary edit-mapping-button" title="Edit">
|
||||
<i class="bx bx-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger delete-mapping-button" title="Delete">
|
||||
<i class="bx bx-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-errors" style="display: none;">
|
||||
<div class="alert alert-warning mt-2">
|
||||
<strong>Sync Errors:</strong>
|
||||
<ul class="error-list mb-0"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class FileSystemSyncOptions extends OptionsWidget {
|
||||
private $fileSyncEnabledCheckbox!: JQuery<HTMLElement>;
|
||||
private $fileSyncControls!: JQuery<HTMLElement>;
|
||||
private $syncStatusText!: JQuery<HTMLElement>;
|
||||
private $mappingsCount!: JQuery<HTMLElement>;
|
||||
private $mappingsList!: JQuery<HTMLElement>;
|
||||
private $createMappingButton!: JQuery<HTMLElement>;
|
||||
private $refreshStatusButton!: JQuery<HTMLElement>;
|
||||
|
||||
// Modal elements
|
||||
private $mappingModal!: JQuery<HTMLElement>;
|
||||
private $modalTitle!: JQuery<HTMLElement>;
|
||||
private $noteSelector!: JQuery<HTMLElement>;
|
||||
private $filePathInput!: JQuery<HTMLElement>;
|
||||
private $validatePathButton!: JQuery<HTMLElement>;
|
||||
private $pathValidationResult!: JQuery<HTMLElement>;
|
||||
private $syncDirectionSelect!: JQuery<HTMLElement>;
|
||||
private $contentFormatSelect!: JQuery<HTMLElement>;
|
||||
private $includeSubtreeCheckbox!: JQuery<HTMLElement>;
|
||||
private $preserveHierarchyCheckbox!: JQuery<HTMLElement>;
|
||||
private $subtreeOptions!: JQuery<HTMLElement>;
|
||||
private $excludePatternsTextarea!: JQuery<HTMLElement>;
|
||||
private $saveMappingButton!: JQuery<HTMLElement>;
|
||||
private $cancelMappingButton!: JQuery<HTMLElement>;
|
||||
private $modalClose!: JQuery<HTMLElement>;
|
||||
|
||||
private currentEditingMappingId: string | null = null;
|
||||
private mappings: FileSystemMapping[] = [];
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.initializeElements();
|
||||
// Ensure modal is hidden on initialization
|
||||
this.$mappingModal.addClass('modal-hidden').removeClass('modal-visible');
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private initializeElements() {
|
||||
this.$fileSyncEnabledCheckbox = this.$widget.find(".file-sync-enabled-checkbox");
|
||||
this.$fileSyncControls = this.$widget.find(".file-sync-controls");
|
||||
this.$syncStatusText = this.$widget.find(".sync-status-text");
|
||||
this.$mappingsCount = this.$widget.find(".mappings-count");
|
||||
this.$mappingsList = this.$widget.find(".mappings-list");
|
||||
this.$createMappingButton = this.$widget.find(".create-mapping-button");
|
||||
this.$refreshStatusButton = this.$widget.find(".refresh-status-button");
|
||||
|
||||
// Modal elements
|
||||
this.$mappingModal = this.$widget.closest(".mapping-modal");
|
||||
this.$modalTitle = this.$mappingModal.find(".modal-title");
|
||||
this.$noteSelector = this.$mappingModal.find(".note-selector");
|
||||
this.$filePathInput = this.$mappingModal.find(".file-path-input");
|
||||
this.$validatePathButton = this.$mappingModal.find(".validate-path-button");
|
||||
this.$pathValidationResult = this.$mappingModal.find(".path-validation-result");
|
||||
this.$syncDirectionSelect = this.$mappingModal.find(".sync-direction-select");
|
||||
this.$contentFormatSelect = this.$mappingModal.find(".content-format-select");
|
||||
this.$includeSubtreeCheckbox = this.$mappingModal.find(".include-subtree-checkbox");
|
||||
this.$preserveHierarchyCheckbox = this.$mappingModal.find(".preserve-hierarchy-checkbox");
|
||||
this.$subtreeOptions = this.$mappingModal.find(".subtree-options");
|
||||
this.$excludePatternsTextarea = this.$mappingModal.find(".exclude-patterns-textarea");
|
||||
this.$saveMappingButton = this.$mappingModal.find(".save-mapping-button");
|
||||
this.$cancelMappingButton = this.$mappingModal.find(".cancel-mapping-button");
|
||||
this.$modalClose = this.$mappingModal.find(".modal-close");
|
||||
}
|
||||
|
||||
private setupEventHandlers() {
|
||||
this.$fileSyncEnabledCheckbox.on("change", async () => {
|
||||
const isEnabled = this.$fileSyncEnabledCheckbox.prop("checked");
|
||||
|
||||
try {
|
||||
if (isEnabled) {
|
||||
await server.post<ApiResponse>("file-system-sync/enable");
|
||||
} else {
|
||||
await server.post<ApiResponse>("file-system-sync/disable");
|
||||
}
|
||||
|
||||
this.toggleControls(isEnabled);
|
||||
if (isEnabled) {
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
toastService.showMessage(`File system sync ${isEnabled ? 'enabled' : 'disabled'}`);
|
||||
} catch (error) {
|
||||
toastService.showError(`Failed to ${isEnabled ? 'enable' : 'disable'} file system sync`);
|
||||
// Revert checkbox state
|
||||
this.$fileSyncEnabledCheckbox.prop("checked", !isEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
this.$createMappingButton.on("click", () => {
|
||||
this.showMappingModal();
|
||||
});
|
||||
|
||||
this.$refreshStatusButton.on("click", () => {
|
||||
this.refreshStatus();
|
||||
});
|
||||
|
||||
this.$validatePathButton.on("click", () => {
|
||||
this.validatePath();
|
||||
});
|
||||
|
||||
this.$includeSubtreeCheckbox.on("change", () => {
|
||||
const isChecked = this.$includeSubtreeCheckbox.prop("checked");
|
||||
this.$subtreeOptions.toggle(isChecked);
|
||||
});
|
||||
|
||||
// Modal handlers
|
||||
this.$saveMappingButton.on("click", () => {
|
||||
this.saveMapping();
|
||||
});
|
||||
|
||||
this.$cancelMappingButton.on("click", () => {
|
||||
this.hideMappingModal();
|
||||
});
|
||||
|
||||
this.$modalClose.on("click", () => {
|
||||
this.hideMappingModal();
|
||||
});
|
||||
|
||||
this.$mappingModal.find(".modal-backdrop").on("click", () => {
|
||||
this.hideMappingModal();
|
||||
});
|
||||
|
||||
// Note selector autocomplete will be initialized in showMappingModal
|
||||
}
|
||||
|
||||
private toggleControls(enabled: boolean) {
|
||||
this.$fileSyncControls.toggle(enabled);
|
||||
}
|
||||
|
||||
private async refreshStatus() {
|
||||
try {
|
||||
const status = await server.get<SyncStatus>("file-system-sync/status");
|
||||
|
||||
this.$syncStatusText.text(status.initialized ? "Active" : "Inactive");
|
||||
|
||||
if (status.initialized) {
|
||||
await this.loadMappings();
|
||||
}
|
||||
} catch (error) {
|
||||
this.$syncStatusText.text("Error");
|
||||
toastService.showError("Failed to get sync status");
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMappings() {
|
||||
try {
|
||||
this.mappings = await server.get<FileSystemMapping[]>("file-system-sync/mappings");
|
||||
this.renderMappings();
|
||||
this.$mappingsCount.text(this.mappings.length.toString());
|
||||
} catch (error) {
|
||||
toastService.showError("Failed to load mappings");
|
||||
}
|
||||
}
|
||||
|
||||
private renderMappings() {
|
||||
this.$mappingsList.empty();
|
||||
|
||||
for (const mapping of this.mappings) {
|
||||
const $item = $(MAPPING_ITEM_TPL);
|
||||
$item.attr("data-mapping-id", mapping.mappingId);
|
||||
|
||||
$item.find(".file-path").text(mapping.filePath);
|
||||
$item.find(".note-title").text(`Note: ${mapping.noteId}`); // TODO: Get actual note title
|
||||
$item.find(".sync-direction-text").text(this.formatSyncDirection(mapping.syncDirection));
|
||||
$item.find(".content-format-text").text(mapping.contentFormat);
|
||||
|
||||
// Status badge
|
||||
const $statusBadge = $item.find(".status-badge");
|
||||
if (mapping.syncErrors && mapping.syncErrors.length > 0) {
|
||||
$statusBadge.addClass("badge badge-danger").text("Error");
|
||||
const $errorsDiv = $item.find(".sync-errors");
|
||||
const $errorList = $errorsDiv.find(".error-list");
|
||||
mapping.syncErrors.forEach(error => {
|
||||
$errorList.append(`<li>${error}</li>`);
|
||||
});
|
||||
$errorsDiv.show();
|
||||
} else if (mapping.isActive) {
|
||||
$statusBadge.addClass("badge badge-success").text("Active");
|
||||
} else {
|
||||
$statusBadge.addClass("badge badge-secondary").text("Inactive");
|
||||
}
|
||||
|
||||
// Last sync time
|
||||
if (mapping.lastSyncTime) {
|
||||
const lastSync = new Date(mapping.lastSyncTime).toLocaleString();
|
||||
$item.find(".last-sync").text(`Last sync: ${lastSync}`);
|
||||
} else {
|
||||
$item.find(".last-sync").text("Never synced");
|
||||
}
|
||||
|
||||
// Action handlers
|
||||
$item.find(".sync-mapping-button").on("click", () => {
|
||||
this.syncMapping(mapping.mappingId);
|
||||
});
|
||||
|
||||
$item.find(".edit-mapping-button").on("click", () => {
|
||||
this.editMapping(mapping);
|
||||
});
|
||||
|
||||
$item.find(".delete-mapping-button").on("click", () => {
|
||||
this.deleteMapping(mapping.mappingId);
|
||||
});
|
||||
|
||||
this.$mappingsList.append($item);
|
||||
}
|
||||
}
|
||||
|
||||
private formatSyncDirection(direction: string): string {
|
||||
switch (direction) {
|
||||
case 'bidirectional': return 'Bidirectional';
|
||||
case 'trilium_to_disk': return 'Trilium → Disk';
|
||||
case 'disk_to_trilium': return 'Disk → Trilium';
|
||||
default: return direction;
|
||||
}
|
||||
}
|
||||
|
||||
private showMappingModal(mapping?: FileSystemMapping) {
|
||||
this.currentEditingMappingId = mapping?.mappingId || null;
|
||||
console.log("Showing mapping modal", this.currentEditingMappingId, this.$mappingModal);
|
||||
|
||||
if (mapping) {
|
||||
this.$modalTitle.text("Edit File System Mapping");
|
||||
this.populateMappingForm(mapping);
|
||||
} else {
|
||||
this.$modalTitle.text("Create File System Mapping");
|
||||
this.clearMappingForm();
|
||||
}
|
||||
|
||||
// Initialize note autocomplete
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$noteSelector, {
|
||||
allowCreatingNotes: true,
|
||||
});
|
||||
|
||||
// Handle note selection
|
||||
this.$noteSelector.off("autocomplete:noteselected").on("autocomplete:noteselected", (event: JQuery.Event, suggestion: Suggestion) => {
|
||||
// The note autocomplete service will automatically set the selected note path
|
||||
// which we can retrieve using getSelectedNoteId()
|
||||
});
|
||||
|
||||
this.$mappingModal.removeClass('modal-hidden').addClass('modal-visible');
|
||||
}
|
||||
|
||||
private hideMappingModal() {
|
||||
this.$mappingModal.removeClass('modal-visible').addClass('modal-hidden');
|
||||
this.clearMappingForm();
|
||||
this.currentEditingMappingId = null;
|
||||
}
|
||||
|
||||
private async populateMappingForm(mapping: FileSystemMapping) {
|
||||
// Set the note using the autocomplete service's setNote method
|
||||
await this.$noteSelector.setNote(mapping.noteId);
|
||||
|
||||
this.$filePathInput.val(mapping.filePath);
|
||||
this.$syncDirectionSelect.val(mapping.syncDirection);
|
||||
this.$contentFormatSelect.val(mapping.contentFormat);
|
||||
this.$includeSubtreeCheckbox.prop("checked", mapping.includeSubtree);
|
||||
this.$preserveHierarchyCheckbox.prop("checked", mapping.preserveHierarchy);
|
||||
this.$subtreeOptions.toggle(mapping.includeSubtree);
|
||||
|
||||
if (mapping.excludePatterns) {
|
||||
this.$excludePatternsTextarea.val(mapping.excludePatterns.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
private clearMappingForm() {
|
||||
// Clear the note selector using autocomplete service
|
||||
this.$noteSelector.val('').setSelectedNotePath('');
|
||||
this.$filePathInput.val('');
|
||||
this.$syncDirectionSelect.val('bidirectional');
|
||||
this.$contentFormatSelect.val('auto');
|
||||
this.$includeSubtreeCheckbox.prop("checked", false);
|
||||
this.$preserveHierarchyCheckbox.prop("checked", true);
|
||||
this.$subtreeOptions.hide();
|
||||
this.$excludePatternsTextarea.val('');
|
||||
this.$pathValidationResult.empty();
|
||||
}
|
||||
|
||||
private async validatePath() {
|
||||
const filePath = this.$filePathInput.val() as string;
|
||||
if (!filePath) {
|
||||
this.$pathValidationResult.html('<div class="text-danger">Please enter a file path</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await server.post<PathValidationResponse>("file-system-sync/validate-path", { filePath } as PathValidationRequest);
|
||||
|
||||
if (result.exists && result.stats) {
|
||||
const type = result.stats.isDirectory ? 'directory' : 'file';
|
||||
this.$pathValidationResult.html(
|
||||
`<div class="text-success">✓ Valid ${type} (${result.stats.size} bytes, modified ${new Date(result.stats.modified).toLocaleString()})</div>`
|
||||
);
|
||||
} else {
|
||||
this.$pathValidationResult.html('<div class="text-warning">⚠ Path does not exist</div>');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$pathValidationResult.html('<div class="text-danger">✗ Invalid path</div>');
|
||||
}
|
||||
}
|
||||
|
||||
private async saveMapping() {
|
||||
const noteId = this.$noteSelector.getSelectedNoteId();
|
||||
const filePath = this.$filePathInput.val() as string;
|
||||
const syncDirection = this.$syncDirectionSelect.val() as string;
|
||||
const contentFormat = this.$contentFormatSelect.val() as string;
|
||||
const includeSubtree = this.$includeSubtreeCheckbox.prop("checked");
|
||||
const preserveHierarchy = this.$preserveHierarchyCheckbox.prop("checked");
|
||||
const excludePatternsText = this.$excludePatternsTextarea.val() as string;
|
||||
|
||||
// Validation
|
||||
if (!noteId) {
|
||||
toastService.showError("Please select a note");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
toastService.showError("Please enter a file path");
|
||||
return;
|
||||
}
|
||||
|
||||
const excludePatterns = excludePatternsText.trim()
|
||||
? excludePatternsText.split('\n').map(p => p.trim()).filter(p => p)
|
||||
: null;
|
||||
|
||||
const mappingData: CreateMappingRequest = {
|
||||
noteId,
|
||||
filePath,
|
||||
syncDirection: syncDirection as 'bidirectional' | 'trilium_to_disk' | 'disk_to_trilium',
|
||||
contentFormat: contentFormat as 'auto' | 'markdown' | 'html' | 'raw',
|
||||
includeSubtree,
|
||||
preserveHierarchy,
|
||||
excludePatterns
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.currentEditingMappingId) {
|
||||
await server.put<ApiResponse>(`file-system-sync/mappings/${this.currentEditingMappingId}`, mappingData as UpdateMappingRequest);
|
||||
toastService.showMessage("Mapping updated successfully");
|
||||
} else {
|
||||
await server.post<ApiResponse>("file-system-sync/mappings", mappingData);
|
||||
toastService.showMessage("Mapping created successfully");
|
||||
}
|
||||
|
||||
this.hideMappingModal();
|
||||
await this.loadMappings();
|
||||
} catch (error) {
|
||||
toastService.showError("Failed to save mapping");
|
||||
}
|
||||
}
|
||||
|
||||
private async syncMapping(mappingId: string) {
|
||||
try {
|
||||
const result = await server.post<SyncMappingResponse>(`file-system-sync/mappings/${mappingId}/sync`);
|
||||
if (result.success) {
|
||||
toastService.showMessage("Sync completed successfully");
|
||||
} else {
|
||||
toastService.showError(`Sync failed: ${result.message}`);
|
||||
}
|
||||
await this.loadMappings();
|
||||
} catch (error) {
|
||||
toastService.showError("Failed to trigger sync");
|
||||
}
|
||||
}
|
||||
|
||||
private editMapping(mapping: FileSystemMapping) {
|
||||
this.showMappingModal(mapping);
|
||||
}
|
||||
|
||||
private async deleteMapping(mappingId: string) {
|
||||
if (!confirm("Are you sure you want to delete this mapping?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await server.delete<ApiResponse>(`file-system-sync/mappings/${mappingId}`);
|
||||
toastService.showMessage("Mapping deleted successfully");
|
||||
await this.loadMappings();
|
||||
} catch (error) {
|
||||
toastService.showError("Failed to delete mapping");
|
||||
}
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
const isEnabled = options.fileSystemSyncEnabled === "true";
|
||||
this.$fileSyncEnabledCheckbox.prop("checked", isEnabled);
|
||||
this.toggleControls(isEnabled);
|
||||
|
||||
if (isEnabled) {
|
||||
await this.refreshStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
@@ -11,43 +9,6 @@ const TPL = /*html*/`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.batch-ocr-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.batch-ocr-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ocr-language-checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
.ocr-language-display {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ocr-language-display .placeholder-text {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
.ocr-language-display .language-code {
|
||||
background-color: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h4>${t("images.images_section_title")}</h4>
|
||||
@@ -83,123 +44,6 @@ const TPL = /*html*/`
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5>${t("images.ocr_section_title")}</h5>
|
||||
|
||||
<label class="tn-checkbox">
|
||||
<input class="ocr-enabled" type="checkbox" name="ocr-enabled">
|
||||
${t("images.enable_ocr")}
|
||||
</label>
|
||||
|
||||
<p class="form-text">${t("images.ocr_description")}</p>
|
||||
|
||||
<div class="ocr-settings-wrapper">
|
||||
<label class="tn-checkbox">
|
||||
<input class="ocr-auto-process" type="checkbox" name="ocr-auto-process">
|
||||
${t("images.ocr_auto_process")}
|
||||
</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("images.ocr_language")}</label>
|
||||
<p class="form-text">${t("images.ocr_multi_language_description")}</p>
|
||||
<div class="ocr-language-checkboxes">
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="eng" data-language="eng">
|
||||
English
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="spa" data-language="spa">
|
||||
Spanish
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="fra" data-language="fra">
|
||||
French
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="deu" data-language="deu">
|
||||
German
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ita" data-language="ita">
|
||||
Italian
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="por" data-language="por">
|
||||
Portuguese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="rus" data-language="rus">
|
||||
Russian
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="chi_sim" data-language="chi_sim">
|
||||
Chinese (Simplified)
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="chi_tra" data-language="chi_tra">
|
||||
Chinese (Traditional)
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="jpn" data-language="jpn">
|
||||
Japanese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="kor" data-language="kor">
|
||||
Korean
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ara" data-language="ara">
|
||||
Arabic
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="hin" data-language="hin">
|
||||
Hindi
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="tha" data-language="tha">
|
||||
Thai
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="vie" data-language="vie">
|
||||
Vietnamese
|
||||
</label>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" value="ron" data-language="ron">
|
||||
Romanian
|
||||
</label>
|
||||
</div>
|
||||
<div class="ocr-language-display form-control" readonly>
|
||||
<span class="placeholder-text">${t("images.ocr_no_languages_selected")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("images.ocr_min_confidence")}</label>
|
||||
<label class="input-group tn-number-unit-pair">
|
||||
<input class="ocr-min-confidence form-control options-number-input" type="number" min="0" max="1" step="0.1">
|
||||
<span class="input-group-text">${t("images.ocr_confidence_unit")}</span>
|
||||
</label>
|
||||
<div class="form-text">${t("images.ocr_confidence_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-ocr-section">
|
||||
<h6>${t("images.batch_ocr_title")}</h6>
|
||||
<p class="form-text">${t("images.batch_ocr_description")}</p>
|
||||
|
||||
<button class="btn btn-primary batch-ocr-button">
|
||||
${t("images.batch_ocr_start")}
|
||||
</button>
|
||||
|
||||
<div class="batch-ocr-progress" style="display: none;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="batch-ocr-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -211,22 +55,9 @@ export default class ImageOptions extends OptionsWidget {
|
||||
private $enableImageCompression!: JQuery<HTMLElement>;
|
||||
private $imageCompressionWrapper!: JQuery<HTMLElement>;
|
||||
|
||||
// OCR elements
|
||||
private $ocrEnabled!: JQuery<HTMLElement>;
|
||||
private $ocrAutoProcess!: JQuery<HTMLElement>;
|
||||
private $ocrLanguageCheckboxes!: JQuery<HTMLElement>;
|
||||
private $ocrLanguageDisplay!: JQuery<HTMLElement>;
|
||||
private $ocrMinConfidence!: JQuery<HTMLElement>;
|
||||
private $ocrSettingsWrapper!: JQuery<HTMLElement>;
|
||||
private $batchOcrButton!: JQuery<HTMLElement>;
|
||||
private $batchOcrProgress!: JQuery<HTMLElement>;
|
||||
private $batchOcrProgressBar!: JQuery<HTMLElement>;
|
||||
private $batchOcrStatus!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
// Image settings
|
||||
this.$imageMaxWidthHeight = this.$widget.find(".image-max-width-height");
|
||||
this.$imageJpegQuality = this.$widget.find(".image-jpeg-quality");
|
||||
|
||||
@@ -245,49 +76,16 @@ export default class ImageOptions extends OptionsWidget {
|
||||
this.updateCheckboxOption("compressImages", this.$enableImageCompression);
|
||||
this.setImageCompression();
|
||||
});
|
||||
|
||||
// OCR settings
|
||||
this.$ocrEnabled = this.$widget.find(".ocr-enabled");
|
||||
this.$ocrAutoProcess = this.$widget.find(".ocr-auto-process");
|
||||
this.$ocrLanguageCheckboxes = this.$widget.find(".ocr-language-checkboxes");
|
||||
this.$ocrLanguageDisplay = this.$widget.find(".ocr-language-display");
|
||||
this.$ocrMinConfidence = this.$widget.find(".ocr-min-confidence");
|
||||
this.$ocrSettingsWrapper = this.$widget.find(".ocr-settings-wrapper");
|
||||
this.$batchOcrButton = this.$widget.find(".batch-ocr-button");
|
||||
this.$batchOcrProgress = this.$widget.find(".batch-ocr-progress");
|
||||
this.$batchOcrProgressBar = this.$widget.find(".progress-bar");
|
||||
this.$batchOcrStatus = this.$widget.find(".batch-ocr-status");
|
||||
|
||||
this.$ocrEnabled.on("change", () => {
|
||||
this.updateCheckboxOption("ocrEnabled", this.$ocrEnabled);
|
||||
this.setOcrVisibility();
|
||||
});
|
||||
|
||||
this.$ocrAutoProcess.on("change", () => this.updateCheckboxOption("ocrAutoProcessImages", this.$ocrAutoProcess));
|
||||
|
||||
this.$ocrLanguageCheckboxes.on("change", "input[type='checkbox']", () => this.updateOcrLanguages());
|
||||
|
||||
this.$ocrMinConfidence.on("change", () => this.updateOption("ocrMinConfidence", String(this.$ocrMinConfidence.val()).trim() || "0.6"));
|
||||
|
||||
this.$batchOcrButton.on("click", () => this.startBatchOcr());
|
||||
}
|
||||
|
||||
optionsLoaded(options: OptionMap) {
|
||||
// Image settings
|
||||
this.$imageMaxWidthHeight.val(options.imageMaxWidthHeight);
|
||||
this.$imageJpegQuality.val(options.imageJpegQuality);
|
||||
|
||||
this.setCheckboxState(this.$downloadImagesAutomatically, options.downloadImagesAutomatically);
|
||||
this.setCheckboxState(this.$enableImageCompression, options.compressImages);
|
||||
|
||||
// OCR settings
|
||||
this.setCheckboxState(this.$ocrEnabled, options.ocrEnabled);
|
||||
this.setCheckboxState(this.$ocrAutoProcess, options.ocrAutoProcessImages);
|
||||
this.setOcrLanguages(options.ocrLanguage || "eng");
|
||||
this.$ocrMinConfidence.val(options.ocrMinConfidence || "0.6");
|
||||
|
||||
this.setImageCompression();
|
||||
this.setOcrVisibility();
|
||||
}
|
||||
|
||||
setImageCompression() {
|
||||
@@ -297,134 +95,4 @@ export default class ImageOptions extends OptionsWidget {
|
||||
this.$imageCompressionWrapper.addClass("disabled-field");
|
||||
}
|
||||
}
|
||||
|
||||
setOcrVisibility() {
|
||||
if (this.$ocrEnabled.prop("checked")) {
|
||||
this.$ocrSettingsWrapper.removeClass("disabled-field");
|
||||
} else {
|
||||
this.$ocrSettingsWrapper.addClass("disabled-field");
|
||||
}
|
||||
}
|
||||
|
||||
setOcrLanguages(languageString: string) {
|
||||
// Clear all checkboxes first
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]').prop('checked', false);
|
||||
|
||||
if (languageString) {
|
||||
// Split by '+' to handle multi-language format like "ron+eng"
|
||||
const languages = languageString.split('+');
|
||||
|
||||
languages.forEach(lang => {
|
||||
const checkbox = this.$ocrLanguageCheckboxes.find(`input[data-language="${lang.trim()}"]`);
|
||||
if (checkbox.length > 0) {
|
||||
checkbox.prop('checked', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.updateOcrLanguageDisplay();
|
||||
}
|
||||
|
||||
updateOcrLanguages() {
|
||||
const selectedLanguages: string[] = [];
|
||||
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]:checked').each(function() {
|
||||
selectedLanguages.push($(this).val() as string);
|
||||
});
|
||||
|
||||
// Join with '+' for Tesseract multi-language format
|
||||
const languageString = selectedLanguages.join('+');
|
||||
|
||||
this.updateOption("ocrLanguage", languageString || "eng");
|
||||
this.updateOcrLanguageDisplay();
|
||||
}
|
||||
|
||||
updateOcrLanguageDisplay() {
|
||||
const selectedLanguages: string[] = [];
|
||||
|
||||
this.$ocrLanguageCheckboxes.find('input[type="checkbox"]:checked').each(function() {
|
||||
selectedLanguages.push($(this).val() as string);
|
||||
});
|
||||
|
||||
const displayContent = this.$ocrLanguageDisplay.find('.placeholder-text, .language-code');
|
||||
displayContent.remove();
|
||||
|
||||
if (selectedLanguages.length === 0) {
|
||||
this.$ocrLanguageDisplay.html(`<span class="placeholder-text">${t("images.ocr_no_languages_selected")}</span>`);
|
||||
} else {
|
||||
const languageTags = selectedLanguages.map(lang =>
|
||||
`<span class="language-code">${lang}</span>`
|
||||
).join('');
|
||||
this.$ocrLanguageDisplay.html(languageTags);
|
||||
}
|
||||
}
|
||||
|
||||
async startBatchOcr() {
|
||||
this.$batchOcrButton.prop("disabled", true);
|
||||
this.$batchOcrProgress.show();
|
||||
this.$batchOcrProgressBar.css("width", "0%");
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_starting"));
|
||||
|
||||
try {
|
||||
const result = await server.post("ocr/batch-process") as {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
this.pollBatchOcrProgress();
|
||||
} else {
|
||||
throw new Error(result.message || "Failed to start batch OCR");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error starting batch OCR:", error);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_error", { error: error.message }));
|
||||
toastService.showError(`Failed to start batch OCR: ${error.message}`);
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
async pollBatchOcrProgress() {
|
||||
try {
|
||||
const result = await server.get("ocr/batch-progress") as {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
};
|
||||
|
||||
if (result.inProgress) {
|
||||
const progress = (result.processed / result.total) * 100;
|
||||
this.$batchOcrProgressBar.css("width", `${progress}%`);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_progress", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
|
||||
// Continue polling
|
||||
setTimeout(() => this.pollBatchOcrProgress(), 1000);
|
||||
} else {
|
||||
// Batch OCR completed
|
||||
this.$batchOcrProgressBar.css("width", "100%");
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_completed", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
toastService.showMessage(t("images.batch_ocr_completed", {
|
||||
processed: result.processed,
|
||||
total: result.total
|
||||
}));
|
||||
|
||||
// Hide progress after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.$batchOcrProgress.hide();
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error polling batch OCR progress:", error);
|
||||
this.$batchOcrStatus.text(t("images.batch_ocr_error", { error: error.message }));
|
||||
toastService.showError(`Failed to get batch OCR progress: ${error.message}`);
|
||||
this.$batchOcrButton.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, KeyboardShortcutWithRequiredActionName } from "@triliumnext/commons";
|
||||
import type { OptionNames, KeyboardShortcut } 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 ("separator" in action) {
|
||||
if (action.separator) {
|
||||
$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.friendlyName))
|
||||
$tr.append($("<td>").text(action.actionName))
|
||||
.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) => "actionName" in act && act.actionName === actionName) as KeyboardShortcutWithRequiredActionName;
|
||||
const action = globActions.find((act) => act.actionName === actionName);
|
||||
|
||||
if (!action) {
|
||||
if (!action || !action.actionName) {
|
||||
this.$widget.find(el).hide();
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +157,6 @@ 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))
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import TypeWidget from "./type_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-detail-ocr-text note-detail-printable">
|
||||
<style>
|
||||
.note-detail-ocr-text {
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ocr-text-content {
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--detail-text-font-family);
|
||||
font-size: var(--detail-text-font-size);
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: var(--accented-background-color);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.ocr-text-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.ocr-text-meta {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ocr-text-empty {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.ocr-text-loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.ocr-text-error {
|
||||
color: var(--error-color);
|
||||
background-color: var(--error-background-color);
|
||||
border: 1px solid var(--error-border-color);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ocr-process-button {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ocr-text-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text_title")}
|
||||
</div>
|
||||
|
||||
<div class="ocr-text-content"></div>
|
||||
|
||||
<div class="ocr-text-actions"></div>
|
||||
|
||||
<div class="ocr-text-meta"></div>
|
||||
</div>`;
|
||||
|
||||
interface OCRResponse {
|
||||
success: boolean;
|
||||
text: string;
|
||||
hasOcr: boolean;
|
||||
extractedAt: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default class ReadOnlyOCRTextWidget extends TypeWidget {
|
||||
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private $actions!: JQuery<HTMLElement>;
|
||||
private $meta!: JQuery<HTMLElement>;
|
||||
private currentNote?: FNote;
|
||||
|
||||
static getType() {
|
||||
return "readOnlyOCRText";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$content = this.$widget.find(".ocr-text-content");
|
||||
this.$actions = this.$widget.find(".ocr-text-actions");
|
||||
this.$meta = this.$widget.find(".ocr-text-meta");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.currentNote = note;
|
||||
|
||||
// Show loading state
|
||||
this.$content.html(`<div class="ocr-text-loading">
|
||||
<span class="bx bx-loader-alt bx-spin"></span> ${t("ocr.loading_text")}
|
||||
</div>`);
|
||||
this.$actions.empty();
|
||||
this.$meta.empty();
|
||||
|
||||
try {
|
||||
const response = await server.get<OCRResponse>(`ocr/notes/${note.noteId}/text`);
|
||||
|
||||
if (!response.success) {
|
||||
this.showError(response.error || t("ocr.failed_to_load"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.hasOcr || !response.text) {
|
||||
this.showNoOCRAvailable();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the OCR text
|
||||
this.$content.text(response.text);
|
||||
|
||||
// Show metadata
|
||||
const extractedAt = response.extractedAt ? new Date(response.extractedAt).toLocaleString() : t("ocr.unknown_date");
|
||||
this.$meta.html(t("ocr.extracted_on", { date: extractedAt }));
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error loading OCR text:", error);
|
||||
this.showError(error.message || t("ocr.failed_to_load"));
|
||||
}
|
||||
}
|
||||
|
||||
private showNoOCRAvailable() {
|
||||
const $processButton = $(`<button class="btn btn-secondary ocr-process-button" type="button">
|
||||
<span class="bx bx-play"></span> ${t("ocr.process_now")}
|
||||
</button>`);
|
||||
|
||||
$processButton.on("click", () => this.processOCR());
|
||||
|
||||
this.$content.html(`<div class="ocr-text-empty">
|
||||
<span class="bx bx-info-circle"></span> ${t("ocr.no_text_available")}
|
||||
</div>`);
|
||||
|
||||
this.$actions.append($processButton);
|
||||
this.$meta.html(t("ocr.no_text_explanation"));
|
||||
}
|
||||
|
||||
private async processOCR() {
|
||||
if (!this.currentNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $button = this.$actions.find(".ocr-process-button");
|
||||
|
||||
// Disable button and show processing state
|
||||
$button.prop("disabled", true);
|
||||
$button.html(`<span class="bx bx-loader-alt bx-spin"></span> ${t("ocr.processing")}`);
|
||||
|
||||
try {
|
||||
const response = await server.post(`ocr/process-note/${this.currentNote.noteId}`);
|
||||
|
||||
if (response.success) {
|
||||
toastService.showMessage(t("ocr.processing_started"));
|
||||
// Refresh the view after a short delay to allow processing to begin
|
||||
setTimeout(() => {
|
||||
if (this.currentNote) {
|
||||
this.doRefresh(this.currentNote);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(response.error || t("ocr.processing_failed"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error processing OCR:", error);
|
||||
toastService.showError(error.message || t("ocr.processing_failed"));
|
||||
|
||||
// Re-enable button
|
||||
$button.prop("disabled", false);
|
||||
$button.html(`<span class="bx bx-play"></span> ${t("ocr.process_now")}`);
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.$content.html(`<div class="ocr-text-error">
|
||||
<span class="bx bx-error"></span> ${message}
|
||||
</div>`);
|
||||
this.$actions.empty();
|
||||
this.$meta.empty();
|
||||
}
|
||||
|
||||
async executeWithContentElementEvent({ resolve, ntxId }: EventData<"executeWithContentElement">) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialized;
|
||||
resolve(this.$content);
|
||||
}
|
||||
}
|
||||
@@ -351,8 +351,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
|
||||
try {
|
||||
const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, {
|
||||
trim: this.viewType === "grid", // for grid only short content is needed
|
||||
showOcrText: this.parentNote.type === "search" // show OCR text only in search results
|
||||
trim: this.viewType === "grid" // for grid only short content is needed
|
||||
});
|
||||
|
||||
if (this.highlightRegex) {
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.4",
|
||||
"@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",
|
||||
"@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",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -72,10 +72,6 @@ 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,12 +65,9 @@ export default class App {
|
||||
async goToNoteInNewTab(noteTitle: string) {
|
||||
const autocomplete = this.currentNoteSplit.locator(".note-autocomplete");
|
||||
await autocomplete.fill(noteTitle);
|
||||
|
||||
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();
|
||||
await expect(this.currentNoteSplit.locator(".note-detail-empty-results")).toContainText(noteTitle);
|
||||
await autocomplete.press("ArrowDown");
|
||||
await autocomplete.press("Enter");
|
||||
}
|
||||
|
||||
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.7",
|
||||
"@types/express-http-proxy": "1.6.6",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
@@ -34,7 +34,6 @@
|
||||
"@types/stream-throttle": "0.1.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -103,16 +102,12 @@
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"time2fa": "^1.3.0",
|
||||
"tesseract.js": "6.0.1",
|
||||
"tmp": "0.2.3",
|
||||
"turndown": "7.2.0",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "8.18.3",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.0",
|
||||
"officeparser": "5.2.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"sharp": "0.34.3"
|
||||
"yauzl": "3.2.0"
|
||||
},
|
||||
"nx": {
|
||||
"name": "server",
|
||||
@@ -359,9 +354,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import log from "./services/log.js";
|
||||
import "./services/handlers.js";
|
||||
import "./becca/becca_loader.js";
|
||||
import { RESOURCE_DIR } from "./services/resource_dir.js";
|
||||
import fileSystemSyncInit from "./services/file_system_sync_init.js";
|
||||
|
||||
export default async function buildApp() {
|
||||
const app = express();
|
||||
@@ -32,6 +33,9 @@ export default async function buildApp() {
|
||||
try {
|
||||
log.info("Database initialized, LLM features available");
|
||||
log.info("LLM features ready");
|
||||
|
||||
// Initialize file system sync after database is ready
|
||||
await fileSystemSyncInit.init();
|
||||
} catch (error) {
|
||||
console.error("Error initializing LLM features:", error);
|
||||
}
|
||||
@@ -41,6 +45,9 @@ export default async function buildApp() {
|
||||
if (sql_init.isDbInitialized()) {
|
||||
try {
|
||||
log.info("LLM features ready");
|
||||
|
||||
// Initialize file system sync if database is already ready
|
||||
await fileSystemSyncInit.init();
|
||||
} catch (error) {
|
||||
console.error("Error initializing LLM features:", error);
|
||||
}
|
||||
|
||||
@@ -107,8 +107,6 @@ CREATE TABLE IF NOT EXISTS "recent_notes"
|
||||
CREATE TABLE IF NOT EXISTS "blobs" (
|
||||
`blobId` TEXT NOT NULL,
|
||||
`content` TEXT NULL DEFAULT NULL,
|
||||
`ocr_text` TEXT DEFAULT NULL,
|
||||
`ocr_last_processed` TEXT DEFAULT NULL,
|
||||
`dateModified` TEXT NOT NULL,
|
||||
`utcDateModified` TEXT NOT NULL,
|
||||
PRIMARY KEY(`blobId`)
|
||||
@@ -154,3 +152,56 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
data TEXT,
|
||||
expires INTEGER
|
||||
);
|
||||
|
||||
-- Table to store file system mappings for notes and subtrees
|
||||
CREATE TABLE IF NOT EXISTS "file_system_mappings" (
|
||||
"mappingId" TEXT NOT NULL PRIMARY KEY,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"syncDirection" TEXT NOT NULL DEFAULT 'bidirectional', -- 'bidirectional', 'trilium_to_disk', 'disk_to_trilium'
|
||||
"isActive" INTEGER NOT NULL DEFAULT 1,
|
||||
"includeSubtree" INTEGER NOT NULL DEFAULT 0,
|
||||
"preserveHierarchy" INTEGER NOT NULL DEFAULT 1,
|
||||
"contentFormat" TEXT NOT NULL DEFAULT 'auto', -- 'auto', 'markdown', 'html', 'raw'
|
||||
"excludePatterns" TEXT DEFAULT NULL, -- JSON array of glob patterns to exclude
|
||||
"lastSyncTime" TEXT DEFAULT NULL,
|
||||
"syncErrors" TEXT DEFAULT NULL, -- JSON array of recent sync errors
|
||||
"dateCreated" TEXT NOT NULL,
|
||||
"dateModified" TEXT NOT NULL,
|
||||
"utcDateCreated" TEXT NOT NULL,
|
||||
"utcDateModified" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Table to track file to note mappings for efficient lookups
|
||||
CREATE TABLE IF NOT EXISTS "file_note_mappings" (
|
||||
"fileNoteId" TEXT NOT NULL PRIMARY KEY,
|
||||
"mappingId" TEXT NOT NULL,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"fileHash" TEXT DEFAULT NULL,
|
||||
"fileModifiedTime" TEXT DEFAULT NULL,
|
||||
"lastSyncTime" TEXT DEFAULT NULL,
|
||||
"syncStatus" TEXT NOT NULL DEFAULT 'synced', -- 'synced', 'pending', 'conflict', 'error'
|
||||
"dateCreated" TEXT NOT NULL,
|
||||
"dateModified" TEXT NOT NULL,
|
||||
"utcDateCreated" TEXT NOT NULL,
|
||||
"utcDateModified" TEXT NOT NULL,
|
||||
FOREIGN KEY ("mappingId") REFERENCES "file_system_mappings" ("mappingId") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("noteId") REFERENCES "notes" ("noteId") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for quick lookup by noteId
|
||||
CREATE INDEX "IDX_file_system_mappings_noteId" ON "file_system_mappings" ("noteId");
|
||||
-- Index for finding active mappings
|
||||
CREATE INDEX "IDX_file_system_mappings_active" ON "file_system_mappings" ("isActive", "noteId");
|
||||
-- Unique constraint to prevent duplicate mappings for same note
|
||||
CREATE UNIQUE INDEX "IDX_file_system_mappings_note_unique" ON "file_system_mappings" ("noteId");
|
||||
|
||||
-- Index for quick lookup by file path
|
||||
CREATE INDEX "IDX_file_note_mappings_filePath" ON "file_note_mappings" ("filePath");
|
||||
-- Index for finding notes by mapping
|
||||
CREATE INDEX "IDX_file_note_mappings_mapping" ON "file_note_mappings" ("mappingId", "noteId");
|
||||
-- Index for finding pending syncs
|
||||
CREATE INDEX "IDX_file_note_mappings_sync_status" ON "file_note_mappings" ("syncStatus", "mappingId");
|
||||
-- Unique constraint for file path per mapping
|
||||
CREATE UNIQUE INDEX "IDX_file_note_mappings_file_unique" ON "file_note_mappings" ("mappingId", "filePath");
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 317 B |
33
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note.html
generated
vendored
Normal file
33
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note.html
generated
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<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: 58 KiB After Width: | Height: | Size: 58 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note_recent-notes.gif
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note_recent-notes.gif
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -1,78 +0,0 @@
|
||||
<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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
38
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html
generated
vendored
38
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html
generated
vendored
@@ -4,29 +4,31 @@
|
||||
child notes into one continuous view. This makes it ideal for reading extensive
|
||||
information broken into smaller, manageable segments.</p>
|
||||
<ul>
|
||||
<li><a class="reference-link" href="#root/_help_8QqnMzx393bx">Grid View</a> which
|
||||
<li data-list-item-id="e7f3117635b8c3e905c71f2839e331942"><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><a class="reference-link" href="#root/_help_mULW0Q3VojwY">List View</a> is
|
||||
<li data-list-item-id="e27a2ec6976e44512c9c52ba8d7a2ef76"><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><a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a> which
|
||||
<li data-list-item-id="eae5c2f56f9a75e7ed813fe419d4a05a4"><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><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a> which
|
||||
<li
|
||||
data-list-item-id="e24642a7a4c2443497fd814d78f1ca784"><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><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>
|
||||
<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>
|
||||
</ul>
|
||||
<p>For a quick presentation of all the supported view types, see the child
|
||||
notes of this help page, including screenshots.</p>
|
||||
@@ -37,13 +39,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/_help_oPVyFC7WL2Lp">Note Tree</a> and look for the <em>Collections</em> entry
|
||||
and select the desired type.</p>
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_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>Create a new collection.</li>
|
||||
<li>In the <a class="reference-link" href="#root/_help_BlN9DFI679QC">Ribbon</a>,
|
||||
<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>,
|
||||
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
|
||||
@@ -58,13 +60,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>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>,
|
||||
<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>,
|
||||
go to <em>Basic Properties</em> and select <em>Collection</em> as the note
|
||||
type.</li>
|
||||
<li>Still in the ribbon, go to <em>Collection Properties</em> and select the
|
||||
<li data-list-item-id="e48769aec6e389d541c275f59220f032e">Still in the ribbon, go to <em>Collection Properties</em> and select the
|
||||
desired view type.</li>
|
||||
<li>Consult the help page of the corresponding view type in order to understand
|
||||
<li data-list-item-id="e5d79e0729f8daaae2c32cce773898464">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,57 +11,58 @@
|
||||
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/_help_2FvYrpmOXm29">Table View</a>, the notes are not displayed
|
||||
in a hierarchy.</p>
|
||||
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/GTwFsgaA0lCt/_help_2FvYrpmOXm29">Table View</a>,
|
||||
the notes are not displayed in a hierarchy.</p>
|
||||
<h2>Interaction with columns</h2>
|
||||
<ul>
|
||||
<li>Create a new column by pressing <em>Add Column</em> near the last column.
|
||||
<li data-list-item-id="e6753be6b26c46c035671af86289cf196">Create a new column by pressing <em>Add Column</em> near the last column.
|
||||
<ul>
|
||||
<li>Once pressed, a text box will be displayed to set the name of the column.
|
||||
<li data-list-item-id="ea187c3f44d40774c710edf7d741a236c">Once pressed, a text box will be displayed to set the name of the column.
|
||||
Press Enter to confirm.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>To reorder a column, simply hold the mouse over the title and drag it
|
||||
<li data-list-item-id="e71e0e606cc30bcaac9c20ac2dd03a8a5">To reorder a column, simply hold the mouse over the title and drag it
|
||||
to the desired position.</li>
|
||||
<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.
|
||||
<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.
|
||||
<ul>
|
||||
<li>Press Enter to confirm.</li>
|
||||
<li>Upon renaming a column, the corresponding status attribute of all its
|
||||
<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
|
||||
notes will be changed in bulk.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>If there are many columns, use the mouse wheel to scroll.</li>
|
||||
</li>
|
||||
<li data-list-item-id="eb357e9f42fcf948eca6212aeb209a5a1">If there are many columns, use the mouse wheel to scroll.</li>
|
||||
</ul>
|
||||
<h2>Interaction with notes</h2>
|
||||
<ul>
|
||||
<li>Create a new note in any column by pressing <em>New item</em>
|
||||
<li data-list-item-id="e3acf3f583b9ed1a7e71bfe6cc9e1f3c1">Create a new note in any column by pressing <em>New item</em>
|
||||
<ul>
|
||||
<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
|
||||
<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
|
||||
by default) set to the name of the column.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>To change the state of a note, simply drag a note from one column to the
|
||||
<li data-list-item-id="e18de462534a4514576912310181a6c5b">To change the state of a note, simply drag a note from one column to the
|
||||
other to change its state.</li>
|
||||
<li>The order of the notes in each column corresponds to their position in
|
||||
<li data-list-item-id="eea36d097a82322804e0878e09b83d5dc">The order of the notes in each column corresponds to their position in
|
||||
the tree.
|
||||
<ul>
|
||||
<li>It's possible to reorder notes simply by dragging them to the desired
|
||||
<li data-list-item-id="ea374f8fd30bfbdeb07b98b8bb56c8565">It's possible to reorder notes simply by dragging them to the desired
|
||||
position within the same columns.</li>
|
||||
<li>It's also possible to drag notes across columns, at the desired position.</li>
|
||||
<li data-list-item-id="ea65032b5bb4a4ace6b551877bf695831">It's also possible to drag notes across columns, at the desired position.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>For more options, right click on a note to display a context menu with
|
||||
<li data-list-item-id="e17382c245068b6f396d96240628efeaa">For more options, right click on a note to display a context menu with
|
||||
the following options:
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
<li>If there are many notes within the column, move the mouse over the column
|
||||
<li data-list-item-id="e8f9888bf5fcb78fd22511a5d6402e313">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>
|
||||
@@ -77,5 +78,7 @@ class="admonition note">
|
||||
<h2>Interaction</h2>
|
||||
<h2>Limitations</h2>
|
||||
<ul>
|
||||
<li>It is not possible yet to use group by a relation, only by label.</li>
|
||||
</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>
|
||||
@@ -8,31 +8,31 @@
|
||||
<h2>How it works</h2>
|
||||
<p>The tabular structure is represented as such:</p>
|
||||
<ul>
|
||||
<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
|
||||
<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
|
||||
(nested notes).</li>
|
||||
<li>Each column is a <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> that
|
||||
<li data-list-item-id="ebd16c84c37c2d3782567cf4d23701354">Each column is a <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> that
|
||||
is defined on the Collection note.
|
||||
<ul>
|
||||
<li>Actually, both promoted and unpromoted attributes are supported, but it's
|
||||
<li data-list-item-id="ea05f5ec1c0c315b37f05bbb3a045583a">Actually, both promoted and unpromoted attributes are supported, but it's
|
||||
a requirement to use a label/relation definition.</li>
|
||||
<li>The promoted attributes are usually defined as inheritable in order to
|
||||
<li data-list-item-id="e2ef130cc4d15f788e50daf210847cbbb">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>If there are multiple attribute definitions with the same <code>name</code>,
|
||||
<li data-list-item-id="e1cb6afc55de7e7d038e5dc2592604e7d">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>The current item number, identified by the <code>#</code> symbol.
|
||||
<li data-list-item-id="e0ee40aa88c6ec67050cf582e1de2a511">The current item number, identified by the <code>#</code> symbol.
|
||||
<ul>
|
||||
<li>This simply counts the note and is affected by sorting.</li>
|
||||
<li data-list-item-id="e0658c976040c3dd7d7d516f3e81b5dd6">This simply counts the note and is affected by sorting.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>,
|
||||
<li data-list-item-id="ee40b5cf3b77d956abee6bbed0781c1aa"><a class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>,
|
||||
representing the unique ID used internally by Trilium</li>
|
||||
<li>The title of the note.</li>
|
||||
<li data-list-item-id="e4e24776fc43d49b40924a55037bae164">The title of the note.</li>
|
||||
</ul>
|
||||
<h2>Interaction</h2>
|
||||
<h3>Creating a new table</h3>
|
||||
@@ -43,17 +43,18 @@
|
||||
is defined on the Collection note.</p>
|
||||
<p>To create a new column, either:</p>
|
||||
<ul>
|
||||
<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
|
||||
<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
|
||||
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>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>
|
||||
<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>
|
||||
</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"
|
||||
@@ -61,27 +62,28 @@
|
||||
<h3>Context menu</h3>
|
||||
<p>There are multiple menus:</p>
|
||||
<ul>
|
||||
<li>Right clicking on a column, allows:
|
||||
<li data-list-item-id="e10284169a0dc36c347245a6fc5a4a0d2">Right clicking on a column, allows:
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Right clicking on the space to the right of the columns, allows:
|
||||
<li data-list-item-id="e913b95accaf874e109db9905b5b6f3b5">Right clicking on a row, allows:
|
||||
<ul>
|
||||
<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
|
||||
<li data-list-item-id="e4e7e38228abfb58c37ffc68616a8da6e">Opening the corresponding note of the row in a new tab, split, window
|
||||
or quick editing it.</li>
|
||||
<li>Inserting rows above, below or as a child note.</li>
|
||||
<li>Deleting the row.</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>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -90,17 +92,18 @@
|
||||
not only reflect in the table, but also as an attribute of the corresponding
|
||||
note.</p>
|
||||
<ul>
|
||||
<li>The editing will respect the type of the promoted attribute, by presenting
|
||||
<li data-list-item-id="ef5625b0a34c060243caf9297a2c959d8">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>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>
|
||||
<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>
|
||||
</ul>
|
||||
<h3>Editing columns</h3>
|
||||
<p>It is possible to edit a column by right clicking it and selecting <em>Edit column.</em> This
|
||||
@@ -114,18 +117,19 @@
|
||||
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>. However, it is possible
|
||||
to sort the data by the values of a column:</p>
|
||||
<ul>
|
||||
<li>To do so, simply click on a column.</li>
|
||||
<li>To switch between ascending or descending sort, simply click again on
|
||||
<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
|
||||
the same column. The arrow next to the column will indicate the direction
|
||||
of the sort.</li>
|
||||
<li>To disable sorting and fall back to the original order, right click any
|
||||
<li data-list-item-id="e8de6519ff78c55846e8ad949efdb1749">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>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
|
||||
<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
|
||||
the item corresponding to the column.</li>
|
||||
</ul>
|
||||
<h3>Reordering rows</h3>
|
||||
@@ -136,10 +140,12 @@
|
||||
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</p>
|
||||
<p>Reordering does have some limitations:</p>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
<h3>Nested trees</h3>
|
||||
<p>If the child notes of the collection also have their own child notes,
|
||||
@@ -150,27 +156,27 @@
|
||||
to a certain number of levels or even disable it completely. To do so,
|
||||
either:</p>
|
||||
<ul>
|
||||
<li>Go to <em>Collection Properties</em> in the <a class="reference-link"
|
||||
<li data-list-item-id="e48a95d7d971cadd6f1b53e25d567be7e">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>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
|
||||
<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
|
||||
display children and sub-children).</li>
|
||||
<li>To re-enable unlimited nesting, remove the number and press Enter.</li>
|
||||
<li data-list-item-id="e9e083d9f55b6658e88651bc005d3519f">To re-enable unlimited nesting, remove the number and press Enter.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Manually set <code>maxNestingDepth</code> to the desired value.</li>
|
||||
<li data-list-item-id="e6a4e204e9145abbceebcc96787a61183">Manually set <code>maxNestingDepth</code> to the desired value.</li>
|
||||
</ul>
|
||||
<p>Limitations:</p>
|
||||
<ul>
|
||||
<li>While in this mode, it's not possible to reorder notes.</li>
|
||||
<li data-list-item-id="ea837ad3a83df0a8939a2cc0d586cf58f">While in this mode, it's not possible to reorder notes.</li>
|
||||
</ul>
|
||||
<h2>Limitations</h2>
|
||||
<ul>
|
||||
<li>Multi-value labels and relations are not supported. If a <a class="reference-link"
|
||||
<li data-list-item-id="efcbcbbae267cd2ed3ddac4d512a6c5a1">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>There is no support to filter the rows by a certain criteria. Consider
|
||||
<li data-list-item-id="e8eb7e91706b56513d6f85828a7afa02f">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>
|
||||
@@ -181,8 +187,8 @@
|
||||
of the <a class="reference-link" href="#root/_help_eIg8jdvaoNNd">Search</a>.</p>
|
||||
<p>However, there are also some limitations:</p>
|
||||
<ul>
|
||||
<li>It's not possible to reorder notes.</li>
|
||||
<li>It's not possible to add a new row.</li>
|
||||
<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>
|
||||
</ul>
|
||||
<p>Columns are supported, by being defined as <a class="reference-link"
|
||||
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> to the
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
"go-to-next-note-title": "跳转到下一条笔记",
|
||||
"new-note-title": "新建笔记",
|
||||
"search-notes-title": "搜索笔记",
|
||||
"jump-to-note-title": "跳转到笔记",
|
||||
"calendar-title": "日历",
|
||||
"recent-changes-title": "最近更改",
|
||||
"bookmarks-title": "书签",
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"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,7 +3,6 @@
|
||||
"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",
|
||||
@@ -22,8 +21,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",
|
||||
"clone-notes-to": "Clone selected notes",
|
||||
"move-notes-to": "Move selected notes",
|
||||
"cloneNotesTo": "Clone selected notes",
|
||||
"moveNotesTo": "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",
|
||||
@@ -105,103 +104,6 @@
|
||||
"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",
|
||||
@@ -327,7 +229,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...",
|
||||
"jump-to-note-title": "Jump to Note",
|
||||
"calendar-title": "Calendar",
|
||||
"recent-changes-title": "Recent Changes",
|
||||
"bookmarks-title": "Bookmarks",
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"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,6 +216,7 @@
|
||||
"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,6 +209,7 @@
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons";
|
||||
import BBlob from "./entities/bblob.js";
|
||||
import BRecentNote from "./entities/brecent_note.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import type BFileSystemMapping from "./entities/bfile_system_mapping.js";
|
||||
import type BFileNoteMapping from "./entities/bfile_note_mapping.js";
|
||||
|
||||
interface AttachmentOpts {
|
||||
includeContentLength?: boolean;
|
||||
@@ -32,6 +34,8 @@ export default class Becca {
|
||||
attributeIndex!: Record<string, BAttribute[]>;
|
||||
options!: Record<string, BOption>;
|
||||
etapiTokens!: Record<string, BEtapiToken>;
|
||||
fileSystemMappings!: Record<string, BFileSystemMapping>;
|
||||
fileNoteMappings!: Record<string, BFileNoteMapping>;
|
||||
|
||||
allNoteSetCache: NoteSet | null;
|
||||
|
||||
@@ -48,6 +52,8 @@ export default class Becca {
|
||||
this.attributeIndex = {};
|
||||
this.options = {};
|
||||
this.etapiTokens = {};
|
||||
this.fileSystemMappings = {};
|
||||
this.fileNoteMappings = {};
|
||||
|
||||
this.dirtyNoteSetCache();
|
||||
|
||||
@@ -213,6 +219,39 @@ export default class Becca {
|
||||
return this.etapiTokens[etapiTokenId];
|
||||
}
|
||||
|
||||
getFileSystemMapping(mappingId: string): BFileSystemMapping | null {
|
||||
return this.fileSystemMappings[mappingId];
|
||||
}
|
||||
|
||||
getFileSystemMappingOrThrow(mappingId: string): BFileSystemMapping {
|
||||
const mapping = this.getFileSystemMapping(mappingId);
|
||||
if (!mapping) {
|
||||
throw new NotFoundError(`File system mapping '${mappingId}' has not been found.`);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
getFileNoteMapping(fileNoteId: string): BFileNoteMapping | null {
|
||||
return this.fileNoteMappings[fileNoteId];
|
||||
}
|
||||
|
||||
getFileNoteMappingOrThrow(fileNoteId: string): BFileNoteMapping {
|
||||
const mapping = this.getFileNoteMapping(fileNoteId);
|
||||
if (!mapping) {
|
||||
throw new NotFoundError(`File note mapping '${fileNoteId}' has not been found.`);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
getFileSystemMappingByNoteId(noteId: string): BFileSystemMapping | null {
|
||||
for (const mapping of Object.values(this.fileSystemMappings)) {
|
||||
if (mapping.noteId === noteId) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
|
||||
if (!entityName || !entityId) {
|
||||
return null;
|
||||
@@ -222,6 +261,10 @@ export default class Becca {
|
||||
return this.getRevision(entityId);
|
||||
} else if (entityName === "attachments") {
|
||||
return this.getAttachment(entityId);
|
||||
} else if (entityName === "file_system_mappings") {
|
||||
return this.getFileSystemMapping(entityId);
|
||||
} else if (entityName === "file_note_mappings") {
|
||||
return this.getFileNoteMapping(entityId);
|
||||
}
|
||||
|
||||
const camelCaseEntityName = entityName.toLowerCase().replace(/(_[a-z])/g, (group) => group.toUpperCase().replace("_", ""));
|
||||
|
||||
@@ -9,18 +9,22 @@ import BBranch from "./entities/bbranch.js";
|
||||
import BAttribute from "./entities/battribute.js";
|
||||
import BOption from "./entities/boption.js";
|
||||
import BEtapiToken from "./entities/betapi_token.js";
|
||||
import BFileSystemMapping from "./entities/bfile_system_mapping.js";
|
||||
import BFileNoteMapping from "./entities/bfile_note_mapping.js";
|
||||
import cls from "../services/cls.js";
|
||||
import entityConstructor from "../becca/entity_constructor.js";
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import type { FileSystemMappingRow } from "./entities/bfile_system_mapping.js";
|
||||
import type { FileNoteMappingRow } from "./entities/bfile_note_mapping.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import ws from "../services/ws.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
|
||||
export const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
const sqlInit = (await import("../services/sql_init.js")).default;
|
||||
// We have to import async since options init requires keyboard actions which require translations.
|
||||
const options_init = (await import("../services/options_init.js")).default;
|
||||
|
||||
dbReady.then(() => {
|
||||
sqlInit.dbReady.then(() => {
|
||||
cls.init(() => {
|
||||
load();
|
||||
|
||||
@@ -64,6 +68,14 @@ function load() {
|
||||
new BEtapiToken(row);
|
||||
}
|
||||
|
||||
for (const row of sql.getRows<FileSystemMappingRow>(/*sql*/`SELECT mappingId, noteId, filePath, syncDirection, isActive, includeSubtree, preserveHierarchy, contentFormat, excludePatterns, lastSyncTime, syncErrors, dateCreated, dateModified, utcDateCreated, utcDateModified FROM file_system_mappings`)) {
|
||||
new BFileSystemMapping(row);
|
||||
}
|
||||
|
||||
for (const row of sql.getRows<FileNoteMappingRow>(/*sql*/`SELECT fileNoteId, mappingId, noteId, filePath, fileHash, fileModifiedTime, lastSyncTime, syncStatus, dateCreated, dateModified, utcDateCreated, utcDateModified FROM file_note_mappings`)) {
|
||||
new BFileNoteMapping(row);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for (const noteId in becca.notes) {
|
||||
@@ -86,7 +98,7 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({ entity
|
||||
return;
|
||||
}
|
||||
|
||||
if (["notes", "branches", "attributes", "etapi_tokens", "options"].includes(entityName)) {
|
||||
if (["notes", "branches", "attributes", "etapi_tokens", "options", "file_system_mappings", "file_note_mappings"].includes(entityName)) {
|
||||
const EntityClass = entityConstructor.getEntityFromEntityName(entityName);
|
||||
const primaryKeyName = EntityClass.primaryKeyName;
|
||||
|
||||
@@ -144,6 +156,10 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENT
|
||||
attributeDeleted(entityId);
|
||||
} else if (entityName === "etapi_tokens") {
|
||||
etapiTokenDeleted(entityId);
|
||||
} else if (entityName === "file_system_mappings") {
|
||||
fileSystemMappingDeleted(entityId);
|
||||
} else if (entityName === "file_note_mappings") {
|
||||
fileNoteMappingDeleted(entityId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -279,6 +295,14 @@ function etapiTokenDeleted(etapiTokenId: string) {
|
||||
delete becca.etapiTokens[etapiTokenId];
|
||||
}
|
||||
|
||||
function fileSystemMappingDeleted(mappingId: string) {
|
||||
delete becca.fileSystemMappings[mappingId];
|
||||
}
|
||||
|
||||
function fileNoteMappingDeleted(fileNoteId: string) {
|
||||
delete becca.fileNoteMappings[fileNoteId];
|
||||
}
|
||||
|
||||
|
||||
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
|
||||
try {
|
||||
|
||||
@@ -10,12 +10,11 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
return "blobId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["blobId", "content", "ocr_text"];
|
||||
return ["blobId", "content"];
|
||||
}
|
||||
|
||||
content!: string | Buffer;
|
||||
contentLength!: number;
|
||||
ocr_text?: string | null;
|
||||
|
||||
constructor(row: BlobRow) {
|
||||
super();
|
||||
@@ -26,7 +25,6 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
this.blobId = row.blobId;
|
||||
this.content = row.content;
|
||||
this.contentLength = row.contentLength;
|
||||
this.ocr_text = row.ocr_text;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
}
|
||||
@@ -36,7 +34,6 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
blobId: this.blobId,
|
||||
content: this.content || null,
|
||||
contentLength: this.contentLength,
|
||||
ocr_text: this.ocr_text || null,
|
||||
dateModified: this.dateModified,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
|
||||
233
apps/server/src/becca/entities/bfile_note_mapping.ts
Normal file
233
apps/server/src/becca/entities/bfile_note_mapping.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
"use strict";
|
||||
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import { newEntityId } from "../../services/utils.js";
|
||||
|
||||
export interface FileNoteMappingRow {
|
||||
fileNoteId?: string;
|
||||
mappingId: string;
|
||||
noteId: string;
|
||||
filePath: string;
|
||||
fileHash?: string | null;
|
||||
fileModifiedTime?: string | null;
|
||||
lastSyncTime?: string | null;
|
||||
syncStatus?: 'synced' | 'pending' | 'conflict' | 'error';
|
||||
dateCreated?: string;
|
||||
dateModified?: string;
|
||||
utcDateCreated?: string;
|
||||
utcDateModified?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileNoteMapping represents the mapping between a specific file and a specific note
|
||||
* This is used for tracking sync status and file metadata
|
||||
*/
|
||||
class BFileNoteMapping extends AbstractBeccaEntity<BFileNoteMapping> {
|
||||
static get entityName() {
|
||||
return "file_note_mappings";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "fileNoteId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["fileNoteId", "mappingId", "noteId", "filePath", "fileHash", "syncStatus"];
|
||||
}
|
||||
|
||||
fileNoteId!: string;
|
||||
mappingId!: string;
|
||||
noteId!: string;
|
||||
filePath!: string;
|
||||
fileHash?: string | null;
|
||||
fileModifiedTime?: string | null;
|
||||
lastSyncTime?: string | null;
|
||||
syncStatus!: 'synced' | 'pending' | 'conflict' | 'error';
|
||||
|
||||
constructor(row?: FileNoteMappingRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: FileNoteMappingRow) {
|
||||
this.update([
|
||||
row.fileNoteId,
|
||||
row.mappingId,
|
||||
row.noteId,
|
||||
row.filePath,
|
||||
row.fileHash,
|
||||
row.fileModifiedTime,
|
||||
row.lastSyncTime,
|
||||
row.syncStatus || 'synced',
|
||||
row.dateCreated,
|
||||
row.dateModified,
|
||||
row.utcDateCreated,
|
||||
row.utcDateModified
|
||||
]);
|
||||
}
|
||||
|
||||
update([
|
||||
fileNoteId,
|
||||
mappingId,
|
||||
noteId,
|
||||
filePath,
|
||||
fileHash,
|
||||
fileModifiedTime,
|
||||
lastSyncTime,
|
||||
syncStatus,
|
||||
dateCreated,
|
||||
dateModified,
|
||||
utcDateCreated,
|
||||
utcDateModified
|
||||
]: any) {
|
||||
this.fileNoteId = fileNoteId;
|
||||
this.mappingId = mappingId;
|
||||
this.noteId = noteId;
|
||||
this.filePath = filePath;
|
||||
this.fileHash = fileHash;
|
||||
this.fileModifiedTime = fileModifiedTime;
|
||||
this.lastSyncTime = lastSyncTime;
|
||||
this.syncStatus = syncStatus || 'synced';
|
||||
this.dateCreated = dateCreated;
|
||||
this.dateModified = dateModified;
|
||||
this.utcDateCreated = utcDateCreated;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.fileNoteId) {
|
||||
this.becca.fileNoteMappings = this.becca.fileNoteMappings || {};
|
||||
this.becca.fileNoteMappings[this.fileNoteId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
get mapping() {
|
||||
return this.becca.fileSystemMappings?.[this.mappingId];
|
||||
}
|
||||
|
||||
getNote() {
|
||||
const note = this.becca.getNote(this.noteId);
|
||||
if (!note) {
|
||||
throw new Error(`Note '${this.noteId}' for file note mapping '${this.fileNoteId}' does not exist.`);
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
getMapping() {
|
||||
const mapping = this.mapping;
|
||||
if (!mapping) {
|
||||
throw new Error(`File system mapping '${this.mappingId}' for file note mapping '${this.fileNoteId}' does not exist.`);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this mapping as needing sync
|
||||
*/
|
||||
markPending() {
|
||||
this.syncStatus = 'pending';
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this mapping as having a conflict
|
||||
*/
|
||||
markConflict() {
|
||||
this.syncStatus = 'conflict';
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this mapping as having an error
|
||||
*/
|
||||
markError() {
|
||||
this.syncStatus = 'error';
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this mapping as synced and update sync time
|
||||
*/
|
||||
markSynced(fileHash?: string, fileModifiedTime?: string) {
|
||||
this.syncStatus = 'synced';
|
||||
this.lastSyncTime = dateUtils.utcNowDateTime();
|
||||
|
||||
if (fileHash !== undefined) {
|
||||
this.fileHash = fileHash;
|
||||
}
|
||||
|
||||
if (fileModifiedTime !== undefined) {
|
||||
this.fileModifiedTime = fileModifiedTime;
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file has been modified since last sync
|
||||
*/
|
||||
hasFileChanged(currentFileHash: string, currentModifiedTime: string): boolean {
|
||||
return this.fileHash !== currentFileHash || this.fileModifiedTime !== currentModifiedTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the note has been modified since last sync
|
||||
*/
|
||||
hasNoteChanged(): boolean {
|
||||
const note = this.note;
|
||||
if (!note) return false;
|
||||
|
||||
if (!this.lastSyncTime) return true;
|
||||
|
||||
return (note.utcDateModified ?? note.dateModified ?? note.utcDateCreated) > this.lastSyncTime;
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
if (!this.fileNoteId) {
|
||||
this.fileNoteId = newEntityId();
|
||||
}
|
||||
|
||||
if (!this.dateCreated) {
|
||||
this.dateCreated = dateUtils.localNowDateTime();
|
||||
}
|
||||
|
||||
if (!this.utcDateCreated) {
|
||||
this.utcDateCreated = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
this.dateModified = dateUtils.localNowDateTime();
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo(): FileNoteMappingRow {
|
||||
return {
|
||||
fileNoteId: this.fileNoteId,
|
||||
mappingId: this.mappingId,
|
||||
noteId: this.noteId,
|
||||
filePath: this.filePath,
|
||||
fileHash: this.fileHash,
|
||||
fileModifiedTime: this.fileModifiedTime,
|
||||
lastSyncTime: this.lastSyncTime,
|
||||
syncStatus: this.syncStatus,
|
||||
dateCreated: this.dateCreated,
|
||||
dateModified: this.dateModified,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BFileNoteMapping;
|
||||
236
apps/server/src/becca/entities/bfile_system_mapping.ts
Normal file
236
apps/server/src/becca/entities/bfile_system_mapping.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
"use strict";
|
||||
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import { newEntityId } from "../../services/utils.js";
|
||||
|
||||
export interface FileSystemMappingRow {
|
||||
mappingId?: string;
|
||||
noteId: string;
|
||||
filePath: string;
|
||||
syncDirection?: 'bidirectional' | 'trilium_to_disk' | 'disk_to_trilium';
|
||||
isActive?: number;
|
||||
includeSubtree?: number;
|
||||
preserveHierarchy?: number;
|
||||
contentFormat?: 'auto' | 'markdown' | 'html' | 'raw';
|
||||
excludePatterns?: string | null;
|
||||
lastSyncTime?: string | null;
|
||||
syncErrors?: string | null;
|
||||
dateCreated?: string;
|
||||
dateModified?: string;
|
||||
utcDateCreated?: string;
|
||||
utcDateModified?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystemMapping represents a mapping between a note/subtree and a file system path
|
||||
*/
|
||||
class BFileSystemMapping extends AbstractBeccaEntity<BFileSystemMapping> {
|
||||
static get entityName() {
|
||||
return "file_system_mappings";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "mappingId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["mappingId", "noteId", "filePath", "syncDirection", "isActive", "includeSubtree", "preserveHierarchy", "contentFormat"];
|
||||
}
|
||||
|
||||
mappingId!: string;
|
||||
noteId!: string;
|
||||
filePath!: string;
|
||||
syncDirection!: 'bidirectional' | 'trilium_to_disk' | 'disk_to_trilium';
|
||||
isActive!: boolean;
|
||||
includeSubtree!: boolean;
|
||||
preserveHierarchy!: boolean;
|
||||
contentFormat!: 'auto' | 'markdown' | 'html' | 'raw';
|
||||
excludePatterns?: (string | RegExp)[] | null;
|
||||
lastSyncTime?: string | null;
|
||||
syncErrors?: string[] | null;
|
||||
|
||||
constructor(row?: FileSystemMappingRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: FileSystemMappingRow) {
|
||||
this.update([
|
||||
row.mappingId,
|
||||
row.noteId,
|
||||
row.filePath,
|
||||
row.syncDirection || 'bidirectional',
|
||||
row.isActive !== undefined ? row.isActive : 1,
|
||||
row.includeSubtree !== undefined ? row.includeSubtree : 0,
|
||||
row.preserveHierarchy !== undefined ? row.preserveHierarchy : 1,
|
||||
row.contentFormat || 'auto',
|
||||
row.excludePatterns,
|
||||
row.lastSyncTime,
|
||||
row.syncErrors,
|
||||
row.dateCreated,
|
||||
row.dateModified,
|
||||
row.utcDateCreated,
|
||||
row.utcDateModified
|
||||
]);
|
||||
}
|
||||
|
||||
update([
|
||||
mappingId,
|
||||
noteId,
|
||||
filePath,
|
||||
syncDirection,
|
||||
isActive,
|
||||
includeSubtree,
|
||||
preserveHierarchy,
|
||||
contentFormat,
|
||||
excludePatterns,
|
||||
lastSyncTime,
|
||||
syncErrors,
|
||||
dateCreated,
|
||||
dateModified,
|
||||
utcDateCreated,
|
||||
utcDateModified
|
||||
]: any) {
|
||||
this.mappingId = mappingId;
|
||||
this.noteId = noteId;
|
||||
this.filePath = filePath;
|
||||
this.syncDirection = syncDirection || 'bidirectional';
|
||||
this.isActive = !!isActive;
|
||||
this.includeSubtree = !!includeSubtree;
|
||||
this.preserveHierarchy = !!preserveHierarchy;
|
||||
this.contentFormat = contentFormat || 'auto';
|
||||
|
||||
// Parse JSON strings for arrays
|
||||
try {
|
||||
this.excludePatterns = excludePatterns ? JSON.parse(excludePatterns) : null;
|
||||
} catch {
|
||||
this.excludePatterns = null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.syncErrors = syncErrors ? JSON.parse(syncErrors) : null;
|
||||
} catch {
|
||||
this.syncErrors = null;
|
||||
}
|
||||
|
||||
this.lastSyncTime = lastSyncTime;
|
||||
this.dateCreated = dateCreated;
|
||||
this.dateModified = dateModified;
|
||||
this.utcDateCreated = utcDateCreated;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.mappingId) {
|
||||
this.becca.fileSystemMappings = this.becca.fileSystemMappings || {};
|
||||
this.becca.fileSystemMappings[this.mappingId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
getNote() {
|
||||
const note = this.becca.getNote(this.noteId);
|
||||
if (!note) {
|
||||
throw new Error(`Note '${this.noteId}' for file system mapping '${this.mappingId}' does not exist.`);
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the mapping allows syncing from Trilium to disk
|
||||
*/
|
||||
get canSyncToDisk(): boolean {
|
||||
return this.isActive && (this.syncDirection === 'bidirectional' || this.syncDirection === 'trilium_to_disk');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the mapping allows syncing from disk to Trilium
|
||||
*/
|
||||
get canSyncFromDisk(): boolean {
|
||||
return this.isActive && (this.syncDirection === 'bidirectional' || this.syncDirection === 'disk_to_trilium');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sync error to the errors list
|
||||
*/
|
||||
addSyncError(error: string) {
|
||||
if (!this.syncErrors) {
|
||||
this.syncErrors = [];
|
||||
}
|
||||
this.syncErrors.push(error);
|
||||
|
||||
// Keep only the last 10 errors
|
||||
if (this.syncErrors.length > 10) {
|
||||
this.syncErrors = this.syncErrors.slice(-10);
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync errors
|
||||
*/
|
||||
clearSyncErrors() {
|
||||
this.syncErrors = null;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last sync time
|
||||
*/
|
||||
updateLastSyncTime() {
|
||||
this.lastSyncTime = dateUtils.utcNowDateTime();
|
||||
this.save();
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
if (!this.mappingId) {
|
||||
this.mappingId = newEntityId();
|
||||
}
|
||||
|
||||
if (!this.dateCreated) {
|
||||
this.dateCreated = dateUtils.localNowDateTime();
|
||||
}
|
||||
|
||||
if (!this.utcDateCreated) {
|
||||
this.utcDateCreated = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
this.dateModified = dateUtils.localNowDateTime();
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo(): FileSystemMappingRow {
|
||||
return {
|
||||
mappingId: this.mappingId,
|
||||
noteId: this.noteId,
|
||||
filePath: this.filePath,
|
||||
syncDirection: this.syncDirection,
|
||||
isActive: this.isActive ? 1 : 0,
|
||||
includeSubtree: this.includeSubtree ? 1 : 0,
|
||||
preserveHierarchy: this.preserveHierarchy ? 1 : 0,
|
||||
contentFormat: this.contentFormat,
|
||||
excludePatterns: this.excludePatterns ? JSON.stringify(this.excludePatterns) : null,
|
||||
lastSyncTime: this.lastSyncTime,
|
||||
syncErrors: this.syncErrors ? JSON.stringify(this.syncErrors) : null,
|
||||
dateCreated: this.dateCreated,
|
||||
dateModified: this.dateModified,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BFileSystemMapping;
|
||||
@@ -9,6 +9,8 @@ import BNote from "./entities/bnote.js";
|
||||
import BOption from "./entities/boption.js";
|
||||
import BRecentNote from "./entities/brecent_note.js";
|
||||
import BRevision from "./entities/brevision.js";
|
||||
import BFileSystemMapping from "./entities/bfile_system_mapping.js";
|
||||
import BFileNoteMapping from "./entities/bfile_note_mapping.js";
|
||||
|
||||
type EntityClass = new (row?: any) => AbstractBeccaEntity<any>;
|
||||
|
||||
@@ -21,7 +23,9 @@ const ENTITY_NAME_TO_ENTITY: Record<string, ConstructorData<any> & EntityClass>
|
||||
notes: BNote,
|
||||
options: BOption,
|
||||
recent_notes: BRecentNote,
|
||||
revisions: BRevision
|
||||
revisions: BRevision,
|
||||
file_system_mappings: BFileSystemMapping,
|
||||
file_note_mappings: BFileNoteMapping
|
||||
};
|
||||
|
||||
function getEntityFromEntityName(entityName: keyof typeof ENTITY_NAME_TO_ENTITY) {
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
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,28 +21,25 @@ export default () => {
|
||||
note.mime = "";
|
||||
note.save();
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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,23 +6,62 @@
|
||||
|
||||
// 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
|
||||
// Add file system mapping support
|
||||
{
|
||||
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);
|
||||
sql: /*sql*/`
|
||||
-- Table to store file system mappings for notes and subtrees
|
||||
CREATE TABLE IF NOT EXISTS "file_system_mappings" (
|
||||
"mappingId" TEXT NOT NULL PRIMARY KEY,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"syncDirection" TEXT NOT NULL DEFAULT 'bidirectional', -- 'bidirectional', 'trilium_to_disk', 'disk_to_trilium'
|
||||
"isActive" INTEGER NOT NULL DEFAULT 1,
|
||||
"includeSubtree" INTEGER NOT NULL DEFAULT 0,
|
||||
"preserveHierarchy" INTEGER NOT NULL DEFAULT 1,
|
||||
"contentFormat" TEXT NOT NULL DEFAULT 'auto', -- 'auto', 'markdown', 'html', 'raw'
|
||||
"excludePatterns" TEXT DEFAULT NULL, -- JSON array of glob patterns to exclude
|
||||
"lastSyncTime" TEXT DEFAULT NULL,
|
||||
"syncErrors" TEXT DEFAULT NULL, -- JSON array of recent sync errors
|
||||
"dateCreated" TEXT NOT NULL,
|
||||
"dateModified" TEXT NOT NULL,
|
||||
"utcDateCreated" TEXT NOT NULL,
|
||||
"utcDateModified" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Index for quick lookup by noteId
|
||||
CREATE INDEX "IDX_file_system_mappings_noteId" ON "file_system_mappings" ("noteId");
|
||||
-- Index for finding active mappings
|
||||
CREATE INDEX "IDX_file_system_mappings_active" ON "file_system_mappings" ("isActive", "noteId");
|
||||
-- Unique constraint to prevent duplicate mappings for same note
|
||||
CREATE UNIQUE INDEX "IDX_file_system_mappings_note_unique" ON "file_system_mappings" ("noteId");
|
||||
|
||||
-- Table to track file to note mappings for efficient lookups
|
||||
CREATE TABLE IF NOT EXISTS "file_note_mappings" (
|
||||
"fileNoteId" TEXT NOT NULL PRIMARY KEY,
|
||||
"mappingId" TEXT NOT NULL,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"fileHash" TEXT DEFAULT NULL,
|
||||
"fileModifiedTime" TEXT DEFAULT NULL,
|
||||
"lastSyncTime" TEXT DEFAULT NULL,
|
||||
"syncStatus" TEXT NOT NULL DEFAULT 'synced', -- 'synced', 'pending', 'conflict', 'error'
|
||||
"dateCreated" TEXT NOT NULL,
|
||||
"dateModified" TEXT NOT NULL,
|
||||
"utcDateCreated" TEXT NOT NULL,
|
||||
"utcDateModified" TEXT NOT NULL,
|
||||
FOREIGN KEY ("mappingId") REFERENCES "file_system_mappings" ("mappingId") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("noteId") REFERENCES "notes" ("noteId") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for quick lookup by file path
|
||||
CREATE INDEX "IDX_file_note_mappings_filePath" ON "file_note_mappings" ("filePath");
|
||||
-- Index for finding notes by mapping
|
||||
CREATE INDEX "IDX_file_note_mappings_mapping" ON "file_note_mappings" ("mappingId", "noteId");
|
||||
-- Index for finding pending syncs
|
||||
CREATE INDEX "IDX_file_note_mappings_sync_status" ON "file_note_mappings" ("syncStatus", "mappingId");
|
||||
-- Unique constraint for file path per mapping
|
||||
CREATE UNIQUE INDEX "IDX_file_note_mappings_file_unique" ON "file_note_mappings" ("mappingId", "filePath");
|
||||
`
|
||||
},
|
||||
// Migrate geo map to collection
|
||||
|
||||
297
apps/server/src/routes/api/file_system_sync.ts
Normal file
297
apps/server/src/routes/api/file_system_sync.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import BFileSystemMapping from "../../becca/entities/bfile_system_mapping.js";
|
||||
import fileSystemSyncInit from "../../services/file_system_sync_init.js";
|
||||
import log from "../../services/log.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { router, asyncApiRoute, apiRoute } from "../route_api.js";
|
||||
|
||||
interface FileStat {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
// Get all file system mappings
|
||||
apiRoute("get", "/mappings", () => {
|
||||
const mappings = Object.values(becca.fileSystemMappings || {}).map(mapping => ({
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: mapping.noteId,
|
||||
filePath: mapping.filePath,
|
||||
syncDirection: mapping.syncDirection,
|
||||
isActive: mapping.isActive,
|
||||
includeSubtree: mapping.includeSubtree,
|
||||
preserveHierarchy: mapping.preserveHierarchy,
|
||||
contentFormat: mapping.contentFormat,
|
||||
excludePatterns: mapping.excludePatterns,
|
||||
lastSyncTime: mapping.lastSyncTime,
|
||||
syncErrors: mapping.syncErrors,
|
||||
dateCreated: mapping.dateCreated,
|
||||
dateModified: mapping.dateModified
|
||||
}));
|
||||
|
||||
return mappings;
|
||||
});
|
||||
|
||||
// Get a specific file system mapping
|
||||
apiRoute("get", "/mappings/:mappingId", (req) => {
|
||||
const { mappingId } = req.params;
|
||||
const mapping = becca.fileSystemMappings[mappingId];
|
||||
|
||||
if (!mapping) {
|
||||
return [404, { error: "Mapping not found" }];
|
||||
}
|
||||
|
||||
return {
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: mapping.noteId,
|
||||
filePath: mapping.filePath,
|
||||
syncDirection: mapping.syncDirection,
|
||||
isActive: mapping.isActive,
|
||||
includeSubtree: mapping.includeSubtree,
|
||||
preserveHierarchy: mapping.preserveHierarchy,
|
||||
contentFormat: mapping.contentFormat,
|
||||
excludePatterns: mapping.excludePatterns,
|
||||
lastSyncTime: mapping.lastSyncTime,
|
||||
syncErrors: mapping.syncErrors,
|
||||
dateCreated: mapping.dateCreated,
|
||||
dateModified: mapping.dateModified
|
||||
};
|
||||
});
|
||||
|
||||
// Create a new file system mapping
|
||||
asyncApiRoute("post", "/mappings", async (req) => {
|
||||
const {
|
||||
noteId,
|
||||
filePath,
|
||||
syncDirection = 'bidirectional',
|
||||
isActive = true,
|
||||
includeSubtree = false,
|
||||
preserveHierarchy = true,
|
||||
contentFormat = 'auto',
|
||||
excludePatterns = null
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!noteId || !filePath) {
|
||||
throw new ValidationError("noteId and filePath are required");
|
||||
}
|
||||
|
||||
// Validate note exists
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
throw new ValidationError(`Note ${noteId} not found`);
|
||||
}
|
||||
|
||||
// Check if mapping already exists for this note
|
||||
const existingMapping = becca.getFileSystemMappingByNoteId(noteId);
|
||||
if (existingMapping) {
|
||||
throw new ValidationError(`File system mapping already exists for note ${noteId}`);
|
||||
}
|
||||
|
||||
// Validate file path exists
|
||||
const normalizedPath = path.resolve(filePath);
|
||||
if (!await fs.pathExists(normalizedPath)) {
|
||||
throw new ValidationError(`File path does not exist: ${normalizedPath}`);
|
||||
}
|
||||
|
||||
// Validate sync direction
|
||||
const validDirections = ['bidirectional', 'trilium_to_disk', 'disk_to_trilium'];
|
||||
if (!validDirections.includes(syncDirection)) {
|
||||
throw new ValidationError(`Invalid sync direction. Must be one of: ${validDirections.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate content format
|
||||
const validFormats = ['auto', 'markdown', 'html', 'raw'];
|
||||
if (!validFormats.includes(contentFormat)) {
|
||||
throw new ValidationError(`Invalid content format. Must be one of: ${validFormats.join(', ')}`);
|
||||
}
|
||||
|
||||
// Create the mapping
|
||||
const mapping = new BFileSystemMapping({
|
||||
noteId,
|
||||
filePath: normalizedPath,
|
||||
syncDirection,
|
||||
isActive: isActive ? 1 : 0,
|
||||
includeSubtree: includeSubtree ? 1 : 0,
|
||||
preserveHierarchy: preserveHierarchy ? 1 : 0,
|
||||
contentFormat,
|
||||
excludePatterns: Array.isArray(excludePatterns) ? JSON.stringify(excludePatterns) : excludePatterns
|
||||
}).save();
|
||||
|
||||
log.info(`Created file system mapping ${mapping.mappingId} for note ${noteId} -> ${normalizedPath}`);
|
||||
|
||||
return [201, {
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: mapping.noteId,
|
||||
filePath: mapping.filePath,
|
||||
syncDirection: mapping.syncDirection,
|
||||
isActive: mapping.isActive,
|
||||
includeSubtree: mapping.includeSubtree,
|
||||
preserveHierarchy: mapping.preserveHierarchy,
|
||||
contentFormat: mapping.contentFormat,
|
||||
excludePatterns: mapping.excludePatterns
|
||||
}];
|
||||
});
|
||||
|
||||
// Update a file system mapping
|
||||
asyncApiRoute("put", "/mappings/:mappingId", async (req) => {
|
||||
const { mappingId } = req.params;
|
||||
const mapping = becca.fileSystemMappings[mappingId];
|
||||
|
||||
if (!mapping) {
|
||||
return [404, { error: "Mapping not found" }];
|
||||
}
|
||||
|
||||
const {
|
||||
filePath,
|
||||
syncDirection,
|
||||
isActive,
|
||||
includeSubtree,
|
||||
preserveHierarchy,
|
||||
contentFormat,
|
||||
excludePatterns
|
||||
} = req.body;
|
||||
|
||||
// Update fields if provided
|
||||
if (filePath !== undefined) {
|
||||
const normalizedPath = path.resolve(filePath);
|
||||
if (!await fs.pathExists(normalizedPath)) {
|
||||
throw new ValidationError(`File path does not exist: ${normalizedPath}`);
|
||||
}
|
||||
mapping.filePath = normalizedPath;
|
||||
}
|
||||
|
||||
if (syncDirection !== undefined) {
|
||||
const validDirections = ['bidirectional', 'trilium_to_disk', 'disk_to_trilium'];
|
||||
if (!validDirections.includes(syncDirection)) {
|
||||
throw new ValidationError(`Invalid sync direction. Must be one of: ${validDirections.join(', ')}`);
|
||||
}
|
||||
mapping.syncDirection = syncDirection;
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
mapping.isActive = !!isActive;
|
||||
}
|
||||
|
||||
if (includeSubtree !== undefined) {
|
||||
mapping.includeSubtree = !!includeSubtree;
|
||||
}
|
||||
|
||||
if (preserveHierarchy !== undefined) {
|
||||
mapping.preserveHierarchy = !!preserveHierarchy;
|
||||
}
|
||||
|
||||
if (contentFormat !== undefined) {
|
||||
const validFormats = ['auto', 'markdown', 'html', 'raw'];
|
||||
if (!validFormats.includes(contentFormat)) {
|
||||
throw new ValidationError(`Invalid content format. Must be one of: ${validFormats.join(', ')}`);
|
||||
}
|
||||
mapping.contentFormat = contentFormat;
|
||||
}
|
||||
|
||||
if (excludePatterns !== undefined) {
|
||||
mapping.excludePatterns = Array.isArray(excludePatterns) ? excludePatterns : null;
|
||||
}
|
||||
|
||||
mapping.save();
|
||||
|
||||
log.info(`Updated file system mapping ${mappingId}`);
|
||||
|
||||
return {
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: mapping.noteId,
|
||||
filePath: mapping.filePath,
|
||||
syncDirection: mapping.syncDirection,
|
||||
isActive: mapping.isActive,
|
||||
includeSubtree: mapping.includeSubtree,
|
||||
preserveHierarchy: mapping.preserveHierarchy,
|
||||
contentFormat: mapping.contentFormat,
|
||||
excludePatterns: mapping.excludePatterns
|
||||
};
|
||||
});
|
||||
|
||||
// Delete a file system mapping
|
||||
apiRoute("delete", "/mappings/:mappingId", (req) => {
|
||||
const { mappingId } = req.params;
|
||||
const mapping = becca.fileSystemMappings[mappingId];
|
||||
|
||||
if (!mapping) {
|
||||
return [404, { error: "Mapping not found" }];
|
||||
}
|
||||
|
||||
mapping.markAsDeleted();
|
||||
|
||||
log.info(`Deleted file system mapping ${mappingId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Trigger full sync for a mapping
|
||||
asyncApiRoute("post", "/mappings/:mappingId/sync", async (req) => {
|
||||
const { mappingId } = req.params;
|
||||
|
||||
if (!fileSystemSyncInit.isInitialized()) {
|
||||
return [503, { error: "File system sync is not initialized" }];
|
||||
}
|
||||
|
||||
const result = await fileSystemSyncInit.fullSync(mappingId);
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
} else {
|
||||
return [400, result];
|
||||
}
|
||||
});
|
||||
|
||||
// Get sync status for all mappings
|
||||
apiRoute("get", "/status", () => {
|
||||
return fileSystemSyncInit.getStatus();
|
||||
});
|
||||
|
||||
// Enable file system sync
|
||||
asyncApiRoute("post", "/enable", async () => {
|
||||
await fileSystemSyncInit.enable();
|
||||
return { success: true, message: "File system sync enabled" };
|
||||
});
|
||||
|
||||
// Disable file system sync
|
||||
asyncApiRoute("post", "/disable", async () => {
|
||||
await fileSystemSyncInit.disable();
|
||||
return { success: true, message: "File system sync disabled" };
|
||||
});
|
||||
|
||||
// Validate file path
|
||||
asyncApiRoute("post", "/validate-path", async (req) => {
|
||||
const { filePath } = req.body;
|
||||
|
||||
if (!filePath) {
|
||||
throw new ValidationError("filePath is required");
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(filePath);
|
||||
const exists = await fs.pathExists(normalizedPath);
|
||||
|
||||
let stats: FileStat | null = null;
|
||||
if (exists) {
|
||||
const fileStats = await fs.stat(normalizedPath);
|
||||
stats = {
|
||||
isFile: fileStats.isFile(),
|
||||
isDirectory: fileStats.isDirectory(),
|
||||
size: fileStats.size,
|
||||
modified: fileStats.mtime.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizedPath,
|
||||
exists,
|
||||
stats
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -308,7 +308,7 @@ describe("LLM API Tests", () => {
|
||||
let testChatId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks for clean state
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import options service to access mock
|
||||
@@ -449,10 +449,33 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming with note mentions", async () => {
|
||||
// This test simply verifies that the endpoint accepts note mentions
|
||||
// and returns the expected success response for streaming initiation
|
||||
// Mock becca for note content retrieval
|
||||
vi.doMock('../../becca/becca.js', () => ({
|
||||
default: {
|
||||
getNote: vi.fn().mockReturnValue({
|
||||
noteId: 'root',
|
||||
title: 'Root Note',
|
||||
getBlob: () => ({
|
||||
getContent: () => 'Root note content for testing'
|
||||
})
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
// Setup streaming with mention context
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
// Verify mention content is included
|
||||
expect(input.query).toContain('Tell me about this note');
|
||||
expect(input.query).toContain('Root note content for testing');
|
||||
|
||||
const callback = input.streamCallback;
|
||||
await callback('The root note contains', false, {});
|
||||
await callback(' important information.', true, {});
|
||||
});
|
||||
|
||||
const response = await supertest(app)
|
||||
.post(`/api/llm/chat/${testChatId}/messages/stream`)
|
||||
|
||||
.send({
|
||||
content: "Tell me about this note",
|
||||
useAdvancedContext: true,
|
||||
@@ -470,6 +493,16 @@ describe("LLM API Tests", () => {
|
||||
success: true,
|
||||
message: "Streaming initiated successfully"
|
||||
});
|
||||
|
||||
// Import ws service to access mock
|
||||
const ws = (await import("../../services/ws.js")).default;
|
||||
|
||||
// Verify thinking message was sent
|
||||
expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({
|
||||
type: 'llm-stream',
|
||||
chatNoteId: testChatId,
|
||||
thinking: 'Initializing streaming LLM response...'
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle streaming with thinking states", async () => {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import ocrRoutes from "./ocr.js";
|
||||
|
||||
// Mock the OCR service
|
||||
vi.mock("../../services/ocr/ocr_service.js", () => ({
|
||||
default: {
|
||||
isOCREnabled: vi.fn(() => true),
|
||||
startBatchProcessing: vi.fn(() => Promise.resolve({ success: true })),
|
||||
getBatchProgress: vi.fn(() => ({ inProgress: false, total: 0, processed: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock becca
|
||||
vi.mock("../../becca/becca.js", () => ({
|
||||
default: {}
|
||||
}));
|
||||
|
||||
// Mock log
|
||||
vi.mock("../../services/log.js", () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("OCR API", () => {
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
params: {},
|
||||
body: {},
|
||||
query: {}
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
triliumResponseHandled: false
|
||||
};
|
||||
});
|
||||
|
||||
it("should set triliumResponseHandled flag in batch processing", async () => {
|
||||
await ocrRoutes.batchProcessOCR(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ success: true });
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
|
||||
it("should set triliumResponseHandled flag in get batch progress", async () => {
|
||||
await ocrRoutes.getBatchProgress(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
});
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle errors and set triliumResponseHandled flag", async () => {
|
||||
// Mock service to throw error
|
||||
const ocrService = await import("../../services/ocr/ocr_service.js");
|
||||
vi.mocked(ocrService.default.startBatchProcessing).mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
await ocrRoutes.batchProcessOCR(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Test error"
|
||||
});
|
||||
expect(mockResponse.triliumResponseHandled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,612 +0,0 @@
|
||||
import { Request, Response } from "express";
|
||||
import ocrService from "../../services/ocr/ocr_service.js";
|
||||
import log from "../../services/log.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import sql from "../../services/sql.js";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/process-note/{noteId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific note
|
||||
* operationId: ocr-process-note
|
||||
* parameters:
|
||||
* - name: noteId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the note to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: OCR language code (e.g. 'eng', 'fra', 'deu')
|
||||
* default: 'eng'
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* result:
|
||||
* type: object
|
||||
* properties:
|
||||
* text:
|
||||
* type: string
|
||||
* confidence:
|
||||
* type: number
|
||||
* extractedAt:
|
||||
* type: string
|
||||
* language:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or unsupported file type
|
||||
* '404':
|
||||
* description: Note not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processNoteOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { noteId } = req.params;
|
||||
const { language = 'eng', forceReprocess = false } = req.body || {};
|
||||
|
||||
if (!noteId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Note ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if OCR is enabled
|
||||
if (!ocrService.isOCREnabled()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'OCR is not enabled in settings'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify note exists
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Note not found'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ocrService.processNoteOCR(noteId, {
|
||||
language,
|
||||
forceReprocess
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Note is not an image or has unsupported format'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error processing OCR for note: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/process-attachment/{attachmentId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific attachment
|
||||
* operationId: ocr-process-attachment
|
||||
* parameters:
|
||||
* - name: attachmentId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the attachment to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: OCR language code (e.g. 'eng', 'fra', 'deu')
|
||||
* default: 'eng'
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or unsupported file type
|
||||
* '404':
|
||||
* description: Attachment not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processAttachmentOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { attachmentId } = req.params;
|
||||
const { language = 'eng', forceReprocess = false } = req.body || {};
|
||||
|
||||
if (!attachmentId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Attachment ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if OCR is enabled
|
||||
if (!ocrService.isOCREnabled()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'OCR is not enabled in settings'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Attachment not found'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ocrService.processAttachmentOCR(attachmentId, {
|
||||
language,
|
||||
forceReprocess
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Attachment is not an image or has unsupported format'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error processing OCR for attachment: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/search:
|
||||
* get:
|
||||
* summary: Search for text in OCR results
|
||||
* operationId: ocr-search
|
||||
* parameters:
|
||||
* - name: q
|
||||
* in: query
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Search query text
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Search results
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* blobId:
|
||||
* type: string
|
||||
* text:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - missing search query
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function searchOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const { q: searchText } = req.query;
|
||||
|
||||
if (!searchText || typeof searchText !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Search query is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = ocrService.searchOCRResults(searchText);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error searching OCR results: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-process:
|
||||
* post:
|
||||
* summary: Process OCR for all images without existing OCR results
|
||||
* operationId: ocr-batch-process
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing initiated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or already processing
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function batchProcessOCR(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error initiating batch OCR processing: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-progress:
|
||||
* get:
|
||||
* summary: Get batch OCR processing progress
|
||||
* operationId: ocr-batch-progress
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing progress information
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* inProgress:
|
||||
* type: boolean
|
||||
* total:
|
||||
* type: number
|
||||
* processed:
|
||||
* type: number
|
||||
* percentage:
|
||||
* type: number
|
||||
* startTime:
|
||||
* type: string
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getBatchProgress(req: Request, res: Response) {
|
||||
try {
|
||||
const progress = ocrService.getBatchProgress();
|
||||
res.json(progress);
|
||||
(res as any).triliumResponseHandled = true;
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error getting batch OCR progress: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/stats:
|
||||
* get:
|
||||
* summary: Get OCR processing statistics
|
||||
* operationId: ocr-get-stats
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR statistics
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* stats:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalProcessed:
|
||||
* type: number
|
||||
* imageNotes:
|
||||
* type: number
|
||||
* imageAttachments:
|
||||
* type: number
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getOCRStats(req: Request, res: Response) {
|
||||
try {
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error getting OCR stats: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/delete/{blobId}:
|
||||
* delete:
|
||||
* summary: Delete OCR results for a specific blob
|
||||
* operationId: ocr-delete-results
|
||||
* parameters:
|
||||
* - name: blobId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the blob
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR results deleted successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* '400':
|
||||
* description: Bad request - invalid parameters
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function deleteOCRResults(req: Request, res: Response) {
|
||||
try {
|
||||
const { blobId } = req.params;
|
||||
|
||||
if (!blobId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Blob ID is required'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ocrService.deleteOCRResult(blobId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OCR results deleted for blob ${blobId}`
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error deleting OCR results: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/notes/{noteId}/text:
|
||||
* get:
|
||||
* summary: Get OCR text for a specific note
|
||||
* operationId: ocr-get-note-text
|
||||
* parameters:
|
||||
* - name: noteId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Note ID to get OCR text for
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OCR text retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* text:
|
||||
* type: string
|
||||
* description: The extracted OCR text
|
||||
* hasOcr:
|
||||
* type: boolean
|
||||
* description: Whether OCR text exists for this note
|
||||
* extractedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: When the OCR was last processed
|
||||
* 404:
|
||||
* description: Note not found
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getNoteOCRText(req: Request, res: Response) {
|
||||
try {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Note not found'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get stored OCR result
|
||||
let ocrText: string | null = null;
|
||||
let extractedAt: string | null = null;
|
||||
|
||||
if (note.blobId) {
|
||||
const result = sql.getRow<{
|
||||
ocr_text: string | null;
|
||||
ocr_last_processed: string | null;
|
||||
}>(`
|
||||
SELECT ocr_text, ocr_last_processed
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [note.blobId]);
|
||||
|
||||
if (result) {
|
||||
ocrText = result.ocr_text;
|
||||
extractedAt = result.ocr_last_processed;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
text: ocrText || '',
|
||||
hasOcr: !!ocrText,
|
||||
extractedAt: extractedAt
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
} catch (error: unknown) {
|
||||
log.error(`Error getting OCR text for note: ${error instanceof Error ? error.message : String(error)}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
(res as any).triliumResponseHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
processNoteOCR,
|
||||
processAttachmentOCR,
|
||||
searchOCR,
|
||||
batchProcessOCR,
|
||||
getBatchProgress,
|
||||
getOCRStats,
|
||||
deleteOCRResults,
|
||||
getNoteOCRText
|
||||
};
|
||||
@@ -93,6 +93,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"redirectBareDomain",
|
||||
"showLoginInShareTheme",
|
||||
"splitEditorOrientation",
|
||||
"fileSystemSyncEnabled",
|
||||
|
||||
// AI/LLM integration options
|
||||
"aiEnabled",
|
||||
@@ -108,13 +109,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"ollamaBaseUrl",
|
||||
"ollamaDefaultModel",
|
||||
"mfaEnabled",
|
||||
"mfaMethod",
|
||||
|
||||
// OCR options
|
||||
"ocrEnabled",
|
||||
"ocrLanguage",
|
||||
"ocrAutoProcessImages",
|
||||
"ocrMinConfidence"
|
||||
"mfaMethod"
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
|
||||
@@ -58,8 +58,8 @@ 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 fileSystemSyncRoute from "./api/file_system_sync.js";
|
||||
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
import etapiAppInfoRoutes from "../etapi/app_info.js";
|
||||
@@ -386,15 +386,8 @@ 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);
|
||||
// File system sync API
|
||||
app.use("/api/file-system-sync", [auth.checkApiAuthOrElectron, csrfMiddleware], fileSystemSyncRoute);
|
||||
|
||||
// API Documentation
|
||||
apiDocsRoute(app);
|
||||
|
||||
@@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
|
||||
const APP_DB_VERSION = 234;
|
||||
const SYNC_VERSION = 37;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -378,10 +378,4 @@ 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", "sup", "sub" ]);
|
||||
instance.keep([ "kbd" ]);
|
||||
}
|
||||
|
||||
return instance.turndown(content);
|
||||
|
||||
464
apps/server/src/services/file_system_content_converter.ts
Normal file
464
apps/server/src/services/file_system_content_converter.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
"use strict";
|
||||
|
||||
import path from "path";
|
||||
import log from "./log.js";
|
||||
import markdownExportService from "./export/markdown.js";
|
||||
import markdownImportService from "./import/markdown.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import BFileSystemMapping from "../becca/entities/bfile_system_mapping.js";
|
||||
import utils from "./utils.js";
|
||||
import { type NoteType } from "@triliumnext/commons";
|
||||
|
||||
export interface ConversionResult {
|
||||
content: string | Buffer;
|
||||
attributes?: Array<{ type: 'label' | 'relation'; name: string; value: string; isInheritable?: boolean }>;
|
||||
mime?: string;
|
||||
type?: NoteType;
|
||||
}
|
||||
|
||||
export interface ConversionOptions {
|
||||
preserveAttributes?: boolean;
|
||||
includeFrontmatter?: boolean;
|
||||
relativeImagePaths?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content converter for file system sync operations
|
||||
* Handles conversion between Trilium note formats and file system formats
|
||||
*/
|
||||
class FileSystemContentConverter {
|
||||
|
||||
/**
|
||||
* Convert note content to file format based on mapping configuration
|
||||
*/
|
||||
async noteToFile(note: BNote, mapping: BFileSystemMapping, filePath: string, options: ConversionOptions = {}): Promise<ConversionResult> {
|
||||
const fileExt = path.extname(filePath).toLowerCase();
|
||||
const contentFormat = mapping.contentFormat === 'auto' ? this.detectFormatFromExtension(fileExt) : mapping.contentFormat;
|
||||
|
||||
switch (contentFormat) {
|
||||
case 'markdown':
|
||||
return this.noteToMarkdown(note, options);
|
||||
case 'html':
|
||||
return this.noteToHtml(note, options);
|
||||
case 'raw':
|
||||
default:
|
||||
return this.noteToRaw(note, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert file content to note format based on mapping configuration
|
||||
*/
|
||||
async fileToNote(fileContent: string | Buffer, mapping: BFileSystemMapping, filePath: string, options: ConversionOptions = {}): Promise<ConversionResult> {
|
||||
const fileExt = path.extname(filePath).toLowerCase();
|
||||
const contentFormat = mapping.contentFormat === 'auto' ? this.detectFormatFromExtension(fileExt) : mapping.contentFormat;
|
||||
|
||||
// Convert Buffer to string for text formats
|
||||
const content = Buffer.isBuffer(fileContent) ? fileContent.toString('utf8') : fileContent;
|
||||
|
||||
switch (contentFormat) {
|
||||
case 'markdown':
|
||||
// Extract title from note for proper H1 deduplication
|
||||
const note = mapping.note;
|
||||
const title = note ? note.title : path.basename(filePath, path.extname(filePath));
|
||||
return this.markdownToNote(content, options, title);
|
||||
case 'html':
|
||||
return this.htmlToNote(content, options);
|
||||
case 'raw':
|
||||
default:
|
||||
return this.rawToNote(fileContent, fileExt, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect content format from file extension
|
||||
*/
|
||||
private detectFormatFromExtension(extension: string): 'markdown' | 'html' | 'raw' {
|
||||
const markdownExts = ['.md', '.markdown', '.mdown', '.mkd'];
|
||||
const htmlExts = ['.html', '.htm'];
|
||||
|
||||
if (markdownExts.includes(extension)) {
|
||||
return 'markdown';
|
||||
} else if (htmlExts.includes(extension)) {
|
||||
return 'html';
|
||||
} else {
|
||||
return 'raw';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert note to Markdown format
|
||||
*/
|
||||
private async noteToMarkdown(note: BNote, options: ConversionOptions): Promise<ConversionResult> {
|
||||
try {
|
||||
let content = note.getContent() as string;
|
||||
|
||||
// Convert HTML content to Markdown
|
||||
if (note.type === 'text' && note.mime === 'text/html') {
|
||||
content = markdownExportService.toMarkdown(content);
|
||||
}
|
||||
|
||||
// Add frontmatter with note attributes if requested
|
||||
if (options.includeFrontmatter && options.preserveAttributes) {
|
||||
const frontmatter = this.createFrontmatter(note);
|
||||
if (frontmatter) {
|
||||
content = `---\n${frontmatter}\n---\n\n${content}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
mime: 'text/markdown',
|
||||
type: 'text'
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error converting note ${note.noteId} to Markdown: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert note to HTML format
|
||||
*/
|
||||
private async noteToHtml(note: BNote, options: ConversionOptions): Promise<ConversionResult> {
|
||||
let content = note.getContent() as string;
|
||||
|
||||
// If note is already HTML, just clean it up
|
||||
if (note.type === 'text' && note.mime === 'text/html') {
|
||||
// Could add HTML processing here if needed
|
||||
} else if (note.type === 'code') {
|
||||
// Wrap code content in pre/code tags
|
||||
const language = this.getLanguageFromMime(note.mime);
|
||||
content = `<pre><code class="language-${language}">${utils.escapeHtml(content)}</code></pre>`;
|
||||
}
|
||||
|
||||
// Add HTML frontmatter as comments if requested
|
||||
if (options.includeFrontmatter && options.preserveAttributes) {
|
||||
const frontmatter = this.createFrontmatter(note);
|
||||
if (frontmatter) {
|
||||
content = `<!-- \n${frontmatter}\n-->\n\n${content}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
mime: 'text/html',
|
||||
type: 'text'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert note to raw format (preserve original content)
|
||||
*/
|
||||
private async noteToRaw(note: BNote, options: ConversionOptions): Promise<ConversionResult> {
|
||||
const content = note.getContent();
|
||||
|
||||
return {
|
||||
content,
|
||||
mime: note.mime,
|
||||
type: note.type
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown content to note format
|
||||
*/
|
||||
private async markdownToNote(content: string, options: ConversionOptions, title: string = ''): Promise<ConversionResult> {
|
||||
try {
|
||||
let processedContent = content;
|
||||
let attributes: ConversionResult['attributes'] = [];
|
||||
|
||||
// Extract frontmatter if present
|
||||
if (options.preserveAttributes) {
|
||||
const frontmatterResult = this.extractFrontmatter(content);
|
||||
processedContent = frontmatterResult.content;
|
||||
attributes = frontmatterResult.attributes;
|
||||
}
|
||||
|
||||
// Convert Markdown to HTML using the correct method
|
||||
// The title helps deduplicate <h1> tags with the note title
|
||||
const htmlContent = markdownImportService.renderToHtml(processedContent, title);
|
||||
|
||||
return {
|
||||
content: htmlContent,
|
||||
attributes,
|
||||
mime: 'text/html',
|
||||
type: 'text'
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error converting Markdown to note: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML content to note format
|
||||
*/
|
||||
private async htmlToNote(content: string, options: ConversionOptions): Promise<ConversionResult> {
|
||||
let processedContent = content;
|
||||
let attributes: ConversionResult['attributes'] = [];
|
||||
|
||||
// Extract HTML comment frontmatter if present
|
||||
if (options.preserveAttributes) {
|
||||
const frontmatterResult = this.extractHtmlFrontmatter(content);
|
||||
processedContent = frontmatterResult.content;
|
||||
attributes = frontmatterResult.attributes;
|
||||
}
|
||||
|
||||
return {
|
||||
content: processedContent,
|
||||
attributes,
|
||||
mime: 'text/html',
|
||||
type: 'text'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw content to note format
|
||||
*/
|
||||
private async rawToNote(content: string | Buffer, extension: string, options: ConversionOptions): Promise<ConversionResult> {
|
||||
// Determine note type and mime based on file extension
|
||||
const { type, mime } = this.getTypeAndMimeFromExtension(extension);
|
||||
|
||||
return {
|
||||
content,
|
||||
mime,
|
||||
type
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create YAML frontmatter from note attributes
|
||||
*/
|
||||
private createFrontmatter(note: BNote): string | null {
|
||||
const attributes = note.getOwnedAttributes();
|
||||
if (attributes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const yamlLines: string[] = [];
|
||||
yamlLines.push(`title: "${note.title.replace(/"/g, '\\"')}"`);
|
||||
yamlLines.push(`noteId: "${note.noteId}"`);
|
||||
yamlLines.push(`type: "${note.type}"`);
|
||||
yamlLines.push(`mime: "${note.mime}"`);
|
||||
|
||||
const labels = attributes.filter(attr => attr.type === 'label');
|
||||
const relations = attributes.filter(attr => attr.type === 'relation');
|
||||
|
||||
if (labels.length > 0) {
|
||||
yamlLines.push('labels:');
|
||||
for (const label of labels) {
|
||||
const inheritable = label.isInheritable ? ' (inheritable)' : '';
|
||||
yamlLines.push(` - name: "${label.name}"`);
|
||||
yamlLines.push(` value: "${label.value.replace(/"/g, '\\"')}"`);
|
||||
if (label.isInheritable) {
|
||||
yamlLines.push(` inheritable: true`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (relations.length > 0) {
|
||||
yamlLines.push('relations:');
|
||||
for (const relation of relations) {
|
||||
yamlLines.push(` - name: "${relation.name}"`);
|
||||
yamlLines.push(` target: "${relation.value}"`);
|
||||
if (relation.isInheritable) {
|
||||
yamlLines.push(` inheritable: true`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return yamlLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract YAML frontmatter from Markdown content
|
||||
*/
|
||||
private extractFrontmatter(content: string): { content: string; attributes: ConversionResult['attributes'] } {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { content, attributes: [] };
|
||||
}
|
||||
|
||||
const frontmatterYaml = match[1];
|
||||
const mainContent = match[2];
|
||||
|
||||
try {
|
||||
const attributes = this.parseFrontmatterYaml(frontmatterYaml);
|
||||
return { content: mainContent, attributes };
|
||||
} catch (error) {
|
||||
log.info(`Error parsing frontmatter YAML: ${error}`);
|
||||
return { content, attributes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract frontmatter from HTML comments
|
||||
*/
|
||||
private extractHtmlFrontmatter(content: string): { content: string; attributes: ConversionResult['attributes'] } {
|
||||
const frontmatterRegex = /^<!--\s*\n([\s\S]*?)\n-->\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { content, attributes: [] };
|
||||
}
|
||||
|
||||
const frontmatterYaml = match[1];
|
||||
const mainContent = match[2];
|
||||
|
||||
try {
|
||||
const attributes = this.parseFrontmatterYaml(frontmatterYaml);
|
||||
return { content: mainContent, attributes };
|
||||
} catch (error) {
|
||||
log.info(`Error parsing HTML frontmatter YAML: ${error}`);
|
||||
return { content, attributes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter into attributes (simplified YAML parser)
|
||||
*/
|
||||
private parseFrontmatterYaml(yaml: string): ConversionResult['attributes'] {
|
||||
const attributes: ConversionResult['attributes'] = [];
|
||||
const lines = yaml.split('\n');
|
||||
|
||||
let currentSection: 'labels' | 'relations' | null = null;
|
||||
let currentItem: any = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === 'labels:') {
|
||||
currentSection = 'labels';
|
||||
continue;
|
||||
} else if (trimmed === 'relations:') {
|
||||
currentSection = 'relations';
|
||||
continue;
|
||||
} else if (trimmed.startsWith('- name:')) {
|
||||
// Save previous item if exists
|
||||
if (currentItem.name && currentSection) {
|
||||
attributes.push({
|
||||
type: currentSection === 'labels' ? 'label' : 'relation',
|
||||
name: currentItem.name,
|
||||
value: currentItem.value || currentItem.target || '',
|
||||
isInheritable: currentItem.inheritable || false
|
||||
});
|
||||
}
|
||||
|
||||
currentItem = { name: this.extractQuotedValue(trimmed) };
|
||||
} else if (trimmed.startsWith('name:')) {
|
||||
currentItem.name = this.extractQuotedValue(trimmed);
|
||||
} else if (trimmed.startsWith('value:')) {
|
||||
currentItem.value = this.extractQuotedValue(trimmed);
|
||||
} else if (trimmed.startsWith('target:')) {
|
||||
currentItem.target = this.extractQuotedValue(trimmed);
|
||||
} else if (trimmed.startsWith('inheritable:')) {
|
||||
currentItem.inheritable = trimmed.includes('true');
|
||||
}
|
||||
}
|
||||
|
||||
// Save last item
|
||||
if (currentItem.name && currentSection) {
|
||||
attributes.push({
|
||||
type: currentSection === 'labels' ? 'label' : 'relation',
|
||||
name: currentItem.name,
|
||||
value: currentItem.value || currentItem.target || '',
|
||||
isInheritable: currentItem.inheritable || false
|
||||
});
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract quoted value from YAML line
|
||||
*/
|
||||
private extractQuotedValue(line: string): string {
|
||||
const match = line.match(/:\s*"([^"]+)"/);
|
||||
return match ? match[1].replace(/\\"/g, '"') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language identifier from MIME type
|
||||
*/
|
||||
private getLanguageFromMime(mime: string): string {
|
||||
const mimeToLang: Record<string, string> = {
|
||||
'application/javascript': 'javascript',
|
||||
'text/javascript': 'javascript',
|
||||
'application/typescript': 'typescript',
|
||||
'text/typescript': 'typescript',
|
||||
'application/json': 'json',
|
||||
'text/css': 'css',
|
||||
'text/html': 'html',
|
||||
'application/xml': 'xml',
|
||||
'text/xml': 'xml',
|
||||
'text/x-python': 'python',
|
||||
'text/x-java': 'java',
|
||||
'text/x-csharp': 'csharp',
|
||||
'text/x-sql': 'sql',
|
||||
'text/x-sh': 'bash',
|
||||
'text/x-yaml': 'yaml'
|
||||
};
|
||||
|
||||
return mimeToLang[mime] || 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get note type and MIME type from file extension
|
||||
*/
|
||||
private getTypeAndMimeFromExtension(extension: string): { type: NoteType; mime: string } {
|
||||
const extToType: Record<string, { type: NoteType; mime: string }> = {
|
||||
'.txt': { type: 'text', mime: 'text/plain' },
|
||||
'.md': { type: 'text', mime: 'text/markdown' },
|
||||
'.html': { type: 'text', mime: 'text/html' },
|
||||
'.htm': { type: 'text', mime: 'text/html' },
|
||||
'.js': { type: 'code', mime: 'application/javascript' },
|
||||
'.ts': { type: 'code', mime: 'application/typescript' },
|
||||
'.json': { type: 'code', mime: 'application/json' },
|
||||
'.css': { type: 'code', mime: 'text/css' },
|
||||
'.xml': { type: 'code', mime: 'application/xml' },
|
||||
'.py': { type: 'code', mime: 'text/x-python' },
|
||||
'.java': { type: 'code', mime: 'text/x-java' },
|
||||
'.cs': { type: 'code', mime: 'text/x-csharp' },
|
||||
'.sql': { type: 'code', mime: 'text/x-sql' },
|
||||
'.sh': { type: 'code', mime: 'text/x-sh' },
|
||||
'.yaml': { type: 'code', mime: 'text/x-yaml' },
|
||||
'.yml': { type: 'code', mime: 'text/x-yaml' },
|
||||
'.png': { type: 'image', mime: 'image/png' },
|
||||
'.jpg': { type: 'image', mime: 'image/jpeg' },
|
||||
'.jpeg': { type: 'image', mime: 'image/jpeg' },
|
||||
'.gif': { type: 'image', mime: 'image/gif' },
|
||||
'.svg': { type: 'image', mime: 'image/svg+xml' }
|
||||
};
|
||||
|
||||
return extToType[extension] || { type: 'file', mime: 'application/octet-stream' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a file type is supported for sync
|
||||
*/
|
||||
isSupportedFileType(filePath: string): boolean {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const textExtensions = ['.txt', '.md', '.html', '.htm', '.js', '.ts', '.json', '.css', '.xml', '.py', '.java', '.cs', '.sql', '.sh', '.yaml', '.yml'];
|
||||
const binaryExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.pdf'];
|
||||
|
||||
return textExtensions.includes(extension) || binaryExtensions.includes(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file should be treated as binary
|
||||
*/
|
||||
isBinaryFile(filePath: string): boolean {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const binaryExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.pdf', '.doc', '.docx', '.zip', '.tar', '.gz'];
|
||||
|
||||
return binaryExtensions.includes(extension);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const fileSystemContentConverter = new FileSystemContentConverter();
|
||||
|
||||
export default fileSystemContentConverter;
|
||||
932
apps/server/src/services/file_system_sync.ts
Normal file
932
apps/server/src/services/file_system_sync.ts
Normal file
@@ -0,0 +1,932 @@
|
||||
"use strict";
|
||||
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import log from "./log.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import BFileSystemMapping from "../becca/entities/bfile_system_mapping.js";
|
||||
import BFileNoteMapping from "../becca/entities/bfile_note_mapping.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import fileSystemContentConverter from "./file_system_content_converter.js";
|
||||
import fileSystemWatcher from "./file_system_watcher.js";
|
||||
import eventService from "./events.js";
|
||||
import noteService from "./notes.js";
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
conflicts?: ConflictInfo[];
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
type: 'content' | 'structure' | 'metadata';
|
||||
filePath: string;
|
||||
noteId: string;
|
||||
fileModified: string;
|
||||
noteModified: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
filesProcessed: number;
|
||||
notesCreated: number;
|
||||
notesUpdated: number;
|
||||
filesCreated: number;
|
||||
filesUpdated: number;
|
||||
conflicts: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional sync engine between Trilium notes and file system
|
||||
*/
|
||||
class FileSystemSync {
|
||||
private isInitialized = false;
|
||||
private syncInProgress = new Set<string>(); // Track ongoing syncs by mapping ID
|
||||
|
||||
constructor() {
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the sync engine
|
||||
*/
|
||||
async init() {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Initializing file system sync engine...');
|
||||
|
||||
// Initialize file system watcher
|
||||
await fileSystemWatcher.init();
|
||||
|
||||
this.isInitialized = true;
|
||||
log.info('File system sync engine initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the sync engine
|
||||
*/
|
||||
async shutdown() {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Shutting down file system sync engine...');
|
||||
|
||||
await fileSystemWatcher.shutdown();
|
||||
|
||||
this.isInitialized = false;
|
||||
log.info('File system sync engine shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for file changes and note changes
|
||||
*/
|
||||
private setupEventHandlers() {
|
||||
// Handle file changes from watcher
|
||||
eventService.subscribe('FILE_CHANGED', async ({ fileNoteMapping, mapping, fileContent, isNew }) => {
|
||||
await this.handleFileChanged(fileNoteMapping, mapping, fileContent, isNew);
|
||||
});
|
||||
|
||||
eventService.subscribe('FILE_DELETED', async ({ fileNoteMapping, mapping }) => {
|
||||
await this.handleFileDeleted(fileNoteMapping, mapping);
|
||||
});
|
||||
|
||||
// Handle note changes
|
||||
eventService.subscribe(eventService.NOTE_CONTENT_CHANGE, async ({ entity: note }) => {
|
||||
await this.handleNoteChanged(note as BNote);
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CHANGED, async ({ entityName, entity }) => {
|
||||
if (entityName === 'notes') {
|
||||
await this.handleNoteChanged(entity as BNote);
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_DELETED, async ({ entityName, entityId }) => {
|
||||
if (entityName === 'notes') {
|
||||
await this.handleNoteDeleted(entityId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full sync for a specific mapping
|
||||
*/
|
||||
async fullSync(mappingId: string): Promise<SyncResult> {
|
||||
const mapping = becca.fileSystemMappings[mappingId];
|
||||
if (!mapping) {
|
||||
return { success: false, message: `Mapping ${mappingId} not found` };
|
||||
}
|
||||
|
||||
if (this.syncInProgress.has(mappingId)) {
|
||||
return { success: false, message: 'Sync already in progress for this mapping' };
|
||||
}
|
||||
|
||||
this.syncInProgress.add(mappingId);
|
||||
const stats: SyncStats = {
|
||||
filesProcessed: 0,
|
||||
notesCreated: 0,
|
||||
notesUpdated: 0,
|
||||
filesCreated: 0,
|
||||
filesUpdated: 0,
|
||||
conflicts: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
try {
|
||||
log.info(`Starting full sync for mapping ${mappingId}: ${mapping.filePath}`);
|
||||
|
||||
if (!await fs.pathExists(mapping.filePath)) {
|
||||
throw new Error(`Path does not exist: ${mapping.filePath}`);
|
||||
}
|
||||
|
||||
const pathStats = await fs.stat(mapping.filePath);
|
||||
|
||||
if (pathStats.isFile()) {
|
||||
await this.syncSingleFile(mapping, mapping.filePath, stats);
|
||||
} else if (pathStats.isDirectory()) {
|
||||
await this.syncDirectory(mapping, mapping.filePath, stats);
|
||||
}
|
||||
|
||||
// Reverse sync: export notes that don't have corresponding files
|
||||
if (mapping.canSyncToDisk) {
|
||||
await this.syncNotesToFiles(mapping, stats);
|
||||
}
|
||||
|
||||
mapping.updateLastSyncTime();
|
||||
mapping.clearSyncErrors();
|
||||
|
||||
log.info(`Full sync completed for mapping ${mappingId}. Stats: ${JSON.stringify(stats)}`);
|
||||
return { success: true, message: `Sync completed successfully. ${stats.filesProcessed} files processed.` };
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Full sync failed for mapping ${mappingId}: ${(error as Error).message}`;
|
||||
log.error(errorMsg);
|
||||
mapping.addSyncError(errorMsg);
|
||||
stats.errors++;
|
||||
return { success: false, message: errorMsg };
|
||||
} finally {
|
||||
this.syncInProgress.delete(mappingId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single file
|
||||
*/
|
||||
private async syncSingleFile(mapping: BFileSystemMapping, filePath: string, stats: SyncStats) {
|
||||
if (!fileSystemContentConverter.isSupportedFileType(filePath)) {
|
||||
log.info(`DEBUG: Skipping unsupported file type: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
stats.filesProcessed++;
|
||||
|
||||
// Check if file note mapping exists
|
||||
let fileNoteMapping = this.findFileNoteMappingByPath(mapping.mappingId, filePath);
|
||||
|
||||
if (fileNoteMapping) {
|
||||
await this.syncExistingFile(mapping, fileNoteMapping, stats);
|
||||
} else {
|
||||
await this.syncNewFile(mapping, filePath, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a directory recursively
|
||||
*/
|
||||
private async syncDirectory(mapping: BFileSystemMapping, dirPath: string, stats: SyncStats) {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
// Skip excluded patterns
|
||||
if (this.isPathExcluded(fullPath, mapping)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
await this.syncSingleFile(mapping, fullPath, stats);
|
||||
} else if (entry.isDirectory() && mapping.includeSubtree) {
|
||||
await this.syncDirectory(mapping, fullPath, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync notes to files (reverse sync) - export notes that don't have corresponding files
|
||||
*/
|
||||
private async syncNotesToFiles(mapping: BFileSystemMapping, stats: SyncStats) {
|
||||
const rootNote = mapping.getNote();
|
||||
|
||||
// Sync the root note itself if it's mapped to a file
|
||||
const pathStats = await fs.stat(mapping.filePath);
|
||||
if (pathStats.isFile()) {
|
||||
await this.syncNoteToFile(mapping, rootNote, mapping.filePath, stats);
|
||||
} else {
|
||||
// Sync child notes in the subtree
|
||||
await this.syncNoteSubtreeToFiles(mapping, rootNote, mapping.filePath, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a note subtree to files recursively
|
||||
*/
|
||||
private async syncNoteSubtreeToFiles(mapping: BFileSystemMapping, note: BNote, basePath: string, stats: SyncStats) {
|
||||
for (const childBranch of note.children) {
|
||||
const childNote = becca.notes[childBranch.noteId];
|
||||
if (!childNote) continue;
|
||||
|
||||
// Skip system notes and other special notes
|
||||
if (childNote.noteId.startsWith('_') || childNote.type === 'book') {
|
||||
if (mapping.includeSubtree) {
|
||||
// For book notes, recurse into children but don't create a file
|
||||
await this.syncNoteSubtreeToFiles(mapping, childNote, basePath, stats);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate file path for this note
|
||||
const fileExtension = this.getFileExtensionForNote(childNote, mapping);
|
||||
const fileName = this.sanitizeFileName(childNote.title) + fileExtension;
|
||||
const filePath = path.join(basePath, fileName);
|
||||
|
||||
// Check if file already exists or has a mapping
|
||||
const existingMapping = this.findFileNoteMappingByNote(mapping.mappingId, childNote.noteId);
|
||||
|
||||
if (!existingMapping && !await fs.pathExists(filePath)) {
|
||||
// Note doesn't have a file mapping and file doesn't exist - create it
|
||||
await this.syncNoteToFile(mapping, childNote, filePath, stats);
|
||||
}
|
||||
|
||||
// Recurse into children if includeSubtree is enabled
|
||||
if (mapping.includeSubtree && childNote.children.length > 0) {
|
||||
const childDir = path.join(basePath, this.sanitizeFileName(childNote.title));
|
||||
await fs.ensureDir(childDir);
|
||||
await this.syncNoteSubtreeToFiles(mapping, childNote, childDir, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single note to a file
|
||||
*/
|
||||
private async syncNoteToFile(mapping: BFileSystemMapping, note: BNote, filePath: string, stats: SyncStats) {
|
||||
try {
|
||||
// Convert note content to file format
|
||||
const conversion = await fileSystemContentConverter.noteToFile(note, mapping, filePath, {
|
||||
preserveAttributes: true,
|
||||
includeFrontmatter: true
|
||||
});
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.ensureDir(path.dirname(filePath));
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(filePath, conversion.content);
|
||||
|
||||
// Calculate file hash and get modification time
|
||||
const fileStats = await fs.stat(filePath);
|
||||
const fileHash = await this.calculateFileHash(filePath);
|
||||
|
||||
// Check if mapping already exists (safety check)
|
||||
const existingMapping = this.findFileNoteMappingByPath(mapping.mappingId, filePath);
|
||||
if (existingMapping) {
|
||||
log.info(`File mapping already exists for ${filePath}, skipping creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create file note mapping
|
||||
const fileNoteMapping = new BFileNoteMapping({
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: note.noteId,
|
||||
filePath,
|
||||
fileHash,
|
||||
fileModifiedTime: fileStats.mtime.toISOString(),
|
||||
syncStatus: 'synced'
|
||||
}).save();
|
||||
|
||||
stats.filesCreated++;
|
||||
log.info(`Created file ${filePath} from note ${note.noteId}`);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error creating file from note ${note.noteId}: ${error}`);
|
||||
mapping.addSyncError(`Error creating file from note ${note.noteId}: ${(error as Error).message}`);
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an existing file that has a note mapping
|
||||
*/
|
||||
private async syncExistingFile(mapping: BFileSystemMapping, fileNoteMapping: BFileNoteMapping, stats: SyncStats) {
|
||||
const filePath = fileNoteMapping.filePath;
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
// File was deleted
|
||||
if (mapping.canSyncFromDisk) {
|
||||
await this.deleteNoteFromFileMapping(fileNoteMapping, stats);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStats = await fs.stat(filePath);
|
||||
const fileHash = await this.calculateFileHash(filePath);
|
||||
const fileModifiedTime = fileStats.mtime.toISOString();
|
||||
|
||||
const note = fileNoteMapping.note;
|
||||
if (!note) {
|
||||
log.info(`Note not found for file mapping: ${fileNoteMapping.noteId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileChanged = fileNoteMapping.hasFileChanged(fileHash, fileModifiedTime);
|
||||
const noteChanged = fileNoteMapping.hasNoteChanged();
|
||||
|
||||
if (!fileChanged && !noteChanged) {
|
||||
// No changes
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileChanged && noteChanged) {
|
||||
// Conflict - both changed
|
||||
fileNoteMapping.markConflict();
|
||||
stats.conflicts++;
|
||||
log.info(`Conflict detected for ${filePath} - both file and note modified`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileChanged && mapping.canSyncFromDisk) {
|
||||
// Update note from file
|
||||
await this.updateNoteFromFile(mapping, fileNoteMapping, fileHash, fileModifiedTime, stats);
|
||||
} else if (noteChanged && mapping.canSyncToDisk) {
|
||||
// Update file from note
|
||||
await this.updateFileFromNote(mapping, fileNoteMapping, fileHash, fileModifiedTime, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a new file that doesn't have a note mapping
|
||||
*/
|
||||
private async syncNewFile(mapping: BFileSystemMapping, filePath: string, stats: SyncStats) {
|
||||
if (!mapping.canSyncFromDisk) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = await fs.stat(filePath);
|
||||
const fileHash = await this.calculateFileHash(filePath);
|
||||
const fileModifiedTime = fileStats.mtime.toISOString();
|
||||
|
||||
// Create note from file
|
||||
const note = await this.createNoteFromFile(mapping, filePath);
|
||||
|
||||
// Check if mapping already exists (safety check)
|
||||
const existingMapping = this.findFileNoteMappingByPath(mapping.mappingId, filePath);
|
||||
if (existingMapping) {
|
||||
log.info(`File mapping already exists for ${filePath}, skipping creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create file note mapping
|
||||
const fileNoteMapping = new BFileNoteMapping({
|
||||
mappingId: mapping.mappingId,
|
||||
noteId: note.noteId,
|
||||
filePath,
|
||||
fileHash,
|
||||
fileModifiedTime,
|
||||
syncStatus: 'synced'
|
||||
}).save();
|
||||
|
||||
stats.notesCreated++;
|
||||
log.info(`Created note ${note.noteId} from file ${filePath}`);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error creating note from file ${filePath}: ${error}`);
|
||||
mapping.addSyncError(`Error creating note from file ${filePath}: ${(error as Error).message}`);
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note from a file
|
||||
*/
|
||||
private async createNoteFromFile(mapping: BFileSystemMapping, filePath: string): Promise<BNote> {
|
||||
const fileContent = await fs.readFile(filePath);
|
||||
const fileName = path.basename(filePath, path.extname(filePath));
|
||||
|
||||
// Convert file content to note format
|
||||
const conversion = await fileSystemContentConverter.fileToNote(fileContent, mapping, filePath, {
|
||||
preserveAttributes: true,
|
||||
includeFrontmatter: true
|
||||
});
|
||||
|
||||
// Determine parent note
|
||||
const parentNote = this.getParentNoteForFile(mapping, filePath);
|
||||
|
||||
// Create the note
|
||||
const note = new BNote({
|
||||
title: fileName,
|
||||
type: conversion.type || 'text',
|
||||
mime: conversion.mime || 'text/html'
|
||||
}).save();
|
||||
|
||||
// Set content
|
||||
note.setContent(conversion.content);
|
||||
|
||||
// Create branch
|
||||
new BBranch({
|
||||
noteId: note.noteId,
|
||||
parentNoteId: parentNote.noteId
|
||||
}).save();
|
||||
|
||||
// Add attributes from conversion
|
||||
if (conversion.attributes) {
|
||||
for (const attr of conversion.attributes) {
|
||||
new BAttribute({
|
||||
noteId: note.noteId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: attr.isInheritable || false
|
||||
}).save();
|
||||
}
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update note content from file
|
||||
*/
|
||||
private async updateNoteFromFile(mapping: BFileSystemMapping, fileNoteMapping: BFileNoteMapping, fileHash: string, fileModifiedTime: string, stats: SyncStats) {
|
||||
try {
|
||||
const note = fileNoteMapping.getNote();
|
||||
const fileContent = await fs.readFile(fileNoteMapping.filePath);
|
||||
|
||||
// Convert file content to note format
|
||||
const conversion = await fileSystemContentConverter.fileToNote(fileContent, mapping, fileNoteMapping.filePath, {
|
||||
preserveAttributes: true,
|
||||
includeFrontmatter: true
|
||||
});
|
||||
|
||||
// Update note content
|
||||
note.setContent(conversion.content);
|
||||
|
||||
// Update note type/mime if they changed
|
||||
if (conversion.type && conversion.type !== note.type) {
|
||||
note.type = conversion.type as any;
|
||||
note.save();
|
||||
}
|
||||
if (conversion.mime && conversion.mime !== note.mime) {
|
||||
note.mime = conversion.mime;
|
||||
note.save();
|
||||
}
|
||||
|
||||
// Update attributes if needed
|
||||
if (conversion.attributes) {
|
||||
// Remove existing attributes that came from file
|
||||
const existingAttrs = note.getOwnedAttributes();
|
||||
for (const attr of existingAttrs) {
|
||||
if (attr.name.startsWith('_fileSync_')) {
|
||||
attr.markAsDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
// Add new attributes
|
||||
for (const attr of conversion.attributes) {
|
||||
new BAttribute({
|
||||
noteId: note.noteId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: attr.isInheritable || false
|
||||
}).save();
|
||||
}
|
||||
}
|
||||
|
||||
fileNoteMapping.markSynced(fileHash, fileModifiedTime);
|
||||
stats.notesUpdated++;
|
||||
|
||||
log.info(`DEBUG: Updated note ${note.noteId} from file ${fileNoteMapping.filePath}`);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error updating note from file ${fileNoteMapping.filePath}: ${error}`);
|
||||
fileNoteMapping.markError();
|
||||
mapping.addSyncError(`Error updating note from file: ${(error as Error).message}`);
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file content from note
|
||||
*/
|
||||
private async updateFileFromNote(mapping: BFileSystemMapping, fileNoteMapping: BFileNoteMapping, currentFileHash: string, currentModifiedTime: string, stats: SyncStats) {
|
||||
try {
|
||||
const note = fileNoteMapping.getNote();
|
||||
|
||||
// Convert note content to file format
|
||||
const conversion = await fileSystemContentConverter.noteToFile(note, mapping, fileNoteMapping.filePath, {
|
||||
preserveAttributes: true,
|
||||
includeFrontmatter: true
|
||||
});
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.ensureDir(path.dirname(fileNoteMapping.filePath));
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(fileNoteMapping.filePath, conversion.content);
|
||||
|
||||
// Update file note mapping with new file info
|
||||
const newStats = await fs.stat(fileNoteMapping.filePath);
|
||||
const newFileHash = await this.calculateFileHash(fileNoteMapping.filePath);
|
||||
|
||||
fileNoteMapping.markSynced(newFileHash, newStats.mtime.toISOString());
|
||||
stats.filesUpdated++;
|
||||
|
||||
log.info(`DEBUG: Updated file ${fileNoteMapping.filePath} from note ${note.noteId}`);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error updating file from note ${fileNoteMapping.noteId}: ${error}`);
|
||||
fileNoteMapping.markError();
|
||||
mapping.addSyncError(`Error updating file from note: ${(error as Error).message}`);
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file change event from watcher
|
||||
*/
|
||||
private async handleFileChanged(fileNoteMapping: BFileNoteMapping, mapping: BFileSystemMapping, fileContent: Buffer, isNew: boolean) {
|
||||
if (this.syncInProgress.has(mapping.mappingId)) {
|
||||
return; // Skip if full sync in progress
|
||||
}
|
||||
|
||||
const stats: SyncStats = {
|
||||
filesProcessed: 1,
|
||||
notesCreated: 0,
|
||||
notesUpdated: 0,
|
||||
filesCreated: 0,
|
||||
filesUpdated: 0,
|
||||
conflicts: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
await this.syncNewFile(mapping, fileNoteMapping.filePath, stats);
|
||||
} else {
|
||||
const fileHash = crypto.createHash('sha256').update(fileContent).digest('hex');
|
||||
const fileStats = await fs.stat(fileNoteMapping.filePath);
|
||||
const fileModifiedTime = fileStats.mtime.toISOString();
|
||||
|
||||
await this.syncExistingFile(mapping, fileNoteMapping, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file deletion event from watcher
|
||||
*/
|
||||
private async handleFileDeleted(fileNoteMapping: BFileNoteMapping, mapping: BFileSystemMapping) {
|
||||
if (this.syncInProgress.has(mapping.mappingId)) {
|
||||
return; // Skip if full sync in progress
|
||||
}
|
||||
|
||||
const stats: SyncStats = {
|
||||
filesProcessed: 0,
|
||||
notesCreated: 0,
|
||||
notesUpdated: 0,
|
||||
filesCreated: 0,
|
||||
filesUpdated: 0,
|
||||
conflicts: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
await this.deleteNoteFromFileMapping(fileNoteMapping, stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle note change event
|
||||
*/
|
||||
private async handleNoteChanged(note: BNote) {
|
||||
// Find all file mappings for this note
|
||||
const fileMappings = this.findFileNoteMappingsByNote(note.noteId);
|
||||
|
||||
for (const fileMapping of fileMappings) {
|
||||
const mapping = fileMapping.mapping;
|
||||
if (!mapping || !mapping.canSyncToDisk || this.syncInProgress.has(mapping.mappingId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if note was actually modified since last sync
|
||||
if (!fileMapping.hasNoteChanged()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats: SyncStats = {
|
||||
filesProcessed: 0,
|
||||
notesCreated: 0,
|
||||
notesUpdated: 0,
|
||||
filesCreated: 0,
|
||||
filesUpdated: 0,
|
||||
conflicts: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
// Check for conflicts
|
||||
if (await fs.pathExists(fileMapping.filePath)) {
|
||||
const fileStats = await fs.stat(fileMapping.filePath);
|
||||
const fileHash = await this.calculateFileHash(fileMapping.filePath);
|
||||
const fileModifiedTime = fileStats.mtime.toISOString();
|
||||
|
||||
if (fileMapping.hasFileChanged(fileHash, fileModifiedTime)) {
|
||||
// Conflict
|
||||
fileMapping.markConflict();
|
||||
log.info(`Conflict detected for note ${note.noteId} - both file and note modified`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Update file from note
|
||||
const currentFileHash = await this.calculateFileHash(fileMapping.filePath);
|
||||
const currentModifiedTime = (await fs.stat(fileMapping.filePath)).mtime.toISOString();
|
||||
|
||||
await this.updateFileFromNote(mapping, fileMapping, currentFileHash, currentModifiedTime, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle note deletion event
|
||||
*/
|
||||
private async handleNoteDeleted(noteId: string) {
|
||||
// Find all file mappings for this note
|
||||
const fileMappings = this.findFileNoteMappingsByNote(noteId);
|
||||
|
||||
for (const fileMapping of fileMappings) {
|
||||
const mapping = fileMapping.mapping;
|
||||
if (!mapping || !mapping.canSyncToDisk || this.syncInProgress.has(mapping.mappingId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the file
|
||||
if (await fs.pathExists(fileMapping.filePath)) {
|
||||
await fs.remove(fileMapping.filePath);
|
||||
log.info(`Deleted file ${fileMapping.filePath} for deleted note ${noteId}`);
|
||||
}
|
||||
|
||||
// Delete the mapping
|
||||
fileMapping.markAsDeleted();
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error deleting file for note ${noteId}: ${error}`);
|
||||
mapping.addSyncError(`Error deleting file: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete note when file is deleted
|
||||
*/
|
||||
private async deleteNoteFromFileMapping(fileNoteMapping: BFileNoteMapping, stats: SyncStats) {
|
||||
try {
|
||||
const note = fileNoteMapping.note;
|
||||
if (note) {
|
||||
note.deleteNote();
|
||||
log.info(`Deleted note ${note.noteId} for deleted file ${fileNoteMapping.filePath}`);
|
||||
}
|
||||
|
||||
// Delete the mapping
|
||||
fileNoteMapping.markAsDeleted();
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error deleting note for file ${fileNoteMapping.filePath}: ${error}`);
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent note for a file based on mapping configuration
|
||||
*/
|
||||
private getParentNoteForFile(mapping: BFileSystemMapping, filePath: string): BNote {
|
||||
const mappedNote = mapping.getNote();
|
||||
|
||||
if (!mapping.preserveHierarchy || !mapping.includeSubtree) {
|
||||
return mappedNote;
|
||||
}
|
||||
|
||||
// Calculate relative path from mapping root
|
||||
const relativePath = path.relative(mapping.filePath, path.dirname(filePath));
|
||||
|
||||
if (!relativePath || relativePath === '.') {
|
||||
return mappedNote;
|
||||
}
|
||||
|
||||
// Create directory structure as notes
|
||||
const pathParts = relativePath.split(path.sep);
|
||||
let currentParent = mappedNote;
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (!part) continue;
|
||||
|
||||
// Look for existing child note with this name
|
||||
let childNote = currentParent.children.find(child => child.title === part);
|
||||
|
||||
if (!childNote) {
|
||||
// Create new note for this directory
|
||||
childNote = new BNote({
|
||||
title: part,
|
||||
type: 'text',
|
||||
mime: 'text/html'
|
||||
}).save();
|
||||
|
||||
childNote.setContent('<p>Directory note</p>');
|
||||
|
||||
// Create branch (notePosition will be auto-calculated)
|
||||
new BBranch({
|
||||
noteId: childNote.noteId,
|
||||
parentNoteId: currentParent.noteId
|
||||
}).save();
|
||||
}
|
||||
|
||||
currentParent = childNote;
|
||||
}
|
||||
|
||||
return currentParent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA256 hash of a file
|
||||
*/
|
||||
private async calculateFileHash(filePath: string): Promise<string> {
|
||||
const content = await fs.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path should be excluded based on mapping patterns
|
||||
*/
|
||||
private isPathExcluded(filePath: string, mapping: BFileSystemMapping): boolean {
|
||||
if (!mapping.excludePatterns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const basename = path.basename(normalizedPath);
|
||||
|
||||
for (const pattern of mapping.excludePatterns) {
|
||||
if (typeof pattern === 'string') {
|
||||
// Simple string matching
|
||||
if (normalizedPath.includes(pattern) || basename.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
} else if (pattern instanceof RegExp) {
|
||||
// Regex pattern
|
||||
if (pattern.test(normalizedPath) || pattern.test(basename)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find file note mapping by file path
|
||||
*/
|
||||
private findFileNoteMappingByPath(mappingId: string, filePath: string): BFileNoteMapping | null {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
|
||||
for (const mapping of Object.values(becca.fileNoteMappings || {})) {
|
||||
if (mapping.mappingId === mappingId && path.normalize(mapping.filePath) === normalizedPath) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all file note mappings for a note
|
||||
*/
|
||||
private findFileNoteMappingsByNote(noteId: string): BFileNoteMapping[] {
|
||||
const mappings: BFileNoteMapping[] = [];
|
||||
|
||||
for (const mapping of Object.values(becca.fileNoteMappings || {})) {
|
||||
if (mapping.noteId === noteId) {
|
||||
mappings.push(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find file note mapping by note ID within a specific mapping
|
||||
*/
|
||||
private findFileNoteMappingByNote(mappingId: string, noteId: string): BFileNoteMapping | null {
|
||||
for (const mapping of Object.values(becca.fileNoteMappings || {})) {
|
||||
if (mapping.mappingId === mappingId && mapping.noteId === noteId) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate file extension for a note based on its type and mapping configuration
|
||||
*/
|
||||
private getFileExtensionForNote(note: BNote, mapping: BFileSystemMapping): string {
|
||||
const contentFormat = mapping.contentFormat;
|
||||
|
||||
if (contentFormat === 'markdown' || (contentFormat === 'auto' && note.type === 'text')) {
|
||||
return '.md';
|
||||
} else if (contentFormat === 'html' || (contentFormat === 'auto' && note.type === 'text' && note.mime === 'text/html')) {
|
||||
return '.html';
|
||||
} else if (note.type === 'code') {
|
||||
// Map MIME types to file extensions
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'application/javascript': '.js',
|
||||
'text/javascript': '.js',
|
||||
'application/typescript': '.ts',
|
||||
'text/typescript': '.ts',
|
||||
'application/json': '.json',
|
||||
'text/css': '.css',
|
||||
'text/x-python': '.py',
|
||||
'text/x-java': '.java',
|
||||
'text/x-csharp': '.cs',
|
||||
'text/x-sql': '.sql',
|
||||
'text/x-sh': '.sh',
|
||||
'text/x-yaml': '.yaml',
|
||||
'application/xml': '.xml',
|
||||
'text/xml': '.xml'
|
||||
};
|
||||
return mimeToExt[note.mime] || '.txt';
|
||||
} else if (note.type === 'image') {
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/svg+xml': '.svg'
|
||||
};
|
||||
return mimeToExt[note.mime] || '.png';
|
||||
} else {
|
||||
return '.txt';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize file name to be safe for file system
|
||||
*/
|
||||
private sanitizeFileName(fileName: string): string {
|
||||
// Replace invalid characters with underscores
|
||||
return fileName
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.substring(0, 100); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status for all mappings
|
||||
*/
|
||||
getSyncStatus() {
|
||||
const status: Record<string, any> = {};
|
||||
|
||||
for (const mapping of Object.values(becca.fileSystemMappings || {})) {
|
||||
const fileMappings = Object.values(becca.fileNoteMappings || {})
|
||||
.filter(fm => fm.mappingId === mapping.mappingId);
|
||||
|
||||
const conflicts = fileMappings.filter(fm => fm.syncStatus === 'conflict').length;
|
||||
const pending = fileMappings.filter(fm => fm.syncStatus === 'pending').length;
|
||||
const errors = fileMappings.filter(fm => fm.syncStatus === 'error').length;
|
||||
|
||||
status[mapping.mappingId] = {
|
||||
filePath: mapping.filePath,
|
||||
isActive: mapping.isActive,
|
||||
syncDirection: mapping.syncDirection,
|
||||
fileCount: fileMappings.length,
|
||||
conflicts,
|
||||
pending,
|
||||
errors,
|
||||
lastSyncTime: mapping.lastSyncTime,
|
||||
syncErrors: mapping.syncErrors,
|
||||
isRunning: this.syncInProgress.has(mapping.mappingId)
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const fileSystemSync = new FileSystemSync();
|
||||
|
||||
export default fileSystemSync;
|
||||
129
apps/server/src/services/file_system_sync_init.ts
Normal file
129
apps/server/src/services/file_system_sync_init.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
|
||||
import log from "./log.js";
|
||||
import fileSystemSync from "./file_system_sync.js";
|
||||
import eventService from "./events.js";
|
||||
import optionService from "./options.js";
|
||||
|
||||
/**
|
||||
* Initialization service for file system sync functionality
|
||||
*/
|
||||
class FileSystemSyncInit {
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize file system sync if enabled
|
||||
*/
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if file system sync is enabled
|
||||
const isEnabled = optionService.getOption('fileSystemSyncEnabled') === 'true';
|
||||
|
||||
if (!isEnabled) {
|
||||
log.info('File system sync is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Initializing file system sync...');
|
||||
|
||||
// Initialize the sync engine
|
||||
await fileSystemSync.init();
|
||||
|
||||
this.initialized = true;
|
||||
log.info('File system sync initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Failed to initialize file system sync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown file system sync
|
||||
*/
|
||||
async shutdown() {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Shutting down file system sync...');
|
||||
|
||||
await fileSystemSync.shutdown();
|
||||
|
||||
this.initialized = false;
|
||||
log.info('File system sync shutdown complete');
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error shutting down file system sync: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file system sync is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
getStatus() {
|
||||
if (!this.initialized) {
|
||||
return { enabled: false, initialized: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
initialized: true,
|
||||
status: fileSystemSync.getSyncStatus()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable file system sync
|
||||
*/
|
||||
async enable() {
|
||||
optionService.setOption('fileSystemSyncEnabled', 'true');
|
||||
|
||||
if (!this.initialized) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
log.info('File system sync enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable file system sync
|
||||
*/
|
||||
async disable() {
|
||||
optionService.setOption('fileSystemSyncEnabled', 'false');
|
||||
|
||||
if (this.initialized) {
|
||||
await this.shutdown();
|
||||
}
|
||||
|
||||
log.info('File system sync disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full sync for a specific mapping
|
||||
*/
|
||||
async fullSync(mappingId: string) {
|
||||
if (!this.initialized) {
|
||||
throw new Error('File system sync is not initialized');
|
||||
}
|
||||
|
||||
return await fileSystemSync.fullSync(mappingId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const fileSystemSyncInit = new FileSystemSyncInit();
|
||||
|
||||
export default fileSystemSyncInit;
|
||||
457
apps/server/src/services/file_system_watcher.ts
Normal file
457
apps/server/src/services/file_system_watcher.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
"use strict";
|
||||
|
||||
import chokidar from "chokidar";
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import crypto from "crypto";
|
||||
import debounce from "debounce";
|
||||
import log from "./log.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import BFileSystemMapping from "../becca/entities/bfile_system_mapping.js";
|
||||
import BFileNoteMapping from "../becca/entities/bfile_note_mapping.js";
|
||||
import eventService from "./events.js";
|
||||
import { newEntityId } from "./utils.js";
|
||||
import type { FSWatcher } from "chokidar";
|
||||
|
||||
interface WatchedMapping {
|
||||
mapping: BFileSystemMapping;
|
||||
watcher: FSWatcher;
|
||||
}
|
||||
|
||||
interface FileChangeEvent {
|
||||
type: 'add' | 'change' | 'unlink';
|
||||
filePath: string;
|
||||
mappingId: string;
|
||||
stats?: fs.Stats;
|
||||
}
|
||||
|
||||
class FileSystemWatcher {
|
||||
private watchers: Map<string, WatchedMapping> = new Map();
|
||||
private syncQueue: FileChangeEvent[] = [];
|
||||
private isProcessing = false;
|
||||
|
||||
// Debounced sync to batch multiple file changes
|
||||
private processSyncQueue = debounce(this._processSyncQueue.bind(this), 500);
|
||||
|
||||
constructor() {
|
||||
// Subscribe to entity changes to watch for new/updated/deleted mappings
|
||||
eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) => {
|
||||
if (entityName === 'file_system_mappings') {
|
||||
this.addWatcher(entity as BFileSystemMapping);
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => {
|
||||
if (entityName === 'file_system_mappings') {
|
||||
this.updateWatcher(entity as BFileSystemMapping);
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_DELETED, ({ entityName, entityId }) => {
|
||||
if (entityName === 'file_system_mappings') {
|
||||
this.removeWatcher(entityId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file system watcher by setting up watchers for all active mappings
|
||||
*/
|
||||
async init() {
|
||||
log.info('Initializing file system watcher...');
|
||||
|
||||
try {
|
||||
const mappings = Object.values(becca.fileSystemMappings || {});
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.isActive && mapping.canSyncFromDisk) {
|
||||
await this.addWatcher(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`File system watcher initialized with ${this.watchers.size} active mappings`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to initialize file system watcher: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all watchers
|
||||
*/
|
||||
async shutdown() {
|
||||
log.info('Shutting down file system watcher...');
|
||||
|
||||
for (const [mappingId, { watcher }] of this.watchers) {
|
||||
await watcher.close();
|
||||
}
|
||||
|
||||
this.watchers.clear();
|
||||
log.info('File system watcher shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new file system watcher for a mapping
|
||||
*/
|
||||
private async addWatcher(mapping: BFileSystemMapping) {
|
||||
if (this.watchers.has(mapping.mappingId)) {
|
||||
await this.removeWatcher(mapping.mappingId);
|
||||
}
|
||||
|
||||
if (!mapping.isActive || !mapping.canSyncFromDisk) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the file path exists
|
||||
if (!await fs.pathExists(mapping.filePath)) {
|
||||
log.info(`File path does not exist for mapping ${mapping.mappingId}: ${mapping.filePath}`);
|
||||
mapping.addSyncError(`File path does not exist: ${mapping.filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await fs.stat(mapping.filePath);
|
||||
const watchPath = stats.isDirectory() ? mapping.filePath : path.dirname(mapping.filePath);
|
||||
|
||||
const watcher = chokidar.watch(watchPath, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
depth: mapping.includeSubtree ? undefined : 0,
|
||||
ignored: this.buildIgnorePatterns(mapping)
|
||||
});
|
||||
|
||||
watcher.on('add', (filePath, stats) => {
|
||||
this.queueFileChange('add', filePath, mapping.mappingId, stats);
|
||||
});
|
||||
|
||||
watcher.on('change', (filePath, stats) => {
|
||||
this.queueFileChange('change', filePath, mapping.mappingId, stats);
|
||||
});
|
||||
|
||||
watcher.on('unlink', (filePath) => {
|
||||
this.queueFileChange('unlink', filePath, mapping.mappingId);
|
||||
});
|
||||
|
||||
watcher.on('error', (error) => {
|
||||
log.error(`File watcher error for mapping ${mapping.mappingId}: ${error}`);
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === 'string') {
|
||||
mapping.addSyncError(`Watcher error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('ready', () => {
|
||||
log.info(`File watcher ready for mapping ${mapping.mappingId}: ${mapping.filePath}`);
|
||||
});
|
||||
|
||||
this.watchers.set(mapping.mappingId, { mapping, watcher });
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Failed to create file watcher for mapping ${mapping.mappingId}: ${error}`);
|
||||
mapping.addSyncError(`Failed to create watcher: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing watcher (remove and re-add)
|
||||
*/
|
||||
private async updateWatcher(mapping: BFileSystemMapping) {
|
||||
await this.addWatcher(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file system watcher
|
||||
*/
|
||||
private async removeWatcher(mappingId: string) {
|
||||
const watchedMapping = this.watchers.get(mappingId);
|
||||
if (watchedMapping) {
|
||||
await watchedMapping.watcher.close();
|
||||
this.watchers.delete(mappingId);
|
||||
log.info(`Removed file watcher for mapping ${mappingId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ignore patterns for chokidar based on mapping configuration
|
||||
*/
|
||||
private buildIgnorePatterns(mapping: BFileSystemMapping): (string | RegExp)[] {
|
||||
const patterns: (string | RegExp)[] = [
|
||||
// Always ignore common temp/system files
|
||||
/^\./, // Hidden files
|
||||
/\.tmp$/,
|
||||
/\.temp$/,
|
||||
/~$/, // Backup files
|
||||
/\.swp$/, // Vim swap files
|
||||
/\.DS_Store$/, // macOS
|
||||
/Thumbs\.db$/ // Windows
|
||||
];
|
||||
|
||||
// Add user-defined exclude patterns
|
||||
if (mapping.excludePatterns) {
|
||||
patterns.push(...mapping.excludePatterns);
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a file change event for processing
|
||||
*/
|
||||
private queueFileChange(type: 'add' | 'change' | 'unlink', filePath: string, mappingId: string, stats?: fs.Stats) {
|
||||
this.syncQueue.push({
|
||||
type,
|
||||
filePath: path.normalize(filePath),
|
||||
mappingId,
|
||||
stats
|
||||
});
|
||||
|
||||
// Trigger debounced processing
|
||||
this.processSyncQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sync queue (called after debounce delay)
|
||||
*/
|
||||
private async _processSyncQueue() {
|
||||
if (this.isProcessing || this.syncQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
const eventsToProcess = [...this.syncQueue];
|
||||
this.syncQueue = [];
|
||||
|
||||
try {
|
||||
// Group events by file path to handle multiple events for the same file
|
||||
const eventMap = new Map<string, FileChangeEvent>();
|
||||
|
||||
for (const event of eventsToProcess) {
|
||||
const key = `${event.mappingId}:${event.filePath}`;
|
||||
eventMap.set(key, event); // Latest event wins
|
||||
}
|
||||
|
||||
// Process each unique file change
|
||||
for (const event of eventMap.values()) {
|
||||
await this.processFileChange(event);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error processing file change queue: ${error}`);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
|
||||
// If more events were queued while processing, schedule another run
|
||||
if (this.syncQueue.length > 0) {
|
||||
this.processSyncQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file change event
|
||||
*/
|
||||
private async processFileChange(event: FileChangeEvent) {
|
||||
try {
|
||||
const mapping = becca.fileSystemMappings[event.mappingId];
|
||||
if (!mapping || !mapping.isActive || !mapping.canSyncFromDisk) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`DEBUG: Processing file ${event.type}: ${event.filePath} for mapping ${event.mappingId}`);
|
||||
|
||||
switch (event.type) {
|
||||
case 'add':
|
||||
case 'change':
|
||||
await this.handleFileAddOrChange(event, mapping);
|
||||
break;
|
||||
case 'unlink':
|
||||
await this.handleFileDelete(event, mapping);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error processing file change for ${event.filePath}: ${error}`);
|
||||
const mapping = becca.fileSystemMappings[event.mappingId];
|
||||
if (mapping) {
|
||||
mapping.addSyncError(`Error processing ${event.filePath}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file addition or modification
|
||||
*/
|
||||
private async handleFileAddOrChange(event: FileChangeEvent, mapping: BFileSystemMapping) {
|
||||
if (!await fs.pathExists(event.filePath)) {
|
||||
return; // File was deleted between queuing and processing
|
||||
}
|
||||
|
||||
const stats = event.stats || await fs.stat(event.filePath);
|
||||
if (stats.isDirectory()) {
|
||||
return; // We only sync files, not directories
|
||||
}
|
||||
|
||||
// Calculate file hash for change detection
|
||||
const fileContent = await fs.readFile(event.filePath);
|
||||
const fileHash = crypto.createHash('sha256').update(fileContent).digest('hex');
|
||||
const fileModifiedTime = stats.mtime.toISOString();
|
||||
|
||||
// Find existing file note mapping
|
||||
let fileNoteMapping: BFileNoteMapping | null = null;
|
||||
for (const mapping of Object.values(becca.fileNoteMappings || {})) {
|
||||
if (mapping.mappingId === event.mappingId && path.normalize(mapping.filePath) === path.normalize(event.filePath)) {
|
||||
fileNoteMapping = mapping;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file actually changed
|
||||
if (fileNoteMapping && !fileNoteMapping.hasFileChanged(fileHash, fileModifiedTime)) {
|
||||
return; // No actual change
|
||||
}
|
||||
|
||||
if (fileNoteMapping) {
|
||||
// Update existing mapping
|
||||
if (fileNoteMapping.hasNoteChanged()) {
|
||||
// Both file and note changed - mark as conflict
|
||||
fileNoteMapping.markConflict();
|
||||
log.info(`Conflict detected for ${event.filePath} - both file and note modified`);
|
||||
return;
|
||||
}
|
||||
|
||||
fileNoteMapping.markPending();
|
||||
} else {
|
||||
// Double-check if mapping exists before creating (race condition protection)
|
||||
const existingCheck = Object.values(becca.fileNoteMappings || {}).find(m =>
|
||||
m.mappingId === event.mappingId && path.normalize(m.filePath) === path.normalize(event.filePath)
|
||||
);
|
||||
|
||||
if (existingCheck) {
|
||||
log.info(`File mapping already exists for ${event.filePath}, using existing mapping`);
|
||||
fileNoteMapping = existingCheck;
|
||||
fileNoteMapping.markPending();
|
||||
} else {
|
||||
// Create new file note mapping
|
||||
try {
|
||||
fileNoteMapping = new BFileNoteMapping({
|
||||
mappingId: event.mappingId,
|
||||
noteId: '', // Will be determined by sync service
|
||||
filePath: event.filePath,
|
||||
fileHash,
|
||||
fileModifiedTime,
|
||||
syncStatus: 'pending'
|
||||
}).save();
|
||||
} catch (error: any) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
log.info(`File mapping constraint violation for ${event.filePath}, trying to find existing mapping`);
|
||||
// Try to find the mapping again - it might have been created by another process
|
||||
fileNoteMapping = Object.values(becca.fileNoteMappings || {}).find(m =>
|
||||
m.mappingId === event.mappingId && path.normalize(m.filePath) === path.normalize(event.filePath)
|
||||
) || null;
|
||||
if (!fileNoteMapping) {
|
||||
throw error; // Re-throw if we still can't find it
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event for sync service to handle
|
||||
eventService.emit('FILE_CHANGED', {
|
||||
fileNoteMapping,
|
||||
mapping,
|
||||
fileContent,
|
||||
isNew: event.type === 'add'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file deletion
|
||||
*/
|
||||
private async handleFileDelete(event: FileChangeEvent, mapping: BFileSystemMapping) {
|
||||
// Find existing file note mapping
|
||||
let fileNoteMapping: BFileNoteMapping | null = null;
|
||||
for (const mappingObj of Object.values(becca.fileNoteMappings || {})) {
|
||||
if (mappingObj.mappingId === event.mappingId && mappingObj.filePath === event.filePath) {
|
||||
fileNoteMapping = mappingObj;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNoteMapping) {
|
||||
// Emit event for sync service to handle deletion
|
||||
eventService.emit('FILE_DELETED', {
|
||||
fileNoteMapping,
|
||||
mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all watchers
|
||||
*/
|
||||
getWatcherStatus() {
|
||||
const status: Record<string, any> = {};
|
||||
|
||||
for (const [mappingId, { mapping, watcher }] of this.watchers) {
|
||||
status[mappingId] = {
|
||||
filePath: mapping.filePath,
|
||||
isActive: mapping.isActive,
|
||||
watchedPaths: watcher.getWatched(),
|
||||
syncDirection: mapping.syncDirection
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a full sync for a specific mapping
|
||||
*/
|
||||
async forceSyncMapping(mappingId: string) {
|
||||
const mapping = becca.fileSystemMappings[mappingId];
|
||||
if (!mapping) {
|
||||
throw new Error(`Mapping ${mappingId} not found`);
|
||||
}
|
||||
|
||||
log.info(`Force syncing mapping ${mappingId}: ${mapping.filePath}`);
|
||||
|
||||
if (await fs.pathExists(mapping.filePath)) {
|
||||
const stats = await fs.stat(mapping.filePath);
|
||||
if (stats.isFile()) {
|
||||
await this.queueFileChange('change', mapping.filePath, mappingId, stats);
|
||||
} else if (stats.isDirectory() && mapping.includeSubtree) {
|
||||
// Scan directory for files
|
||||
await this.scanDirectoryForFiles(mapping.filePath, mapping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for files and queue them for sync
|
||||
*/
|
||||
private async scanDirectoryForFiles(dirPath: string, mapping: BFileSystemMapping) {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile()) {
|
||||
const stats = await fs.stat(fullPath);
|
||||
this.queueFileChange('change', fullPath, mapping.mappingId, stats);
|
||||
} else if (entry.isDirectory() && mapping.includeSubtree) {
|
||||
await this.scanDirectoryForFiles(fullPath, mapping);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error scanning directory ${dirPath}: ${error}`);
|
||||
mapping.addSyncError(`Error scanning directory: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const fileSystemWatcher = new FileSystemWatcher();
|
||||
|
||||
export default fileSystemWatcher;
|
||||
@@ -6,9 +6,6 @@ import becca from "../becca/becca.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import hiddenSubtreeService from "./hidden_subtree.js";
|
||||
import oneTimeTimer from "./one_time_timer.js";
|
||||
import ocrService from "./ocr/ocr_service.js";
|
||||
import optionService from "./options.js";
|
||||
import log from "./log.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
|
||||
@@ -140,25 +137,6 @@ eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) =>
|
||||
}
|
||||
} else if (entityName === "notes") {
|
||||
runAttachedRelations(entity, "runOnNoteCreation", entity);
|
||||
|
||||
// Note: OCR processing for images is now handled in image.ts during image processing
|
||||
// OCR processing for files remains here since they don't go through image processing
|
||||
// Only auto-process if both OCR is enabled and auto-processing is enabled
|
||||
if (entity.type === 'file' && ocrService.isOCREnabled() && optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
// Check if the file MIME type is supported by any OCR processor
|
||||
const supportedMimeTypes = ocrService.getAllSupportedMimeTypes();
|
||||
|
||||
if (entity.mime && supportedMimeTypes.includes(entity.mime)) {
|
||||
// Process OCR asynchronously to avoid blocking note creation
|
||||
ocrService.processNoteOCR(entity.noteId).then(result => {
|
||||
if (result) {
|
||||
log.info(`Automatically processed OCR for file note ${entity.noteId} with MIME type ${entity.mime}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
log.error(`Failed to automatically process OCR for file note ${entity.noteId}: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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";
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
/*
|
||||
* Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always
|
||||
@@ -369,18 +369,16 @@ 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
|
||||
if (item.enforceBranches || item.id.startsWith("_help")) {
|
||||
const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
|
||||
const currentBranches = note.getParentBranches();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,8 +411,7 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb");
|
||||
if (shouldRestoreNames && note.title !== item.title) {
|
||||
if ((extraOpts.restoreNames || note.noteId.startsWith("_help")) && note.title !== item.title) {
|
||||
note.title = item.title;
|
||||
note.save();
|
||||
}
|
||||
@@ -468,5 +465,13 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
}
|
||||
|
||||
export default {
|
||||
checkHiddenSubtree
|
||||
checkHiddenSubtree,
|
||||
LBTPL_ROOT,
|
||||
LBTPL_BASE,
|
||||
LBTPL_COMMAND,
|
||||
LBTPL_NOTE_LAUNCHER,
|
||||
LBTPL_WIDGET,
|
||||
LBTPL_SCRIPT,
|
||||
LBTPL_SPACER,
|
||||
LBTPL_CUSTOM_WIDGET
|
||||
};
|
||||
|
||||
@@ -12,9 +12,8 @@ import sanitizeFilename from "sanitize-filename";
|
||||
import isSvg from "is-svg";
|
||||
import isAnimated from "is-animated";
|
||||
import htmlSanitizer from "./html_sanitizer.js";
|
||||
import ocrService, { type OCRResult } from "./ocr/ocr_service.js";
|
||||
|
||||
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean, noteId?: string) {
|
||||
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) {
|
||||
const compressImages = optionService.getOptionBool("compressImages");
|
||||
const origImageFormat = await getImageType(uploadBuffer);
|
||||
|
||||
@@ -25,42 +24,6 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm
|
||||
shrinkImageSwitch = false;
|
||||
}
|
||||
|
||||
// Schedule OCR processing in the background for best quality
|
||||
// Only auto-process if both OCR is enabled and auto-processing is enabled
|
||||
if (noteId && ocrService.isOCREnabled() && optionService.getOptionBool("ocrAutoProcessImages") && origImageFormat) {
|
||||
const imageMime = getImageMimeFromExtension(origImageFormat.ext);
|
||||
const supportedMimeTypes = ocrService.getAllSupportedMimeTypes();
|
||||
|
||||
if (supportedMimeTypes.includes(imageMime)) {
|
||||
// Process OCR asynchronously without blocking image creation
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const ocrResult = await ocrService.extractTextFromFile(uploadBuffer, imageMime);
|
||||
if (ocrResult) {
|
||||
// We need to get the entity again to get its blobId after it's been saved
|
||||
// noteId could be either a note ID or attachment ID
|
||||
const note = becca.getNote(noteId);
|
||||
const attachment = becca.getAttachment(noteId);
|
||||
|
||||
let blobId: string | undefined;
|
||||
if (note && note.blobId) {
|
||||
blobId = note.blobId;
|
||||
} else if (attachment && attachment.blobId) {
|
||||
blobId = attachment.blobId;
|
||||
}
|
||||
|
||||
if (blobId) {
|
||||
await ocrService.storeOCRResult(blobId, ocrResult);
|
||||
log.info(`Successfully processed OCR for image ${noteId} (${originalName})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for image ${noteId}: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let finalImageBuffer;
|
||||
let imageFormat;
|
||||
|
||||
@@ -109,7 +72,7 @@ function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string)
|
||||
note.setLabel("originalFileName", originalName);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, true, noteId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, true).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
note.save();
|
||||
@@ -145,7 +108,7 @@ function saveImage(parentNoteId: string, uploadBuffer: Buffer, originalName: str
|
||||
note.addLabel("originalFileName", originalName);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, shrinkImageSwitch, note.noteId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
note.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
|
||||
@@ -196,7 +159,7 @@ function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalNam
|
||||
}, 5000);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch, attachment.attachmentId).then(({ buffer, imageFormat }) => {
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
// re-read, might be changed in the meantime
|
||||
if (!attachment.attachmentId) {
|
||||
|
||||
@@ -299,10 +299,4 @@ $$`;
|
||||
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 { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { KeyboardShortcut } from "@triliumnext/commons";
|
||||
import { t } from "i18next";
|
||||
|
||||
function getDefaultKeyboardActions() {
|
||||
@@ -17,8 +17,6 @@ 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"),
|
||||
@@ -26,8 +24,6 @@ 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"),
|
||||
@@ -35,72 +31,48 @@ 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",
|
||||
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
|
||||
scope: "window"
|
||||
},
|
||||
{
|
||||
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"
|
||||
@@ -111,96 +83,72 @@ 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"
|
||||
@@ -212,56 +160,42 @@ 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",
|
||||
ignoreFromCommandPalette: true
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
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",
|
||||
ignoreFromCommandPalette: true
|
||||
scope: "note-tree"
|
||||
},
|
||||
{
|
||||
actionName: "duplicateSubtree",
|
||||
friendlyName: t("keyboard_action_names.duplicate-subtree"),
|
||||
iconClass: "bx bx-outline",
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.duplicate-subtree"),
|
||||
scope: "note-tree"
|
||||
@@ -272,147 +206,109 @@ 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",
|
||||
friendlyName: t("keyboard_action_names.switch-to-last-tab"),
|
||||
iconClass: "bx bx-rectangle",
|
||||
defaultShortcuts: ["CommandOrControl+0"],
|
||||
defaultShortcuts: [],
|
||||
description: t("keyboard_actions.last-tab"),
|
||||
scope: "window"
|
||||
},
|
||||
@@ -421,65 +317,49 @@ 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"
|
||||
@@ -490,57 +370,43 @@ 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"
|
||||
@@ -551,17 +417,13 @@ 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"
|
||||
@@ -572,57 +434,43 @@ 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"
|
||||
@@ -630,40 +478,30 @@ 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"
|
||||
@@ -675,148 +513,108 @@ 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"
|
||||
@@ -829,7 +627,7 @@ function getDefaultKeyboardActions() {
|
||||
const platformModifier = isMac ? "Meta" : "Ctrl";
|
||||
|
||||
for (const action of DEFAULT_KEYBOARD_ACTIONS) {
|
||||
if ("defaultShortcuts" in action && action.defaultShortcuts) {
|
||||
if (action.defaultShortcuts) {
|
||||
action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier));
|
||||
}
|
||||
}
|
||||
@@ -841,9 +639,7 @@ function getKeyboardActions() {
|
||||
const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions()));
|
||||
|
||||
for (const action of actions) {
|
||||
if ("effectiveShortcuts" in action && action.effectiveShortcuts) {
|
||||
action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : [];
|
||||
}
|
||||
action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : [];
|
||||
}
|
||||
|
||||
for (const option of optionService.getOptions()) {
|
||||
@@ -851,7 +647,7 @@ function getKeyboardActions() {
|
||||
let actionName = option.name.substring(17);
|
||||
actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1);
|
||||
|
||||
const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut;
|
||||
const action = actions.find((ea) => ea.actionName === actionName);
|
||||
|
||||
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{4}/);
|
||||
expect(result.title).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); // Date pattern
|
||||
});
|
||||
});
|
||||
|
||||
@@ -622,4 +622,4 @@ describe('ChatStorageService', () => {
|
||||
expect(toolExecutions[0].arguments).toEqual({ query: 'existing' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,916 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// Mock Tesseract.js
|
||||
const mockWorker = {
|
||||
recognize: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
reinitialize: vi.fn()
|
||||
};
|
||||
|
||||
const mockTesseract = {
|
||||
createWorker: vi.fn().mockResolvedValue(mockWorker)
|
||||
};
|
||||
|
||||
vi.mock('tesseract.js', () => ({
|
||||
default: mockTesseract
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const mockOptions = {
|
||||
getOptionBool: vi.fn(),
|
||||
getOption: vi.fn()
|
||||
};
|
||||
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
const mockSql = {
|
||||
execute: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
getRows: vi.fn()
|
||||
};
|
||||
|
||||
const mockBecca = {
|
||||
getNote: vi.fn(),
|
||||
getAttachment: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../options.js', () => ({
|
||||
default: mockOptions
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
default: mockLog
|
||||
}));
|
||||
|
||||
vi.mock('../sql.js', () => ({
|
||||
default: mockSql
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
// Import the service after mocking
|
||||
let ocrService: typeof import('./ocr_service.js').default;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockOptions.getOption.mockReturnValue('eng');
|
||||
mockSql.execute.mockImplementation(() => ({ lastInsertRowid: 1 }));
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
|
||||
// Set up createWorker to properly set the worker on the service
|
||||
mockTesseract.createWorker.mockImplementation(async () => {
|
||||
return mockWorker;
|
||||
});
|
||||
|
||||
// Dynamically import the service to ensure mocks are applied
|
||||
const module = await import('./ocr_service.js');
|
||||
ocrService = module.default; // It's an instance, not a class
|
||||
|
||||
// Reset the OCR service state
|
||||
(ocrService as any).isInitialized = false;
|
||||
(ocrService as any).worker = null;
|
||||
(ocrService as any).isProcessing = false;
|
||||
(ocrService as any).batchProcessingState = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OCRService', () => {
|
||||
describe('isOCREnabled', () => {
|
||||
it('should return true when OCR is enabled in options', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(true);
|
||||
expect(mockOptions.getOptionBool).toHaveBeenCalledWith('ocrEnabled');
|
||||
});
|
||||
|
||||
it('should return false when OCR is disabled in options', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(false);
|
||||
expect(mockOptions.getOptionBool).toHaveBeenCalledWith('ocrEnabled');
|
||||
});
|
||||
|
||||
it('should return false when options throws an error', () => {
|
||||
mockOptions.getOptionBool.mockImplementation(() => {
|
||||
throw new Error('Options not available');
|
||||
});
|
||||
|
||||
expect(ocrService.isOCREnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedMimeType', () => {
|
||||
it('should return true for supported image MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType('image/jpeg')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/jpg')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/png')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/gif')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/bmp')).toBe(true);
|
||||
expect(ocrService.isSupportedMimeType('image/tiff')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unsupported MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType('text/plain')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('application/pdf')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('video/mp4')).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('audio/mp3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined MIME types', () => {
|
||||
expect(ocrService.isSupportedMimeType(null as any)).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType(undefined as any)).toBe(false);
|
||||
expect(ocrService.isSupportedMimeType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize Tesseract worker successfully', async () => {
|
||||
await ocrService.initialize();
|
||||
|
||||
expect(mockTesseract.createWorker).toHaveBeenCalledWith('eng', 1, {
|
||||
workerPath: expect.any(String),
|
||||
corePath: expect.any(String),
|
||||
logger: expect.any(Function)
|
||||
});
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Initializing OCR service with Tesseract.js...');
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service initialized successfully');
|
||||
});
|
||||
|
||||
it('should not reinitialize if already initialized', async () => {
|
||||
await ocrService.initialize();
|
||||
mockTesseract.createWorker.mockClear();
|
||||
|
||||
await ocrService.initialize();
|
||||
|
||||
expect(mockTesseract.createWorker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
const error = new Error('Tesseract initialization failed');
|
||||
mockTesseract.createWorker.mockRejectedValue(error);
|
||||
|
||||
await expect(ocrService.initialize()).rejects.toThrow('Tesseract initialization failed');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to initialize OCR service: Error: Tesseract initialization failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextFromImage', () => {
|
||||
const mockImageBuffer = Buffer.from('fake-image-data');
|
||||
|
||||
beforeEach(async () => {
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
});
|
||||
|
||||
it('should extract text successfully with default options', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: 'Extracted text from image',
|
||||
confidence: 95
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Extracted text from image',
|
||||
confidence: 0.95,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockWorker.recognize).toHaveBeenCalledWith(mockImageBuffer);
|
||||
});
|
||||
|
||||
it('should extract text with custom language', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: 'French text',
|
||||
confidence: 88
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer, { language: 'fra' });
|
||||
|
||||
expect(result.language).toBe('fra');
|
||||
expect(mockWorker.terminate).toHaveBeenCalled();
|
||||
expect(mockTesseract.createWorker).toHaveBeenCalledWith('fra', 1, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle OCR recognition errors', async () => {
|
||||
const error = new Error('OCR recognition failed');
|
||||
mockWorker.recognize.mockRejectedValue(error);
|
||||
|
||||
await expect(ocrService.extractTextFromImage(mockImageBuffer)).rejects.toThrow('OCR recognition failed');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('OCR text extraction failed: Error: OCR recognition failed');
|
||||
});
|
||||
|
||||
it('should handle empty or low-confidence results', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: ' ',
|
||||
confidence: 15
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromImage(mockImageBuffer);
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.confidence).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeOCRResult', () => {
|
||||
it('should store OCR result in blob successfully', async () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await ocrService.storeOCRResult('blob123', ocrResult);
|
||||
|
||||
expect(mockSql.execute).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE blobs SET ocr_text = ?'),
|
||||
['Sample text', 'blob123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined blobId gracefully', async () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await ocrService.storeOCRResult(undefined, ocrResult);
|
||||
|
||||
expect(mockSql.execute).not.toHaveBeenCalled();
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Cannot store OCR result: blobId is undefined');
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const error = new Error('Database error');
|
||||
mockSql.execute.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
await expect(ocrService.storeOCRResult('blob123', ocrResult)).rejects.toThrow('Database error');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to store OCR result for blob blob123: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processNoteOCR', () => {
|
||||
const mockNote = {
|
||||
noteId: 'note123',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob123',
|
||||
getContent: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockNote.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
it('should process note OCR successfully', async () => {
|
||||
// Ensure getRow returns null for all calls in this test
|
||||
mockSql.getRow.mockImplementation(() => null);
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Note image text',
|
||||
confidence: 90
|
||||
}
|
||||
};
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Note image text',
|
||||
confidence: 0.9,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note123');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return existing OCR result if forceReprocess is false', async () => {
|
||||
const existingResult = {
|
||||
ocr_text: 'Existing text'
|
||||
};
|
||||
mockSql.getRow.mockReturnValue(existingResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Existing text',
|
||||
confidence: 0.95,
|
||||
language: 'eng',
|
||||
extractedAt: expect.any(String)
|
||||
});
|
||||
expect(mockNote.getContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reprocess if forceReprocess is true', async () => {
|
||||
const existingResult = {
|
||||
ocr_text: 'Existing text'
|
||||
};
|
||||
mockSql.getRow.mockResolvedValue(existingResult);
|
||||
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'New processed text',
|
||||
confidence: 95
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123', { forceReprocess: true });
|
||||
|
||||
expect(result?.text).toBe('New processed text');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null for non-existent note', async () => {
|
||||
mockBecca.getNote.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processNoteOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Note nonexistent not found');
|
||||
});
|
||||
|
||||
it('should return null for unsupported MIME type', async () => {
|
||||
mockNote.mime = 'text/plain';
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Note note123 has unsupported MIME type text/plain, skipping OCR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAttachmentOCR', () => {
|
||||
const mockAttachment = {
|
||||
attachmentId: 'attach123',
|
||||
role: 'image',
|
||||
mime: 'image/png',
|
||||
blobId: 'blob456',
|
||||
getContent: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getAttachment.mockReturnValue(mockAttachment);
|
||||
mockAttachment.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
it('should process attachment OCR successfully', async () => {
|
||||
// Ensure getRow returns null for all calls in this test
|
||||
mockSql.getRow.mockImplementation(() => null);
|
||||
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Attachment image text',
|
||||
confidence: 92
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('attach123');
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Attachment image text',
|
||||
confidence: 0.92,
|
||||
extractedAt: expect.any(String),
|
||||
language: 'eng'
|
||||
});
|
||||
expect(mockBecca.getAttachment).toHaveBeenCalledWith('attach123');
|
||||
});
|
||||
|
||||
it('should return null for non-existent attachment', async () => {
|
||||
mockBecca.getAttachment.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Attachment nonexistent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchOCRResults', () => {
|
||||
it('should search OCR results successfully', () => {
|
||||
const mockResults = [
|
||||
{
|
||||
blobId: 'blob1',
|
||||
ocr_text: 'Sample search text'
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(mockResults);
|
||||
|
||||
const results = ocrService.searchOCRResults('search');
|
||||
|
||||
expect(results).toEqual([{
|
||||
blobId: 'blob1',
|
||||
text: 'Sample search text'
|
||||
}]);
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WHERE ocr_text LIKE ?'),
|
||||
['%search%']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle search errors gracefully', () => {
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
const results = ocrService.searchOCRResults('search');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to search OCR results: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOCRStats', () => {
|
||||
it('should return OCR statistics successfully', () => {
|
||||
const mockStats = {
|
||||
total_processed: 150
|
||||
};
|
||||
const mockNoteStats = {
|
||||
count: 100
|
||||
};
|
||||
const mockAttachmentStats = {
|
||||
count: 50
|
||||
};
|
||||
|
||||
mockSql.getRow.mockReturnValueOnce(mockStats);
|
||||
mockSql.getRow.mockReturnValueOnce(mockNoteStats);
|
||||
mockSql.getRow.mockReturnValueOnce(mockAttachmentStats);
|
||||
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalProcessed: 150,
|
||||
imageNotes: 100,
|
||||
imageAttachments: 50
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing statistics gracefully', () => {
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
const stats = ocrService.getOCRStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalProcessed: 0,
|
||||
imageNotes: 0,
|
||||
imageAttachments: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
describe('startBatchProcessing', () => {
|
||||
beforeEach(() => {
|
||||
// Reset batch processing state
|
||||
ocrService.cancelBatchProcessing();
|
||||
});
|
||||
|
||||
it('should start batch processing when images are available', async () => {
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 }); // image notes
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 3 }); // image attachments
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSql.getRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return error if batch processing already in progress', async () => {
|
||||
// Start first batch
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 3 });
|
||||
|
||||
// Mock background processing queries
|
||||
const mockImageNotes = Array.from({length: 5}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Start without awaiting to keep it in progress
|
||||
const firstStart = ocrService.startBatchProcessing();
|
||||
|
||||
// Try to start second batch immediately
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
// Clean up by awaiting the first one
|
||||
await firstStart;
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Batch processing already in progress'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if OCR is disabled', async () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'OCR is disabled'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if no images need processing', async () => {
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 }); // image notes
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 }); // image attachments
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'No images found that need OCR processing'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const error = new Error('Database connection failed');
|
||||
mockSql.getRow.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Database connection failed'
|
||||
});
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
'Failed to start batch processing: Database connection failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatchProgress', () => {
|
||||
it('should return initial progress state', () => {
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
expect(progress.inProgress).toBe(false);
|
||||
expect(progress.total).toBe(0);
|
||||
expect(progress.processed).toBe(0);
|
||||
});
|
||||
|
||||
it('should return progress with percentage when total > 0', async () => {
|
||||
// Start batch processing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 10 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Mock the background processing queries to return items that will take time to process
|
||||
const mockImageNotes = Array.from({length: 10}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes); // image notes query
|
||||
mockSql.getRows.mockReturnValueOnce([]); // image attachments query
|
||||
|
||||
const startPromise = ocrService.startBatchProcessing();
|
||||
|
||||
// Check progress immediately after starting (before awaiting)
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
await startPromise;
|
||||
|
||||
expect(progress.inProgress).toBe(true);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.processed).toBe(0);
|
||||
expect(progress.percentage).toBe(0);
|
||||
expect(progress.startTime).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelBatchProcessing', () => {
|
||||
it('should cancel ongoing batch processing', async () => {
|
||||
// Start batch processing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 5 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Mock background processing queries
|
||||
const mockImageNotes = Array.from({length: 5}, (_, i) => ({
|
||||
noteId: `note${i}`,
|
||||
mime: 'image/jpeg'
|
||||
}));
|
||||
mockSql.getRows.mockReturnValueOnce(mockImageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
const startPromise = ocrService.startBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(true);
|
||||
|
||||
await startPromise;
|
||||
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(false);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
|
||||
it('should do nothing if no batch processing is running', () => {
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(mockLog.info).not.toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processBatchInBackground', () => {
|
||||
beforeEach(async () => {
|
||||
await ocrService.initialize();
|
||||
});
|
||||
|
||||
it('should process image notes and attachments in sequence', async () => {
|
||||
// Clear all mocks at the start of this test to ensure clean state
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reinitialize OCR service after clearing mocks
|
||||
await ocrService.initialize();
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
// Mock data for batch processing
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' },
|
||||
{ noteId: 'note2', mime: 'image/png', blobId: 'blob2' }
|
||||
];
|
||||
const imageAttachments = [
|
||||
{ attachmentId: 'attach1', mime: 'image/gif', blobId: 'blob3' }
|
||||
];
|
||||
|
||||
// Setup mocks for startBatchProcessing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 }); // image notes count
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 1 }); // image attachments count
|
||||
|
||||
// Setup mocks for background processing
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes); // image notes query
|
||||
mockSql.getRows.mockReturnValueOnce(imageAttachments); // image attachments query
|
||||
|
||||
// Mock successful OCR processing
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: { text: 'Test text', confidence: 95 }
|
||||
});
|
||||
|
||||
// Mock notes and attachments
|
||||
const mockNote1 = {
|
||||
noteId: 'note1',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob1',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
const mockNote2 = {
|
||||
noteId: 'note2',
|
||||
type: 'image',
|
||||
mime: 'image/png',
|
||||
blobId: 'blob2',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
const mockAttachment = {
|
||||
attachmentId: 'attach1',
|
||||
role: 'image',
|
||||
mime: 'image/gif',
|
||||
blobId: 'blob3',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
|
||||
mockBecca.getNote.mockImplementation((noteId) => {
|
||||
if (noteId === 'note1') return mockNote1;
|
||||
if (noteId === 'note2') return mockNote2;
|
||||
return null;
|
||||
});
|
||||
mockBecca.getAttachment.mockReturnValue(mockAttachment);
|
||||
mockSql.getRow.mockReturnValue(null); // No existing OCR results
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
// Need to wait longer since there's a 500ms delay between each item in batch processing
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Verify notes and attachments were processed
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note1');
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note2');
|
||||
expect(mockBecca.getAttachment).toHaveBeenCalledWith('attach1');
|
||||
});
|
||||
|
||||
it('should handle processing errors gracefully', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' }
|
||||
];
|
||||
|
||||
// Setup mocks for startBatchProcessing
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 1 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
|
||||
// Setup mocks for background processing
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Mock note that will cause an error
|
||||
const mockNote = {
|
||||
noteId: 'note1',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob1',
|
||||
getContent: vi.fn().mockImplementation(() => { throw new Error('Failed to get content'); })
|
||||
};
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify error was logged but processing continued
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to process OCR for note note1')
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop processing when cancelled', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'image/jpeg', blobId: 'blob1' },
|
||||
{ noteId: 'note2', mime: 'image/png', blobId: 'blob2' }
|
||||
];
|
||||
|
||||
// Setup mocks
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Cancel immediately
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify processing was stopped early
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip unsupported MIME types', async () => {
|
||||
const imageNotes = [
|
||||
{ noteId: 'note1', mime: 'text/plain', blobId: 'blob1' }, // unsupported
|
||||
{ noteId: 'note2', mime: 'image/jpeg', blobId: 'blob2' } // supported
|
||||
];
|
||||
|
||||
// Setup mocks
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 2 });
|
||||
mockSql.getRow.mockReturnValueOnce({ count: 0 });
|
||||
mockSql.getRows.mockReturnValueOnce(imageNotes);
|
||||
mockSql.getRows.mockReturnValueOnce([]);
|
||||
|
||||
const mockNote = {
|
||||
noteId: 'note2',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob2',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
};
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: { text: 'Test text', confidence: 95 }
|
||||
});
|
||||
|
||||
// Start batch processing
|
||||
await ocrService.startBatchProcessing();
|
||||
|
||||
// Wait for background processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify only supported MIME type was processed
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note2');
|
||||
expect(mockBecca.getNote).not.toHaveBeenCalledWith('note1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOCRResult', () => {
|
||||
it('should delete OCR result successfully', () => {
|
||||
ocrService.deleteOCRResult('blob123');
|
||||
|
||||
expect(mockSql.execute).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE blobs SET ocr_text = NULL'),
|
||||
['blob123']
|
||||
);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Deleted OCR result for blob blob123');
|
||||
});
|
||||
|
||||
it('should handle deletion errors', () => {
|
||||
mockSql.execute.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
expect(() => ocrService.deleteOCRResult('blob123')).toThrow('Database error');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to delete OCR result for blob blob123: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCurrentlyProcessing', () => {
|
||||
it('should return false initially', () => {
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true during processing', async () => {
|
||||
mockBecca.getNote.mockReturnValue({
|
||||
noteId: 'note123',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob123',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('fake-image-data'))
|
||||
});
|
||||
mockSql.getRow.mockResolvedValue(null);
|
||||
|
||||
await ocrService.initialize();
|
||||
mockWorker.recognize.mockImplementation(() => {
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(true);
|
||||
return Promise.resolve({
|
||||
data: { text: 'test', confidence: 90 }
|
||||
});
|
||||
});
|
||||
|
||||
await ocrService.processNoteOCR('note123');
|
||||
expect(ocrService.isCurrentlyProcessing()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should terminate worker on cleanup', async () => {
|
||||
await ocrService.initialize();
|
||||
// Manually set the worker since mocking might not do it properly
|
||||
(ocrService as any).worker = mockWorker;
|
||||
|
||||
await ocrService.cleanup();
|
||||
|
||||
expect(mockWorker.terminate).toHaveBeenCalled();
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service cleaned up');
|
||||
});
|
||||
|
||||
it('should handle cleanup when worker is not initialized', async () => {
|
||||
await ocrService.cleanup();
|
||||
|
||||
expect(mockWorker.terminate).not.toHaveBeenCalled();
|
||||
expect(mockLog.info).toHaveBeenCalledWith('OCR service cleaned up');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,752 +0,0 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import log from '../log.js';
|
||||
import sql from '../sql.js';
|
||||
import becca from '../../becca/becca.js';
|
||||
import options from '../options.js';
|
||||
import { ImageProcessor } from './processors/image_processor.js';
|
||||
import { PDFProcessor } from './processors/pdf_processor.js';
|
||||
import { TIFFProcessor } from './processors/tiff_processor.js';
|
||||
import { OfficeProcessor } from './processors/office_processor.js';
|
||||
import { FileProcessor } from './processors/file_processor.js';
|
||||
|
||||
export interface OCRResult {
|
||||
text: string;
|
||||
confidence: number;
|
||||
extractedAt: string;
|
||||
language?: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
export interface OCRProcessingOptions {
|
||||
language?: string;
|
||||
forceReprocess?: boolean;
|
||||
confidence?: number;
|
||||
enablePDFTextExtraction?: boolean;
|
||||
}
|
||||
|
||||
interface OCRBlobRow {
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
ocr_last_processed?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR Service for extracting text from images and other OCR-able objects
|
||||
* Uses Tesseract.js for text recognition
|
||||
*/
|
||||
class OCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private isProcessing = false;
|
||||
private processors: Map<string, FileProcessor> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize file processors
|
||||
this.processors.set('image', new ImageProcessor());
|
||||
this.processors.set('pdf', new PDFProcessor());
|
||||
this.processors.set('tiff', new TIFFProcessor());
|
||||
this.processors.set('office', new OfficeProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OCR is enabled in settings
|
||||
*/
|
||||
isOCREnabled(): boolean {
|
||||
try {
|
||||
return options.getOptionBool('ocrEnabled');
|
||||
} catch (error) {
|
||||
log.error(`Failed to check OCR enabled status: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is supported for OCR
|
||||
*/
|
||||
isSupportedMimeType(mimeType: string): boolean {
|
||||
if (!mimeType || typeof mimeType !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const supportedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
];
|
||||
return supportedTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from file buffer using appropriate processor
|
||||
*/
|
||||
async extractTextFromFile(fileBuffer: Buffer, mimeType: string, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info(`Starting OCR text extraction for MIME type: ${mimeType}`);
|
||||
this.isProcessing = true;
|
||||
|
||||
// Find appropriate processor
|
||||
const processor = this.getProcessorForMimeType(mimeType);
|
||||
if (!processor) {
|
||||
throw new Error(`No processor found for MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const result = await processor.extractText(fileBuffer, options);
|
||||
|
||||
log.info(`OCR extraction completed. Confidence: ${result.confidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`OCR text extraction failed: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for a note (image type)
|
||||
*/
|
||||
async processNoteOCR(noteId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled in settings');
|
||||
return null;
|
||||
}
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
log.error(`Note ${noteId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if note type and MIME type are supported for OCR
|
||||
if (note.type === 'image') {
|
||||
if (!this.isSupportedMimeType(note.mime)) {
|
||||
log.info(`Image note ${noteId} has unsupported MIME type ${note.mime}, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
} else if (note.type === 'file') {
|
||||
// Check if file MIME type is supported by any processor
|
||||
const processor = this.getProcessorForMimeType(note.mime);
|
||||
if (!processor) {
|
||||
log.info(`File note ${noteId} has unsupported MIME type ${note.mime} for OCR, skipping`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info(`Note ${noteId} is not an image or file note, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if OCR already exists and is up-to-date
|
||||
const existingOCR = this.getStoredOCRResult(note.blobId);
|
||||
if (existingOCR && !options.forceReprocess && note.blobId && !this.needsReprocessing(note.blobId)) {
|
||||
log.info(`OCR already exists and is up-to-date for note ${noteId}, returning cached result`);
|
||||
return existingOCR;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = note.getContent();
|
||||
if (!content || !(content instanceof Buffer)) {
|
||||
throw new Error(`Cannot get image content for note ${noteId}`);
|
||||
}
|
||||
|
||||
const ocrResult = await this.extractTextFromFile(content, note.mime, options);
|
||||
|
||||
// Store OCR result in blob
|
||||
await this.storeOCRResult(note.blobId, ocrResult);
|
||||
|
||||
return ocrResult;
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for note ${noteId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for an attachment
|
||||
*/
|
||||
async processAttachmentOCR(attachmentId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled in settings');
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
log.error(`Attachment ${attachmentId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if attachment role and MIME type are supported for OCR
|
||||
if (attachment.role === 'image') {
|
||||
if (!this.isSupportedMimeType(attachment.mime)) {
|
||||
log.info(`Image attachment ${attachmentId} has unsupported MIME type ${attachment.mime}, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
} else if (attachment.role === 'file') {
|
||||
// Check if file MIME type is supported by any processor
|
||||
const processor = this.getProcessorForMimeType(attachment.mime);
|
||||
if (!processor) {
|
||||
log.info(`File attachment ${attachmentId} has unsupported MIME type ${attachment.mime} for OCR, skipping`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info(`Attachment ${attachmentId} is not an image or file, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if OCR already exists and is up-to-date
|
||||
const existingOCR = this.getStoredOCRResult(attachment.blobId);
|
||||
if (existingOCR && !options.forceReprocess && attachment.blobId && !this.needsReprocessing(attachment.blobId)) {
|
||||
log.info(`OCR already exists and is up-to-date for attachment ${attachmentId}, returning cached result`);
|
||||
return existingOCR;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = attachment.getContent();
|
||||
if (!content || !(content instanceof Buffer)) {
|
||||
throw new Error(`Cannot get image content for attachment ${attachmentId}`);
|
||||
}
|
||||
|
||||
const ocrResult = await this.extractTextFromFile(content, attachment.mime, options);
|
||||
|
||||
// Store OCR result in blob
|
||||
await this.storeOCRResult(attachment.blobId, ocrResult);
|
||||
|
||||
return ocrResult;
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for attachment ${attachmentId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OCR result in blob
|
||||
*/
|
||||
async storeOCRResult(blobId: string | undefined, ocrResult: OCRResult): Promise<void> {
|
||||
if (!blobId) {
|
||||
log.error('Cannot store OCR result: blobId is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store OCR text and timestamp in blobs table
|
||||
sql.execute(`
|
||||
UPDATE blobs SET
|
||||
ocr_text = ?,
|
||||
ocr_last_processed = ?
|
||||
WHERE blobId = ?
|
||||
`, [
|
||||
ocrResult.text,
|
||||
new Date().toISOString(),
|
||||
blobId
|
||||
]);
|
||||
|
||||
log.info(`Stored OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to store OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OCR result from blob
|
||||
*/
|
||||
private getStoredOCRResult(blobId: string | undefined): OCRResult | null {
|
||||
if (!blobId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sql.getRow<{
|
||||
ocr_text: string | null;
|
||||
}>(`
|
||||
SELECT ocr_text
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
if (!row || !row.ocr_text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return basic OCR result from stored text
|
||||
// Note: we lose confidence, language, and extractedAt metadata
|
||||
// but gain simplicity by storing directly in blob
|
||||
return {
|
||||
text: row.ocr_text,
|
||||
confidence: 0.95, // Default high confidence for existing OCR
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: 'eng'
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Failed to get OCR result for blob ${blobId}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for text in OCR results
|
||||
*/
|
||||
searchOCRResults(searchText: string): Array<{ blobId: string; text: string }> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT blobId, ocr_text
|
||||
FROM blobs
|
||||
WHERE ocr_text LIKE ?
|
||||
AND ocr_text IS NOT NULL
|
||||
`;
|
||||
const params = [`%${searchText}%`];
|
||||
|
||||
const rows = sql.getRows<OCRBlobRow>(query, params);
|
||||
|
||||
return rows.map(row => ({
|
||||
blobId: row.blobId,
|
||||
text: row.ocr_text
|
||||
}));
|
||||
} catch (error) {
|
||||
log.error(`Failed to search OCR results: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete OCR results for a blob
|
||||
*/
|
||||
deleteOCRResult(blobId: string): void {
|
||||
try {
|
||||
sql.execute(`
|
||||
UPDATE blobs SET ocr_text = NULL
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
log.info(`Deleted OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for all files that don't have OCR results yet or need reprocessing
|
||||
*/
|
||||
async processAllImages(): Promise<void> {
|
||||
return this.processAllBlobsNeedingOCR();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OCR statistics
|
||||
*/
|
||||
getOCRStats(): { totalProcessed: number; imageNotes: number; imageAttachments: number } {
|
||||
try {
|
||||
const stats = sql.getRow<{
|
||||
total_processed: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as total_processed
|
||||
FROM blobs
|
||||
WHERE ocr_text IS NOT NULL AND ocr_text != ''
|
||||
`);
|
||||
|
||||
// Count image notes with OCR
|
||||
const noteStats = sql.getRow<{
|
||||
count: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM notes n
|
||||
JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type = 'image'
|
||||
AND n.isDeleted = 0
|
||||
AND b.ocr_text IS NOT NULL AND b.ocr_text != ''
|
||||
`);
|
||||
|
||||
// Count image attachments with OCR
|
||||
const attachmentStats = sql.getRow<{
|
||||
count: number;
|
||||
}>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM attachments a
|
||||
JOIN blobs b ON a.blobId = b.blobId
|
||||
WHERE a.role = 'image'
|
||||
AND a.isDeleted = 0
|
||||
AND b.ocr_text IS NOT NULL AND b.ocr_text != ''
|
||||
`);
|
||||
|
||||
return {
|
||||
totalProcessed: stats?.total_processed || 0,
|
||||
imageNotes: noteStats?.count || 0,
|
||||
imageAttachments: attachmentStats?.count || 0
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Failed to get OCR stats: ${error}`);
|
||||
return { totalProcessed: 0, imageNotes: 0, imageAttachments: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up OCR service
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
log.info('OCR service cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently processing
|
||||
*/
|
||||
isCurrentlyProcessing(): boolean {
|
||||
return this.isProcessing;
|
||||
}
|
||||
|
||||
// Batch processing state
|
||||
private batchProcessingState: {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
startTime?: Date;
|
||||
} = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Start batch OCR processing with progress tracking
|
||||
*/
|
||||
async startBatchProcessing(): Promise<{ success: boolean; message?: string }> {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
return { success: false, message: 'Batch processing already in progress' };
|
||||
}
|
||||
|
||||
if (!this.isOCREnabled()) {
|
||||
return { success: false, message: 'OCR is disabled' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Count total blobs needing OCR processing
|
||||
const blobsNeedingOCR = this.getBlobsNeedingOCR();
|
||||
const totalCount = blobsNeedingOCR.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return { success: false, message: 'No images found that need OCR processing' };
|
||||
}
|
||||
|
||||
// Initialize batch processing state
|
||||
this.batchProcessingState = {
|
||||
inProgress: true,
|
||||
total: totalCount,
|
||||
processed: 0,
|
||||
startTime: new Date()
|
||||
};
|
||||
|
||||
// Start processing in background
|
||||
this.processBatchInBackground(blobsNeedingOCR).catch(error => {
|
||||
log.error(`Batch processing failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.batchProcessingState.inProgress = false;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error(`Failed to start batch processing: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { success: false, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch processing progress
|
||||
*/
|
||||
getBatchProgress(): { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } {
|
||||
const result: { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } = { ...this.batchProcessingState };
|
||||
if (result.total > 0) {
|
||||
result.percentage = (result.processed / result.total) * 100;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process batch OCR in background with progress tracking
|
||||
*/
|
||||
private async processBatchInBackground(blobsToProcess: Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }>): Promise<void> {
|
||||
try {
|
||||
log.info('Starting batch OCR processing...');
|
||||
|
||||
for (const blobInfo of blobsToProcess) {
|
||||
if (!this.batchProcessingState.inProgress) {
|
||||
break; // Stop if processing was cancelled
|
||||
}
|
||||
|
||||
try {
|
||||
if (blobInfo.entityType === 'note') {
|
||||
await this.processNoteOCR(blobInfo.entityId);
|
||||
} else {
|
||||
await this.processAttachmentOCR(blobInfo.entityId);
|
||||
}
|
||||
this.batchProcessingState.processed++;
|
||||
// Add small delay to prevent overwhelming the system
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for ${blobInfo.entityType} ${blobInfo.entityId}: ${error}`);
|
||||
this.batchProcessingState.processed++; // Count as processed even if failed
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
this.batchProcessingState.inProgress = false;
|
||||
log.info(`Batch OCR processing completed. Processed ${this.batchProcessingState.processed} files.`);
|
||||
} catch (error) {
|
||||
log.error(`Batch OCR processing failed: ${error}`);
|
||||
this.batchProcessingState.inProgress = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel batch processing
|
||||
*/
|
||||
cancelBatchProcessing(): void {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
this.batchProcessingState.inProgress = false;
|
||||
log.info('Batch OCR processing cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processor for a given MIME type
|
||||
*/
|
||||
private getProcessorForMimeType(mimeType: string): FileProcessor | null {
|
||||
for (const processor of this.processors.values()) {
|
||||
if (processor.canProcess(mimeType)) {
|
||||
return processor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MIME types supported by all registered processors
|
||||
*/
|
||||
getAllSupportedMimeTypes(): string[] {
|
||||
const supportedTypes = new Set<string>();
|
||||
|
||||
// Gather MIME types from all registered processors
|
||||
for (const processor of this.processors.values()) {
|
||||
const processorTypes = processor.getSupportedMimeTypes();
|
||||
processorTypes.forEach(type => supportedTypes.add(type));
|
||||
}
|
||||
|
||||
return Array.from(supportedTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is supported by any processor
|
||||
*/
|
||||
isSupportedByAnyProcessor(mimeType: string): boolean {
|
||||
if (!mimeType) return false;
|
||||
|
||||
// Check if any processor can handle this MIME type
|
||||
const processor = this.getProcessorForMimeType(mimeType);
|
||||
return processor !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if blob needs OCR re-processing due to content changes
|
||||
*/
|
||||
needsReprocessing(blobId: string): boolean {
|
||||
if (!blobId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const blobInfo = sql.getRow<{
|
||||
utcDateModified: string;
|
||||
ocr_last_processed: string | null;
|
||||
}>(`
|
||||
SELECT utcDateModified, ocr_last_processed
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
if (!blobInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If OCR was never processed, it needs processing
|
||||
if (!blobInfo.ocr_last_processed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If blob was modified after last OCR processing, it needs re-processing
|
||||
const blobModified = new Date(blobInfo.utcDateModified);
|
||||
const lastOcrProcessed = new Date(blobInfo.ocr_last_processed);
|
||||
|
||||
return blobModified > lastOcrProcessed;
|
||||
} catch (error) {
|
||||
log.error(`Failed to check if blob ${blobId} needs reprocessing: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate OCR results for a blob (clear ocr_text and ocr_last_processed)
|
||||
*/
|
||||
invalidateOCRResult(blobId: string): void {
|
||||
if (!blobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sql.execute(`
|
||||
UPDATE blobs SET
|
||||
ocr_text = NULL,
|
||||
ocr_last_processed = NULL
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
log.info(`Invalidated OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to invalidate OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blobs that need OCR processing (modified after last OCR or never processed)
|
||||
*/
|
||||
getBlobsNeedingOCR(): Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }> {
|
||||
try {
|
||||
// Get notes with blobs that need OCR (both image notes and file notes with supported MIME types)
|
||||
const noteBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT n.blobId, n.mime as mimeType, n.noteId as entityId
|
||||
FROM notes n
|
||||
JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE (
|
||||
n.type = 'image'
|
||||
OR (
|
||||
n.type = 'file'
|
||||
AND n.mime IN (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/rtf',
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND n.isDeleted = 0
|
||||
AND n.blobId IS NOT NULL
|
||||
AND (
|
||||
b.ocr_last_processed IS NULL
|
||||
OR b.utcDateModified > b.ocr_last_processed
|
||||
)
|
||||
`);
|
||||
|
||||
// Get attachments with blobs that need OCR (both image and file attachments with supported MIME types)
|
||||
const attachmentBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT a.blobId, a.mime as mimeType, a.attachmentId as entityId
|
||||
FROM attachments a
|
||||
JOIN blobs b ON a.blobId = b.blobId
|
||||
WHERE (
|
||||
a.role = 'image'
|
||||
OR (
|
||||
a.role = 'file'
|
||||
AND a.mime IN (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/rtf',
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND a.isDeleted = 0
|
||||
AND a.blobId IS NOT NULL
|
||||
AND (
|
||||
b.ocr_last_processed IS NULL
|
||||
OR b.utcDateModified > b.ocr_last_processed
|
||||
)
|
||||
`);
|
||||
|
||||
// Combine results
|
||||
const result = [
|
||||
...noteBlobs.map(blob => ({ ...blob, entityType: 'note' as const })),
|
||||
...attachmentBlobs.map(blob => ({ ...blob, entityType: 'attachment' as const }))
|
||||
];
|
||||
|
||||
// Return all results (no need to filter by MIME type as we already did in the query)
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get blobs needing OCR: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for all blobs that need it (auto-processing)
|
||||
*/
|
||||
async processAllBlobsNeedingOCR(): Promise<void> {
|
||||
if (!this.isOCREnabled()) {
|
||||
log.info('OCR is disabled, skipping auto-processing');
|
||||
return;
|
||||
}
|
||||
|
||||
const blobsNeedingOCR = this.getBlobsNeedingOCR();
|
||||
if (blobsNeedingOCR.length === 0) {
|
||||
log.info('No blobs need OCR processing');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Auto-processing OCR for ${blobsNeedingOCR.length} blobs...`);
|
||||
|
||||
for (const blobInfo of blobsNeedingOCR) {
|
||||
try {
|
||||
if (blobInfo.entityType === 'note') {
|
||||
await this.processNoteOCR(blobInfo.entityId);
|
||||
} else {
|
||||
await this.processAttachmentOCR(blobInfo.entityId);
|
||||
}
|
||||
|
||||
// Add small delay to prevent overwhelming the system
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
log.error(`Failed to auto-process OCR for ${blobInfo.entityType} ${blobInfo.entityId}: ${error}`);
|
||||
// Continue with other blobs
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Auto-processing OCR completed');
|
||||
}
|
||||
}
|
||||
|
||||
export default new OCRService();
|
||||
@@ -1,33 +0,0 @@
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
|
||||
/**
|
||||
* Base class for file processors that extract text from different file types
|
||||
*/
|
||||
export abstract class FileProcessor {
|
||||
/**
|
||||
* Check if this processor can handle the given MIME type
|
||||
*/
|
||||
abstract canProcess(mimeType: string): boolean;
|
||||
|
||||
/**
|
||||
* Extract text from the given file buffer
|
||||
*/
|
||||
abstract extractText(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult>;
|
||||
|
||||
/**
|
||||
* Get the processing type identifier
|
||||
*/
|
||||
abstract getProcessingType(): string;
|
||||
|
||||
/**
|
||||
* Get list of MIME types supported by this processor
|
||||
*/
|
||||
abstract getSupportedMimeTypes(): string[];
|
||||
|
||||
/**
|
||||
* Clean up any resources
|
||||
*/
|
||||
cleanup(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import log from '../../log.js';
|
||||
import options from '../../options.js';
|
||||
|
||||
/**
|
||||
* Image processor for extracting text from image files using Tesseract
|
||||
*/
|
||||
export class ImageProcessor extends FileProcessor {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private isInitialized = false;
|
||||
private readonly supportedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return this.supportedTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.worker) {
|
||||
throw new Error('Image processor worker not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Starting image OCR text extraction...');
|
||||
|
||||
// Set language if specified and different from current
|
||||
// Support multi-language format like 'ron+eng'
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
|
||||
// Validate language format
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
if (language !== 'eng') {
|
||||
// For different languages, create a new worker
|
||||
await this.worker.terminate();
|
||||
log.info(`Initializing Tesseract worker for language(s): ${language}`);
|
||||
this.worker = await Tesseract.createWorker(language, 1, {
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
log.info(`Image OCR progress (${language}): ${Math.round(m.progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.worker.recognize(buffer);
|
||||
|
||||
// Filter text based on minimum confidence threshold
|
||||
const { filteredText, overallConfidence } = this.filterTextByConfidence(result.data, options);
|
||||
|
||||
const ocrResult: OCRResult = {
|
||||
text: filteredText,
|
||||
confidence: overallConfidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: 1
|
||||
};
|
||||
|
||||
log.info(`Image OCR extraction completed. Confidence: ${ocrResult.confidence}%, Text length: ${ocrResult.text.length}`);
|
||||
return ocrResult;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Image OCR text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Initializing image OCR processor with Tesseract.js...');
|
||||
|
||||
// Configure proper paths for Node.js environment
|
||||
const tesseractDir = require.resolve('tesseract.js').replace('/src/index.js', '');
|
||||
const workerPath = require.resolve('tesseract.js/src/worker-script/node/index.js');
|
||||
const corePath = require.resolve('tesseract.js-core/tesseract-core.wasm.js');
|
||||
|
||||
log.info(`Using worker path: ${workerPath}`);
|
||||
log.info(`Using core path: ${corePath}`);
|
||||
|
||||
this.worker = await Tesseract.createWorker(this.getDefaultOCRLanguage(), 1, {
|
||||
workerPath,
|
||||
corePath,
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
log.info(`Image OCR progress: ${Math.round(m.progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.isInitialized = true;
|
||||
log.info('Image OCR processor initialized successfully');
|
||||
} catch (error) {
|
||||
log.error(`Failed to initialize image OCR processor: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
log.info('Image OCR processor cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter text based on minimum confidence threshold
|
||||
*/
|
||||
private filterTextByConfidence(data: any, options: OCRProcessingOptions): { filteredText: string; overallConfidence: number } {
|
||||
const minConfidence = this.getMinConfidenceThreshold();
|
||||
|
||||
// If no minimum confidence set, return original text
|
||||
if (minConfidence <= 0) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence: data.confidence / 100
|
||||
};
|
||||
}
|
||||
|
||||
let filteredWords: string[] = [];
|
||||
let validConfidences: number[] = [];
|
||||
|
||||
// Tesseract provides word-level data
|
||||
if (data.words && Array.isArray(data.words)) {
|
||||
for (const word of data.words) {
|
||||
const wordConfidence = word.confidence / 100; // Convert to decimal
|
||||
|
||||
if (wordConfidence >= minConfidence) {
|
||||
filteredWords.push(word.text);
|
||||
validConfidences.push(wordConfidence);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: if word-level data not available, use overall confidence
|
||||
const overallConfidence = data.confidence / 100;
|
||||
if (overallConfidence >= minConfidence) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence
|
||||
};
|
||||
} else {
|
||||
log.info(`Entire text filtered out due to low confidence ${overallConfidence} (below threshold ${minConfidence})`);
|
||||
return {
|
||||
filteredText: '',
|
||||
overallConfidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average confidence of accepted words
|
||||
const averageConfidence = validConfidences.length > 0
|
||||
? validConfidences.reduce((sum, conf) => sum + conf, 0) / validConfidences.length
|
||||
: 0;
|
||||
|
||||
const filteredText = filteredWords.join(' ').trim();
|
||||
|
||||
log.info(`Filtered OCR text: ${filteredWords.length} words kept out of ${data.words?.length || 0} total words (min confidence: ${minConfidence})`);
|
||||
|
||||
return {
|
||||
filteredText,
|
||||
overallConfidence: averageConfidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum confidence threshold from options
|
||||
*/
|
||||
private getMinConfidenceThreshold(): number {
|
||||
const minConfidence = options.getOption('ocrMinConfidence') ?? 0;
|
||||
return parseFloat(minConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as officeParser from 'officeparser';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* Office document processor for extracting text and images from DOCX/XLSX/PPTX files
|
||||
*/
|
||||
export class OfficeProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // XLSX
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PPTX
|
||||
'application/msword', // DOC
|
||||
'application/vnd.ms-excel', // XLS
|
||||
'application/vnd.ms-powerpoint', // PPT
|
||||
'application/rtf' // RTF
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return this.supportedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting Office document text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// Extract text from Office document
|
||||
const data = await this.parseOfficeDocument(buffer);
|
||||
|
||||
// Extract text from Office document
|
||||
const combinedText = data.data && data.data.trim().length > 0 ? data.data.trim() : '';
|
||||
const confidence = combinedText.length > 0 ? 0.99 : 0; // High confidence for direct text extraction
|
||||
|
||||
const result: OCRResult = {
|
||||
text: combinedText,
|
||||
confidence: confidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: language,
|
||||
pageCount: 1 // Office documents are treated as single logical document
|
||||
};
|
||||
|
||||
log.info(`Office document text extraction completed. Confidence: ${confidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Office document text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseOfficeDocument(buffer: Buffer): Promise<{ data: string }> {
|
||||
try {
|
||||
// Use promise-based API directly
|
||||
const data = await officeParser.parseOfficeAsync(buffer, {
|
||||
outputErrorToConsole: false,
|
||||
newlineDelimiter: '\n',
|
||||
ignoreNotes: false,
|
||||
putNotesAtLast: false
|
||||
});
|
||||
|
||||
return {
|
||||
data: data || ''
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Office document parsing failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'office';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import * as pdfParse from 'pdf-parse';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
/**
|
||||
* PDF processor for extracting text from PDF files
|
||||
* First tries to extract existing text, then falls back to OCR on images
|
||||
*/
|
||||
export class PDFProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = ['application/pdf'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase() === 'application/pdf';
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting PDF text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// First try to extract existing text from PDF
|
||||
if (options.enablePDFTextExtraction !== false) {
|
||||
const textResult = await this.extractTextFromPDF(buffer, options);
|
||||
if (textResult.text.trim().length > 0) {
|
||||
log.info(`PDF text extraction successful. Length: ${textResult.text.length}`);
|
||||
return textResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to OCR if no text found or PDF text extraction is disabled
|
||||
log.info('No text found in PDF or text extraction disabled, falling back to OCR...');
|
||||
return await this.extractTextViaOCR(buffer, options);
|
||||
|
||||
} catch (error) {
|
||||
log.error(`PDF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async extractTextFromPDF(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult> {
|
||||
try {
|
||||
const data = await pdfParse(buffer);
|
||||
|
||||
return {
|
||||
text: data.text.trim(),
|
||||
confidence: 0.99, // High confidence for direct text extraction
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: data.numpages
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`PDF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async extractTextViaOCR(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult> {
|
||||
try {
|
||||
// Convert PDF to images and OCR each page
|
||||
// For now, we'll use a simple approach - convert first page to image
|
||||
// In a full implementation, we'd convert all pages
|
||||
|
||||
// This is a simplified implementation
|
||||
// In practice, you might want to use pdf2pic or similar library
|
||||
// to convert PDF pages to images for OCR
|
||||
|
||||
// For now, we'll return a placeholder result
|
||||
// indicating that OCR on PDF is not fully implemented
|
||||
log.info('PDF to image conversion not fully implemented, returning placeholder');
|
||||
|
||||
return {
|
||||
text: '[PDF OCR not fully implemented - would convert PDF pages to images and OCR each page]',
|
||||
confidence: 0.0,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: 1
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`PDF OCR extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import sharp from 'sharp';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* TIFF processor for extracting text from multi-page TIFF files
|
||||
*/
|
||||
export class TIFFProcessor extends FileProcessor {
|
||||
private imageProcessor: ImageProcessor;
|
||||
private readonly supportedTypes = ['image/tiff', 'image/tif'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.imageProcessor = new ImageProcessor();
|
||||
}
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase() === 'image/tiff' || mimeType.toLowerCase() === 'image/tif';
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
try {
|
||||
log.info('Starting TIFF text extraction...');
|
||||
|
||||
// Validate language format
|
||||
const language = options.language || this.getDefaultOCRLanguage();
|
||||
if (!this.isValidLanguageFormat(language)) {
|
||||
throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`);
|
||||
}
|
||||
|
||||
// Check if this is a multi-page TIFF
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const pageCount = metadata.pages || 1;
|
||||
|
||||
let combinedText = '';
|
||||
let totalConfidence = 0;
|
||||
|
||||
// Process each page
|
||||
for (let page = 0; page < pageCount; page++) {
|
||||
try {
|
||||
log.info(`Processing TIFF page ${page + 1}/${pageCount}...`);
|
||||
|
||||
// Extract page as PNG buffer
|
||||
const pageBuffer = await sharp(buffer, { page })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// OCR the page
|
||||
const pageResult = await this.imageProcessor.extractText(pageBuffer, options);
|
||||
|
||||
if (pageResult.text.trim().length > 0) {
|
||||
if (combinedText.length > 0) {
|
||||
combinedText += '\n\n--- Page ' + (page + 1) + ' ---\n';
|
||||
}
|
||||
combinedText += pageResult.text;
|
||||
totalConfidence += pageResult.confidence;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to process TIFF page ${page + 1}: ${error}`);
|
||||
// Continue with other pages
|
||||
}
|
||||
}
|
||||
|
||||
const averageConfidence = pageCount > 0 ? totalConfidence / pageCount : 0;
|
||||
|
||||
const result: OCRResult = {
|
||||
text: combinedText.trim(),
|
||||
confidence: averageConfidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || this.getDefaultOCRLanguage(),
|
||||
pageCount: pageCount
|
||||
};
|
||||
|
||||
log.info(`TIFF text extraction completed. Pages: ${pageCount}, Confidence: ${averageConfidence}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`TIFF text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'tiff';
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.imageProcessor.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default OCR language from options
|
||||
*/
|
||||
private getDefaultOCRLanguage(): string {
|
||||
try {
|
||||
const options = require('../../options.js').default;
|
||||
const ocrLanguage = options.getOption('ocrLanguage');
|
||||
if (!ocrLanguage) {
|
||||
throw new Error('OCR language not configured in user settings');
|
||||
}
|
||||
return ocrLanguage;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get default OCR language: ${error}`);
|
||||
throw new Error('OCR language must be configured in settings before processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OCR language format
|
||||
* Supports single language (eng) or multi-language (ron+eng)
|
||||
*/
|
||||
private isValidLanguageFormat(language: string): boolean {
|
||||
if (!language || typeof language !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by '+' for multi-language format
|
||||
const languages = language.split('+');
|
||||
|
||||
// Check each language code (should be 2-7 characters, alphanumeric with underscores)
|
||||
const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/;
|
||||
|
||||
return languages.every(lang => {
|
||||
const trimmed = lang.trim();
|
||||
return trimmed.length > 0 && validLanguagePattern.test(trimmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -212,11 +212,8 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ 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 },
|
||||
// File system sync options
|
||||
{ name: "fileSystemSyncEnabled", value: "false", isSynced: false },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -257,7 +254,7 @@ function initStartupOptions() {
|
||||
}
|
||||
|
||||
function getKeyboardDefaultOptions() {
|
||||
return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({
|
||||
return (keyboardActions.getDefaultKeyboardActions().filter((ka) => !!ka.actionName) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({
|
||||
name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`,
|
||||
value: JSON.stringify(ka.defaultShortcuts),
|
||||
isSynced: false
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import Expression from "./expression.js";
|
||||
import SearchContext from "../search_context.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import sql from "../../sql.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
|
||||
/**
|
||||
* Search expression for finding text within OCR-extracted content from images
|
||||
*/
|
||||
export default class OCRContentExpression extends Expression {
|
||||
private searchText: string;
|
||||
|
||||
constructor(searchText: string) {
|
||||
super();
|
||||
this.searchText = searchText;
|
||||
}
|
||||
|
||||
execute(inputNoteSet: NoteSet, executionContext: object, searchContext: SearchContext): NoteSet {
|
||||
// Don't search OCR content if it's not enabled
|
||||
if (!this.isOCRSearchEnabled()) {
|
||||
return new NoteSet();
|
||||
}
|
||||
|
||||
const resultNoteSet = new NoteSet();
|
||||
const ocrResults = this.searchOCRContent(this.searchText);
|
||||
|
||||
for (const ocrResult of ocrResults) {
|
||||
// Find notes that use this blob
|
||||
const notes = sql.getRows<{noteId: string}>(`
|
||||
SELECT noteId FROM notes
|
||||
WHERE blobId = ? AND isDeleted = 0
|
||||
`, [ocrResult.blobId]);
|
||||
|
||||
for (const noteRow of notes) {
|
||||
const note = becca.getNote(noteRow.noteId);
|
||||
if (note && !note.isDeleted && inputNoteSet.hasNoteId(note.noteId)) {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
// Find attachments that use this blob and their parent notes
|
||||
const attachments = sql.getRows<{ownerId: string}>(`
|
||||
SELECT ownerId FROM attachments
|
||||
WHERE blobId = ? AND isDeleted = 0
|
||||
`, [ocrResult.blobId]);
|
||||
|
||||
for (const attachmentRow of attachments) {
|
||||
const note = becca.getNote(attachmentRow.ownerId);
|
||||
if (note && !note.isDeleted && inputNoteSet.hasNoteId(note.noteId)) {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add highlight tokens for OCR matches
|
||||
if (ocrResults.length > 0) {
|
||||
const tokens = this.extractHighlightTokens(this.searchText);
|
||||
searchContext.highlightedTokens.push(...tokens);
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
private isOCRSearchEnabled(): boolean {
|
||||
try {
|
||||
const optionService = require('../../options.js').default;
|
||||
return optionService.getOptionBool('ocrEnabled');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private searchOCRContent(searchText: string): Array<{
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
}> {
|
||||
try {
|
||||
// Search in blobs table for OCR text
|
||||
const query = `
|
||||
SELECT blobId, ocr_text
|
||||
FROM blobs
|
||||
WHERE ocr_text LIKE ?
|
||||
AND ocr_text IS NOT NULL
|
||||
AND ocr_text != ''
|
||||
LIMIT 50
|
||||
`;
|
||||
const params = [`%${searchText}%`];
|
||||
|
||||
return sql.getRows<{
|
||||
blobId: string;
|
||||
ocr_text: string;
|
||||
}>(query, params);
|
||||
} catch (error) {
|
||||
console.error('Error searching OCR content:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extractHighlightTokens(searchText: string): string[] {
|
||||
// Split search text into words and return them as highlight tokens
|
||||
return searchText
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 2)
|
||||
.map(token => token.toLowerCase());
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `OCRContent('${this.searchText}')`;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import sql from "../sql.js";
|
||||
import options from "../options.js";
|
||||
|
||||
class SearchResult {
|
||||
notePathArray: string[];
|
||||
@@ -50,9 +48,6 @@ class SearchResult {
|
||||
this.addScoreForStrings(tokens, note.title, 2.0); // Increased to give more weight to title matches
|
||||
this.addScoreForStrings(tokens, this.notePathTitle, 0.3); // Reduced to further de-emphasize path matches
|
||||
|
||||
// Add OCR scoring - weight between title and content matches
|
||||
this.addOCRScore(tokens, 1.5);
|
||||
|
||||
if (note.isInHiddenSubtree()) {
|
||||
this.score = this.score / 3; // Increased penalty for hidden notes
|
||||
}
|
||||
@@ -75,37 +70,6 @@ class SearchResult {
|
||||
}
|
||||
this.score += tokenScore;
|
||||
}
|
||||
|
||||
addOCRScore(tokens: string[], factor: number) {
|
||||
try {
|
||||
// Check if OCR is enabled
|
||||
if (!options.getOptionBool('ocrEnabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for OCR results for this note and its attachments
|
||||
const ocrResults = sql.getRows(`
|
||||
SELECT b.ocr_text
|
||||
FROM blobs b
|
||||
WHERE b.ocr_text IS NOT NULL
|
||||
AND b.ocr_text != ''
|
||||
AND (
|
||||
b.blobId = (SELECT blobId FROM notes WHERE noteId = ? AND isDeleted = 0)
|
||||
OR b.blobId IN (
|
||||
SELECT blobId FROM attachments WHERE ownerId = ? AND isDeleted = 0
|
||||
)
|
||||
)
|
||||
`, [this.noteId, this.noteId]);
|
||||
|
||||
for (const ocrResult of ocrResults as Array<{ocr_text: string}>) {
|
||||
// Add score for OCR text matches
|
||||
this.addScoreForStrings(tokens, ocrResult.ocr_text, factor);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR service is not available
|
||||
console.debug('OCR scoring failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchResult;
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
const mockSql = {
|
||||
getRows: vi.fn()
|
||||
};
|
||||
|
||||
const mockOptions = {
|
||||
getOptionBool: vi.fn()
|
||||
};
|
||||
|
||||
const mockBecca = {
|
||||
notes: {},
|
||||
getNote: vi.fn()
|
||||
};
|
||||
|
||||
const mockBeccaService = {
|
||||
getNoteTitleForPath: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../sql.js', () => ({
|
||||
default: mockSql
|
||||
}));
|
||||
|
||||
vi.mock('../options.js', () => ({
|
||||
default: mockOptions
|
||||
}));
|
||||
|
||||
// The SearchResult now uses proper ES imports which are mocked above
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca_service.js', () => ({
|
||||
default: mockBeccaService
|
||||
}));
|
||||
|
||||
// Import SearchResult after mocking
|
||||
let SearchResult: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
mockBeccaService.getNoteTitleForPath.mockReturnValue('Test Note Title');
|
||||
|
||||
// Setup mock note
|
||||
const mockNote = {
|
||||
noteId: 'test123',
|
||||
title: 'Test Note',
|
||||
isInHiddenSubtree: vi.fn().mockReturnValue(false)
|
||||
};
|
||||
mockBecca.notes['test123'] = mockNote;
|
||||
|
||||
// Dynamically import SearchResult
|
||||
const module = await import('./search_result.js');
|
||||
SearchResult = module.default;
|
||||
});
|
||||
|
||||
describe('SearchResult', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with note path array', () => {
|
||||
const searchResult = new SearchResult(['root', 'folder', 'test123']);
|
||||
|
||||
expect(searchResult.notePathArray).toEqual(['root', 'folder', 'test123']);
|
||||
expect(searchResult.noteId).toBe('test123');
|
||||
expect(searchResult.notePath).toBe('root/folder/test123');
|
||||
expect(searchResult.score).toBe(0);
|
||||
expect(mockBeccaService.getNoteTitleForPath).toHaveBeenCalledWith(['root', 'folder', 'test123']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeScore', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
describe('basic scoring', () => {
|
||||
it('should give highest score for exact note ID match', () => {
|
||||
searchResult.computeScore('test123', ['test123']);
|
||||
expect(searchResult.score).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('should give high score for exact title match', () => {
|
||||
searchResult.computeScore('test note', ['test', 'note']);
|
||||
expect(searchResult.score).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('should give medium score for title prefix match', () => {
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it('should give lower score for title word match', () => {
|
||||
mockBecca.notes['test123'].title = 'This is a test note';
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCR scoring integration', () => {
|
||||
beforeEach(() => {
|
||||
// Mock OCR-enabled
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should add OCR score when OCR results exist', () => {
|
||||
const mockOCRResults = [
|
||||
{
|
||||
extracted_text: 'sample text from image',
|
||||
confidence: 0.95
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(mockOCRResults);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM ocr_results'),
|
||||
['test123', 'test123']
|
||||
);
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply confidence weighting to OCR scores', () => {
|
||||
const highConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.95
|
||||
}
|
||||
];
|
||||
const lowConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.30
|
||||
}
|
||||
];
|
||||
|
||||
// Test high confidence
|
||||
mockSql.getRows.mockReturnValue(highConfidenceResult);
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
const highConfidenceScore = searchResult.score;
|
||||
|
||||
// Reset and test low confidence
|
||||
searchResult.score = 0;
|
||||
mockSql.getRows.mockReturnValue(lowConfidenceResult);
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
const lowConfidenceScore = searchResult.score;
|
||||
|
||||
expect(highConfidenceScore).toBeGreaterThan(lowConfidenceScore);
|
||||
});
|
||||
|
||||
it('should handle multiple OCR results', () => {
|
||||
const multipleResults = [
|
||||
{
|
||||
extracted_text: 'first sample text',
|
||||
confidence: 0.90
|
||||
},
|
||||
{
|
||||
extracted_text: 'second sample document',
|
||||
confidence: 0.85
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(multipleResults);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
// Score should account for multiple matches
|
||||
});
|
||||
|
||||
it('should skip OCR scoring when OCR is disabled', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle OCR scoring errors gracefully', () => {
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
searchResult.computeScore('sample', ['sample']);
|
||||
}).not.toThrow();
|
||||
|
||||
// Score should still be calculated from other factors
|
||||
expect(searchResult.score).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden notes penalty', () => {
|
||||
it('should apply penalty for hidden notes', () => {
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(true);
|
||||
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const hiddenScore = searchResult.score;
|
||||
|
||||
// Reset and test non-hidden
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(false);
|
||||
searchResult.score = 0;
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const normalScore = searchResult.score;
|
||||
|
||||
expect(normalScore).toBeGreaterThan(hiddenScore);
|
||||
expect(hiddenScore).toBe(normalScore / 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addScoreForStrings', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
it('should give highest score for exact token match', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const exactScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sampling text', 1.0);
|
||||
const prefixScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'text sample text', 1.0);
|
||||
const partialScore = searchResult.score;
|
||||
|
||||
expect(exactScore).toBeGreaterThan(prefixScore);
|
||||
expect(exactScore).toBeGreaterThanOrEqual(partialScore);
|
||||
});
|
||||
|
||||
it('should apply factor multiplier correctly', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 2.0);
|
||||
const doubleFactorScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const singleFactorScore = searchResult.score;
|
||||
|
||||
expect(doubleFactorScore).toBe(singleFactorScore * 2);
|
||||
});
|
||||
|
||||
it('should handle multiple tokens', () => {
|
||||
searchResult.addScoreForStrings(['hello', 'world'], 'hello world test', 1.0);
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const lowerCaseScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'SAMPLE text', 1.0);
|
||||
const upperCaseScore = searchResult.score;
|
||||
|
||||
expect(upperCaseScore).toEqual(lowerCaseScore);
|
||||
expect(upperCaseScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOCRScore', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
it('should query for both note and attachment OCR results', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM ocr_results'),
|
||||
['test123', 'test123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply minimum confidence multiplier', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
const lowConfidenceResult = [
|
||||
{
|
||||
extracted_text: 'sample text',
|
||||
confidence: 0.1 // Very low confidence
|
||||
}
|
||||
];
|
||||
mockSql.getRows.mockReturnValue(lowConfidenceResult);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.0);
|
||||
|
||||
// Should still get some score due to minimum 0.5x multiplier
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle database query errors', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should skip when OCR is disabled', () => {
|
||||
mockOptions.getOptionBool.mockReturnValue(false);
|
||||
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle options service errors', () => {
|
||||
mockOptions.getOptionBool.mockImplementation(() => {
|
||||
throw new Error('Options service unavailable');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
searchResult.addOCRScore(['sample'], 1.5);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(mockSql.getRows).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import ValueExtractor from "../value_extractor.js";
|
||||
import { removeDiacritic } from "../../utils.js";
|
||||
import TrueExp from "../expressions/true.js";
|
||||
import IsHiddenExp from "../expressions/is_hidden.js";
|
||||
import OCRContentExpression from "../expressions/ocr_content.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
import type { TokenData, TokenStructure } from "./types.js";
|
||||
import type Expression from "../expressions/expression.js";
|
||||
@@ -34,20 +33,11 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchExpressions: Expression[] = [
|
||||
new NoteFlatTextExp(tokens)
|
||||
];
|
||||
|
||||
if (!searchContext.fastSearch) {
|
||||
searchExpressions.push(new NoteContentFulltextExp("*=*", { tokens, flatText: true }));
|
||||
|
||||
// Add OCR content search for each token
|
||||
for (const token of tokens) {
|
||||
searchExpressions.push(new OCRContentExpression(token));
|
||||
}
|
||||
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp("*=*", { tokens, flatText: true })]);
|
||||
} else {
|
||||
return new NoteFlatTextExp(tokens);
|
||||
}
|
||||
|
||||
return new OrExp(searchExpressions);
|
||||
}
|
||||
|
||||
const OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", ">", ">=", "<", "<=", "%="]);
|
||||
|
||||
@@ -7,9 +7,10 @@ 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 { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
|
||||
import hiddenSubtree 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,11 +14,12 @@ 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";
|
||||
|
||||
export const dbReady = deferred<void>();
|
||||
const dbReady = deferred<void>();
|
||||
|
||||
function schemaExists() {
|
||||
return !!sql.getValue(/*sql*/`SELECT name FROM sqlite_master
|
||||
@@ -82,7 +83,6 @@ 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 ...");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user