Compare commits

..

9 Commits

Author SHA1 Message Date
Elian Doran
f6bc65471d fix(fs_sync): sync error on froca 2025-07-26 20:35:20 +03:00
Elian Doran
eb07d4b0ed fix(fs_sync): unique constraint failed 2025-07-26 20:33:54 +03:00
Elian Doran
2c096f3080 fix(fs_sync): new files from server not synced 2025-07-26 19:30:41 +03:00
Elian Doran
bac95c97e5 fix(fs_sync): missing autocomplete 2025-07-26 19:13:40 +03:00
Elian Doran
fe6daac979 fix(fs_sync): modal not showing 2025-07-26 19:05:52 +03:00
Elian Doran
770281214b fix(fs_sync): option not readable/writable by client 2025-07-26 18:56:48 +03:00
Elian Doran
15bd5aa4e4 fix(fs_sync): cls errors in router 2025-07-26 18:40:22 +03:00
Elian Doran
3da6838395 fix(fs_sync): modal shown immediately when entering advanced 2025-07-26 18:31:34 +03:00
Elian Doran
16cdd9e137 feat(fs_sync): draft implementation 2025-07-26 18:31:16 +03:00
116 changed files with 15688 additions and 14906 deletions

View File

@@ -4,7 +4,7 @@ applyTo: '**'
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
You are in an nx workspace using Nx 21.3.7 and pnpm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:

2
.nvmrc
View File

@@ -1 +1 @@
22.18.0
22.17.1

View File

@@ -1,9 +1,10 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
Donate: ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran?style=flat-square) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran?style=flat-square)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes?style=flat-square)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total?style=flat-square)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop&style=flat-square)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
@@ -115,14 +116,6 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
## 💻 Contribute
### Translations
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
Here's the language coverage we have so far:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):

View File

@@ -35,10 +35,10 @@
"chore:generate-openapi": "tsx bin/generate-openapi.js"
},
"devDependencies": {
"@playwright/test": "1.54.2",
"@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.9",
"typedoc": "0.28.7",
"typedoc-plugin-missing-exports": "4.0.0"
},
"optionalDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.97.2",
"version": "0.97.1",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -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",
@@ -39,6 +39,7 @@
"i18next": "25.3.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.22",
@@ -51,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",
@@ -64,7 +65,7 @@
"@types/leaflet": "1.9.20",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.2.9",
"@types/tabulator-tables": "6.2.8",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",

View File

@@ -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,73 +262,6 @@ export type CommandMappings = {
closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData;
commandPalette: CommandData;
// Keyboard shortcuts
backInNoteHistory: CommandData;
forwardInNoteHistory: CommandData;
forceSaveRevision: CommandData;
scrollToActiveNote: CommandData;
quickSearch: CommandData;
collapseTree: CommandData;
createNoteAfter: CommandData;
createNoteInto: CommandData;
addNoteAboveToSelection: CommandData;
addNoteBelowToSelection: CommandData;
openNewTab: CommandData;
activateNextTab: CommandData;
activatePreviousTab: CommandData;
openNewWindow: CommandData;
toggleTray: CommandData;
firstTab: CommandData;
secondTab: CommandData;
thirdTab: CommandData;
fourthTab: CommandData;
fifthTab: CommandData;
sixthTab: CommandData;
seventhTab: CommandData;
eigthTab: CommandData;
ninthTab: CommandData;
lastTab: CommandData;
showNoteSource: CommandData;
showSQLConsole: CommandData;
showBackendLog: CommandData;
showCheatsheet: CommandData;
showHelp: CommandData;
addLinkToText: CommandData;
followLinkUnderCursor: CommandData;
insertDateTimeToText: CommandData;
pasteMarkdownIntoText: CommandData;
cutIntoNote: CommandData;
addIncludeNoteToText: CommandData;
editReadOnlyNote: CommandData;
toggleRibbonTabClassicEditor: CommandData;
toggleRibbonTabBasicProperties: CommandData;
toggleRibbonTabBookProperties: CommandData;
toggleRibbonTabFileProperties: CommandData;
toggleRibbonTabImageProperties: CommandData;
toggleRibbonTabOwnedAttributes: CommandData;
toggleRibbonTabInheritedAttributes: CommandData;
toggleRibbonTabPromotedAttributes: CommandData;
toggleRibbonTabNoteMap: CommandData;
toggleRibbonTabNoteInfo: CommandData;
toggleRibbonTabNotePaths: CommandData;
toggleRibbonTabSimilarNotes: CommandData;
toggleRightPane: CommandData;
printActiveNote: CommandData;
exportAsPdf: CommandData;
openNoteExternally: CommandData;
renderActiveNote: CommandData;
unhoist: CommandData;
reloadFrontendApp: CommandData;
openDevTools: CommandData;
findInText: CommandData;
toggleLeftPane: CommandData;
toggleFullscreen: CommandData;
zoomOut: CommandData;
zoomIn: CommandData;
zoomReset: CommandData;
copyWithoutFormatting: CommandData;
// Geomap
deleteFromMap: { noteId: string };

View File

@@ -30,6 +30,13 @@ interface CreateChildrenResponse {
export default class Entrypoints extends Component {
constructor() {
super();
if (jQuery.hotkeys) {
// hot keys are active also inside inputs and content editables
jQuery.hotkeys.options.filterInputAcceptingElements = false;
jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
}
}
openDevToolsCommand() {
@@ -106,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() {

View File

@@ -13,6 +13,7 @@ import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "jquery-hotkeys";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();

View File

@@ -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 },

View File

@@ -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();

View File

@@ -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;

View File

@@ -65,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")
@@ -75,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) {

View File

@@ -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}'`);

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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
};

View File

@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
let dismissTimer: ReturnType<typeof setTimeout>;
function setupGlobalTooltip() {
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "a", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
$(document).on("click", (e) => {

View File

@@ -1,323 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
default: {
isDesktop: () => true
}
}));
// Mock jQuery globally since it's used in the shortcuts module
const mockElement = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
const mockJQuery = vi.fn(() => [mockElement]);
(mockJQuery as any).length = 1;
mockJQuery[0] = mockElement;
(global as any).$ = mockJQuery as any;
global.document = mockElement as any;
describe("shortcuts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Clean up any active bindings after each test
shortcuts.removeGlobalShortcut("test-namespace");
});
describe("normalizeShortcut", () => {
it("should normalize shortcut to lowercase and remove whitespace", () => {
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
});
it("should handle empty or null shortcuts", () => {
expect(shortcuts.normalizeShortcut("")).toBe("");
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
});
it("should handle shortcuts with multiple spaces", () => {
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
});
it("should warn about malformed shortcuts", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
shortcuts.normalizeShortcut("ctrl+");
shortcuts.normalizeShortcut("+a");
shortcuts.normalizeShortcut("ctrl++a");
expect(consoleSpy).toHaveBeenCalledTimes(3);
consoleSpy.mockRestore();
});
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
const event = createKeyboardEvent("a", "KeyA");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "A")).toBe(true);
});
it("should match number keys using digit codes", () => {
const event = createKeyboardEvent("1", "Digit1");
expect(keyMatches(event, "1")).toBe(true);
});
it("should match special keys using key mapping", () => {
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
});
it("should match function keys", () => {
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
});
it("should handle undefined or null keys", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("matchesShortcut", () => {
const createKeyboardEvent = (options: {
key: string;
code?: string;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
}) => ({
key: options.key,
code: options.code || `Key${options.key.toUpperCase()}`,
ctrlKey: options.ctrlKey || false,
altKey: options.altKey || false,
shiftKey: options.shiftKey || false,
metaKey: options.metaKey || false
} as KeyboardEvent);
it("should match simple key shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(true);
});
it("should match shortcuts with modifiers", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
});
it("should match complex modifier combinations", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyA",
ctrlKey: true,
shiftKey: true
});
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
});
it("should not match when modifiers don't match", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "alt+a")).toBe(false);
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should handle alternative modifier names", () => {
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
});
it("should handle empty or invalid shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "")).toBe(false);
expect(matchesShortcut(event, null as any)).toBe(false);
});
it("should handle invalid events", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(matchesShortcut(null as any, "a")).toBe(false);
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should warn about invalid shortcut formats", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
matchesShortcut(event, "ctrl+");
matchesShortcut(event, "+");
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("bindGlobalShortcut", () => {
it("should bind a global shortcut", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should not bind shortcuts when handler is null", () => {
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
expect(mockElement.addEventListener).not.toHaveBeenCalled();
});
it("should remove previous bindings when namespace is reused", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
});
});
describe("bindElShortcut", () => {
it("should bind shortcut to specific element", () => {
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const mockJQueryEl = [mockEl] as any;
mockJQueryEl.length = 1;
const handler = vi.fn();
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should fall back to document when element is empty", () => {
const emptyJQuery = [] as any;
emptyJQuery.length = 0;
const handler = vi.fn();
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("removeGlobalShortcut", () => {
it("should remove shortcuts for a specific namespace", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
shortcuts.removeGlobalShortcut("test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("event handling", () => {
it.skip("should call handler when shortcut matches", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
// Get the listener that was registered
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
const [, listener] = mockElement.addEventListener.mock.calls[0];
// First verify that matchesShortcut works directly
const testEvent = {
type: "keydown",
key: "a",
code: "KeyA",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
// Test matchesShortcut directly first
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
// Now test the actual listener
listener(testEvent);
expect(handler).toHaveBeenCalled();
expect(testEvent.preventDefault).toHaveBeenCalled();
expect(testEvent.stopPropagation).toHaveBeenCalled();
});
it("should not call handler for non-keyboard events", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-keyboard event
const event = {
type: "click"
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
});
it("should not call handler when shortcut doesn't match", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-matching keydown event
const event = {
type: "keydown",
key: "b",
code: "KeyB",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,18 +1,7 @@
import utils from "./utils.js";
type ElementType = HTMLElement | Document;
type Handler = (e: KeyboardEvent) => void;
interface ShortcutBinding {
element: HTMLElement | Document;
shortcut: string;
handler: Handler;
namespace: string | null;
listener: (evt: Event) => void;
}
// Store all active shortcut bindings for management
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
function removeGlobalShortcut(namespace: string) {
bindGlobalShortcut("", null, namespace);
@@ -26,167 +15,38 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
if (utils.isDesktop()) {
keyboardShortcut = normalizeShortcut(keyboardShortcut);
// If namespace is provided, remove all previous bindings for this namespace
let eventName = "keydown";
if (namespace) {
removeNamespaceBindings(namespace);
eventName += `.${namespace}`;
// if there's a namespace, then we replace the existing event handler with the new one
$el.off(eventName);
}
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut && handler) {
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
const listener = (evt: Event) => {
// Only handle keyboard events
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
return;
}
const e = evt as KeyboardEvent;
if (matchesShortcut(e, keyboardShortcut)) {
e.preventDefault();
e.stopPropagation();
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut) {
$el.bind(eventName, keyboardShortcut, (e) => {
if (handler) {
handler(e);
}
};
// Add the event listener
element.addEventListener('keydown', listener);
// Store the binding for later cleanup
const binding: ShortcutBinding = {
element,
shortcut: keyboardShortcut,
handler,
namespace,
listener
};
const key = namespace || 'global';
if (!activeBindings.has(key)) {
activeBindings.set(key, []);
}
activeBindings.get(key)!.push(binding);
e.preventDefault();
e.stopPropagation();
});
}
}
}
function removeNamespaceBindings(namespace: string) {
const bindings = activeBindings.get(namespace);
if (bindings) {
// Remove all event listeners for this namespace
bindings.forEach(binding => {
binding.element.removeEventListener('keydown', binding.listener);
});
activeBindings.delete(namespace);
}
}
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
if (!shortcut) return false;
// Ensure we have a proper KeyboardEvent with key property
if (!e || typeof e.key !== 'string') {
console.warn('matchesShortcut called with invalid event:', e);
return false;
}
const parts = shortcut.toLowerCase().split('+');
const key = parts[parts.length - 1]; // Last part is the actual key
const modifiers = parts.slice(0, -1); // Everything before is modifiers
// Defensive check - ensure we have a valid key
if (!key || key.trim() === '') {
console.warn('Invalid shortcut format:', shortcut);
return false;
}
// Check if the main key matches
if (!keyMatches(e, key)) {
return false;
}
// Check modifiers
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
const expectedAlt = modifiers.includes('alt');
const expectedShift = modifiers.includes('shift');
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
return e.ctrlKey === expectedCtrl &&
e.altKey === expectedAlt &&
e.shiftKey === expectedShift &&
e.metaKey === expectedMeta;
}
export function keyMatches(e: KeyboardEvent, key: string): boolean {
// Defensive check for undefined/null key
if (!key) {
console.warn('keyMatches called with undefined/null key');
return false;
}
// Handle special key mappings and aliases
const keyMap: { [key: string]: string[] } = {
'return': ['Enter'],
'enter': ['Enter'], // alias for return
'del': ['Delete'],
'delete': ['Delete'], // alias for del
'esc': ['Escape'],
'escape': ['Escape'], // alias for esc
'space': [' ', 'Space'],
'tab': ['Tab'],
'backspace': ['Backspace'],
'home': ['Home'],
'end': ['End'],
'pageup': ['PageUp'],
'pagedown': ['PageDown'],
'up': ['ArrowUp'],
'down': ['ArrowDown'],
'left': ['ArrowLeft'],
'right': ['ArrowRight']
};
// Function keys
for (let i = 1; i <= 19; i++) {
keyMap[`f${i}`] = [`F${i}`];
}
const mappedKeys = keyMap[key.toLowerCase()];
if (mappedKeys) {
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
}
// For number keys, use the physical key code regardless of modifiers
// This works across all keyboard layouts
if (key >= '0' && key <= '9') {
return e.code === `Digit${key}`;
}
// For letter keys, use the physical key code for consistency
if (key.length === 1 && key >= 'a' && key <= 'z') {
return e.code === `Key${key.toUpperCase()}`;
}
// For regular keys, check both key and code as fallback
return e.key.toLowerCase() === key.toLowerCase() ||
e.code.toLowerCase() === key.toLowerCase();
}
/**
* Simple normalization - just lowercase and trim whitespace
* Normalize to the form expected by the jquery.hotkeys.js
*/
function normalizeShortcut(shortcut: string): string {
if (!shortcut) {
return shortcut;
}
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
// Warn about potentially problematic shortcuts
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
}
return normalized;
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
}
export default {

View File

@@ -36,9 +36,7 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
const $copyButton = $("<button>")
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
.attr("title", t("code_block.copy_title"))
.on("click", (e) => {
e.stopPropagation();
.on("click", () => {
if (!isShare) {
copyTextWithToast($codeBlock.text());
} else {

View File

@@ -1,4 +1,5 @@
import "jquery";
import "jquery-hotkeys";
import utils from "./services/utils.js";
import ko from "knockout";
import "./stylesheets/bootstrap.scss";

View File

@@ -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;
}

View File

@@ -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;
}
@@ -2251,3 +2201,189 @@ footer.webview-footer button {
content: "\ec24";
transform: rotate(180deg);
}
/* 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;
}
.mapping-modal .modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1051;
}
.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;
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;
}

View File

@@ -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;
}

View File

@@ -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 */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>"
},
@@ -1987,23 +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"
},
"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"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
{
"code_block": {
"theme_none": "Sem destaque de sintaxe",
"theme_group_light": "Temas claros",
"theme_group_dark": "Temas escuros"
}
"revisions": {
"delete_button": ""
},
"code_block": {
"theme_none": "Sem destaque de sintaxe",
"theme_group_light": "Temas claros",
"theme_group_dark": "Temas escuros"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +0,0 @@
{
"about": {
"title": "O Trilium Belеškama",
"close": "Zatvori",
"homepage": "Početna stranica:",
"app_version": "Verzija aplikacije:",
"db_version": "Verzija baze podataka:",
"sync_version": "Verzija sinhronizacije:",
"build_date": "Datum izgradnje:",
"build_revision": "Revizija izgradnje:",
"data_directory": "Direktorijum sa podacima:"
},
"toast": {
"critical-error": {
"title": "Kritična greška",
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
},
"widget-error": {
"title": "Pokretanje vidžeta nije uspelo",
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
},
"bundle-error": {
"title": "Pokretanje prilagođene skripte neuspešno",
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
}
},
"add_link": {
"add_link": "Dodaj link",
"help_on_links": "Pomoć na linkovima",
"close": "Zatvori",
"note": "Beleška",
"search_note": "potražite belešku po njenom imenu",
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
"link_title": "Naziv linka",
"button_add_link": "Dodaj link <kbd>enter</kbd>"
},
"branch_prefix": {
"edit_branch_prefix": "Izmeni prefiks grane",
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
"close": "Zatvori",
"prefix": "Prefiks: ",
"save": "Sačuvaj",
"branch_prefix_saved": "Prefiks grane je sačuvan."
},
"bulk_actions": {
"bulk_actions": "Grupne akcije",
"close": "Zatvori",
"affected_notes": "Pogođene beleške",
"include_descendants": "Obuhvati potomke izabranih beleški",
"available_actions": "Dostupne akcije",
"chosen_actions": "Izabrane akcije",
"execute_bulk_actions": "Izvrši grupne akcije",
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
"labels": "Oznake",
"relations": "Odnosi",
"notes": "Beleške",
"other": "Ostalo"
},
"clone_to": {
"clone_notes_to": "Klonirajte beleške u...",
"close": "Zatvori",
"help_on_links": "Pomoć na linkovima",
"notes_to_clone": "Beleške za kloniranje",
"target_parent_note": "Ciljna nadređena beleška",
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
"prefix_optional": "Prefiks (opciono)",
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
"no_path_to_clone_to": "Nema putanje za kloniranje.",
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
},
"confirm": {
"confirmation": "Potvrda",
"close": "Zatvori",
"cancel": "Otkaži",
"ok": "U redu",
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
},
"export": {
"export_note_title": "Izvezi belešku",
"close": "Zatvori",
"export_type_subtree": "Ova beleška i svi njeni potomci",
"format_html": "HTML - preporučuje se jer čuva formatiranje",
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
"opml_version_1": "OPML v1.0 - samo običan tekst",
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
"export_type_single": "Samo ovu belešku bez njenih potomaka",
"export": "Izvoz",
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
"export_status": "Status izvoza",
"export_in_progress": "Izvoz u toku: {{progressCount}}",
"export_finished_successfully": "Izvoz je uspešno završen.",
"format_pdf": "PDF - za namene štampanja ili deljenja."
},
"help": {
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
"close": "Zatvori",
"noteNavigation": "Navigacija beleški",
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
"notSet": "nije podešeno",
"goBackForwards": "idi u nazad/napred kroz istoriju",
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
"scrollToActiveNote": "skroluj do aktivne beleške",
"jumpToParentNote": "<kbd>Backspace</kbd> - idi do nadređene beleške",
"collapseWholeTree": "sakupi celo drvo beleški",
"collapseSubTree": "sakupi pod-drvo",
"tabShortcuts": "Prečice na karticama",
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (ili <kbd>middle mouse click</kbd>) na link beleške otvara belešku u novoj kartici",
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (ili <kbd>Shift+middle mouse click</kbd>) na link beleške otvara i aktivira belešku u novoj kartici",
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
"openEmptyTab": "otvori praznu karticu",
"closeActiveTab": "zatvori aktivnu karticu",
"activateNextTab": "aktiviraj narednu karticu",
"activatePreviousTab": "aktiviraj prethodnu karticu",
"creatingNotes": "Pravljenje beleški",
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
"movingCloningNotes": "Premeštanje / kloniranje beleški",
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
"selectNote": "<kbd>Shift+click</kbd> - izaberi belešku",
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
"deleteNotes": "obriši belešku / podstablo",
"editingNotes": "Izmena beleški",
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
"createEditLink": "<kbd>Ctrl+K</kbd> - napravi / izmeni spoljašnji link",
"createInternalLink": "napravi unutrašnji link",
"followLink": "prati link ispod kursora",
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
"troubleshooting": "Rešavanje problema",
"reloadFrontend": "ponovo učitaj Trilium frontend",
"showDevTools": "prikaži alate za programere",
"showSQLConsole": "prikaži SQL konzolu",
"other": "Ostalo",
"quickSearch": "fokus na unos za brzu pretragu",
"inPageSearch": "pretraga unutar stranice"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,16 @@ declare global {
setNote(noteId: string);
}
interface JQueryStatic {
hotkeys: {
options: {
filterInputAcceptingElements: boolean;
filterContentEditable: boolean;
filterTextInputs: boolean;
}
}
}
var logError: (message: string, e?: Error | string) => void;
var logInfo: (message: string) => void;
var glob: CustomGlobals;

View File

@@ -1,10 +1,9 @@
import { ActionKeyboardShortcut } from "@triliumnext/commons";
import type { CommandNames } from "../../components/app_context.js";
import keyboardActionsService from "../../services/keyboard_actions.js";
import keyboardActionsService, { type Action } from "../../services/keyboard_actions.js";
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
import type { ButtonNoteIdProvider } from "./button_from_note.js";
let actions: ActionKeyboardShortcut[];
let actions: Action[];
keyboardActionsService.getActions().then((as) => (actions = as));
@@ -50,7 +49,7 @@ export default class CommandButtonWidget extends AbstractButtonWidget<CommandBut
const action = actions.find((act) => act.actionName === this._command);
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
if (action && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title;

View File

@@ -186,7 +186,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));

View File

@@ -268,7 +268,7 @@ export default class RibbonContainer extends NoteContextAwareWidget {
const action = actions.find((act) => act.actionName === toggleCommandName);
const title = $(this).attr("data-title");
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
if (action && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title ?? "";

View File

@@ -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,89 +81,50 @@ 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");
}
}
showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
showInFullText(e: JQuery.TriggeredEvent) {
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
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 });

View File

@@ -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");

View File

@@ -97,7 +97,6 @@ const TPL = /*html*/`
</div>
</div>`;
const SUPPORTED_NOTE_TYPES = ["text", "code", "render", "mindMap", "doc"];
export default class FindWidget extends NoteContextAwareWidget {
private searchTerm: string | null;
@@ -189,7 +188,7 @@ export default class FindWidget extends NoteContextAwareWidget {
return;
}
if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
if (!["text", "code", "render", "mindMap"].includes(this.note?.type ?? "")) {
return;
}
@@ -252,7 +251,6 @@ export default class FindWidget extends NoteContextAwareWidget {
const readOnly = await this.noteContext?.isReadOnly();
return readOnly ? this.htmlHandler : this.textHandler;
case "mindMap":
case "doc":
return this.htmlHandler;
default:
console.warn("FindWidget: Unsupported note type for find widget", this.note?.type);
@@ -356,7 +354,7 @@ export default class FindWidget extends NoteContextAwareWidget {
}
isEnabled() {
return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "");
return super.isEnabled() && ["text", "code", "render", "mindMap"].includes(this.note?.type ?? "");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@@ -727,9 +727,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
for (const key in hotKeys) {
const handler = hotKeys[key];
shortcutService.bindElShortcut($(this.tree.$container), key, () => {
$(this.tree.$container).on("keydown", null, key, (evt) => {
const node = this.tree.getActiveNode();
return handler(node, {} as JQuery.KeyDownEvent);
return handler(node, evt);
// return false from the handler will stop default handling.
});
}
@@ -1552,7 +1552,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const hotKeyMap: Record<string, (node: Fancytree.FancytreeNode, e: JQuery.KeyDownEvent) => boolean> = {};
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
for (const shortcut of action.effectiveShortcuts) {
hotKeyMap[shortcutService.normalizeShortcut(shortcut)] = (node) => {
const notePath = treeService.getNotePath(node);

View File

@@ -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

View File

@@ -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&#10;node_modules&#10;.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();
}
}
}

View File

@@ -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))

View File

@@ -52,8 +52,7 @@ export default class DateTimeFormatOptions extends OptionsWidget {
}
async optionsLoaded(options: OptionMap) {
const action = await keyboardActionsService.getAction("insertDateTimeToText");
const shortcutKey = (action.effectiveShortcuts ?? []).join(", ");
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
"title": shortcutKey,
"showTooltip": false

View File

@@ -292,7 +292,6 @@ class ListOrGridView extends ViewMode<{}> {
const $card = $('<div class="note-book-card">')
.attr("data-note-id", note.noteId)
.addClass("no-tooltip-preview")
.append(
$('<h5 class="note-book-header">')
.append($expander)

View File

@@ -19,6 +19,6 @@
},
"devDependencies": {
"dotenv": "17.2.1",
"electron": "37.2.5"
"electron": "37.2.4"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/desktop",
"version": "0.97.2",
"version": "0.97.1",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "main.cjs",
@@ -17,15 +17,15 @@
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.0",
"electron": "37.2.5",
"@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": "37.2.4",
"@electron-forge/cli": "7.8.1",
"@electron-forge/maker-deb": "7.8.1",
"@electron-forge/maker-dmg": "7.8.1",
"@electron-forge/maker-flatpak": "7.8.1",
"@electron-forge/maker-rpm": "7.8.1",
"@electron-forge/maker-squirrel": "7.8.1",
"@electron-forge/maker-zip": "7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "7.8.1",
"prebuild-install": "^7.1.1"
},
"config": {

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.0",
"electron": "37.2.5",
"electron": "37.2.4",
"fs-extra": "11.3.0"
},
"nx": {

View File

@@ -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 });

View File

@@ -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() {

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.97.2",
"version": "0.97.1",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"dependencies": {
@@ -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",
@@ -59,7 +59,7 @@
"debounce": "2.2.0",
"debug": "4.4.1",
"ejs": "3.1.10",
"electron": "37.2.5",
"electron": "37.2.4",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
@@ -88,7 +88,7 @@
"multer": "2.0.2",
"normalize-strings": "1.1.1",
"ollama": "0.5.16",
"openai": "5.11.0",
"openai": "5.10.2",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@@ -354,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"
}
}
},

View File

@@ -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);
}

View File

@@ -152,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");

File diff suppressed because one or more lines are too long

View 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&nbsp;<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -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&nbsp;<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>&gt;</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>&gt;</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&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>&nbsp;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&nbsp;<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

View File

@@ -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>&nbsp;which
<li data-list-item-id="e7f3117635b8c3e905c71f2839e331942"><a class="reference-link" href="#root/_help_8QqnMzx393bx">Grid View</a>&nbsp;which
is the default presentation method for child notes (see&nbsp;<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>&nbsp;is
<li data-list-item-id="e27a2ec6976e44512c9c52ba8d7a2ef76"><a class="reference-link" href="#root/_help_mULW0Q3VojwY">List View</a>&nbsp;is
similar to&nbsp;<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>&nbsp;which
<li data-list-item-id="eae5c2f56f9a75e7ed813fe419d4a05a4"><a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a>&nbsp;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>&nbsp;which
<li
data-list-item-id="e24642a7a4c2443497fd814d78f1ca784"><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a>&nbsp;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>&nbsp;displays
each note as a row in a table, with&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;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>&nbsp;(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>&nbsp;displays
each note as a row in a table, with&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;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>&nbsp;(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&nbsp;<a class="reference-link"
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and look for the <em>Collections</em> entry
and select the desired type.</p>
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;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&nbsp;<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&nbsp;<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&nbsp;<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&nbsp;<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>

View File

@@ -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&nbsp;<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>&nbsp;</p>
<p>&nbsp;</p>

View File

@@ -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&nbsp;<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&nbsp;<a class="reference-link"
<li data-list-item-id="e48a95d7d971cadd6f1b53e25d567be7e">Go to <em>Collection Properties</em> in the&nbsp;<a class="reference-link"
href="#root/_help_BlN9DFI679QC">Ribbon</a>&nbsp;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&nbsp;<a class="reference-link"
<li data-list-item-id="efcbcbbae267cd2ed3ddac4d512a6c5a1">Multi-value labels and relations are not supported. If a&nbsp;<a class="reference-link"
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;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&nbsp;<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&nbsp;<a class="reference-link"
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;to the&nbsp;

View File

@@ -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": "书签",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,390 +1,307 @@
{
"keyboard_actions": {
"back-in-note-history": "Navegar a la nota previa en el historial",
"forward-in-note-history": "Navegar a la nota siguiente en el historial",
"open-jump-to-note-dialog": "Abrir cuadro de diálogo \"Saltar a nota\"",
"scroll-to-active-note": "Desplazarse a la nota activa en el árbol de notas",
"quick-search": "Activar barra de búsqueda rápida",
"search-in-subtree": "Buscar notas en el subárbol de la nota activa",
"expand-subtree": "Expandir el subárbol de la nota actual",
"collapse-tree": "Colapsa el árbol de notas completo",
"collapse-subtree": "Colapsa el subárbol de la nota actual",
"sort-child-notes": "Ordenar subnotas",
"creating-and-moving-notes": "Creando y moviendo notas",
"create-note-after": "Crear nota después de la nota activa",
"create-note-into": "Crear nota como subnota de la nota activa",
"create-note-into-inbox": "Crear una nota en la bandeja de entrada (si está definida) o nota del día",
"delete-note": "Eliminar nota",
"move-note-up": "Mover nota hacia arriba",
"move-note-down": "Mover nota hacia abajo",
"move-note-up-in-hierarchy": "Mover nota hacia arriba en la jerarquía",
"move-note-down-in-hierarchy": "Mover nota hacia abajo en la jerarquía",
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
"cloneNotesTo": "Clonar notas seleccionadas",
"moveNotesTo": "Mover notas seleccionadas",
"note-clipboard": "Portapapeles de notas",
"copy-notes-to-clipboard": "Copiar las notas seleccionadas al portapapeles",
"paste-notes-from-clipboard": "Pegar las notas del portapapeles en una nota activa",
"cut-notes-to-clipboard": "Cortar las notas seleccionadas al portapapeles",
"select-all-notes-in-parent": "Seleccionar todas las notas del nivel de la nota actual",
"add-note-above-to-the-selection": "Agregar nota arriba de la selección",
"add-note-below-to-selection": "Agregar nota arriba de la selección",
"duplicate-subtree": "Duplicar subárbol",
"tabs-and-windows": "Pestañas y ventanas",
"open-new-tab": "Abre una nueva pestaña",
"close-active-tab": "Cierra la pestaña activa",
"reopen-last-tab": "Vuelve a abrir la última pestaña cerrada",
"activate-next-tab": "Activa la pestaña de la derecha",
"activate-previous-tab": "Activa la pestaña de la izquierda",
"open-new-window": "Abrir nueva ventana vacía",
"toggle-tray": "Muestra/Oculta la aplicación en la bandeja del sistema",
"first-tab": "Activa la primera pestaña de la lista",
"second-tab": "Activa la segunda pestaña de la lista",
"third-tab": "Activa la tercera pestaña de la lista",
"fourth-tab": "Activa la cuarta pestaña de la lista",
"fifth-tab": "Activa la quinta pestaña de la lista",
"sixth-tab": "Activa la sexta pestaña de la lista",
"seventh-tab": "Activa la séptima pestaña de la lista",
"eight-tab": "Activa la octava pestaña de la lista",
"ninth-tab": "Activa la novena pestaña de la lista",
"last-tab": "Activa la última pestaña de la lista",
"dialogs": "Diálogos",
"show-note-source": "Muestra el cuadro de diálogo Fuente de nota",
"show-options": "Muestra el cuadro de diálogo Opciones",
"show-revisions": "Muestra el cuadro de diálogo Revisiones de notas",
"show-recent-changes": "Muestra el cuadro de diálogo Cambios recientes",
"show-sql-console": "Muestra el cuadro de diálogo Consola SQL",
"show-backend-log": "Muestra el cuadro de diálogo Registro de backend",
"show-help": "Muestra ayuda/hoja de referencia integrada",
"show-cheatsheet": "Muestra un modal con operaciones de teclado comunes",
"text-note-operations": "Operaciones de notas de texto",
"add-link-to-text": "Abrir cuadro de diálogo para agregar un enlace al texto",
"follow-link-under-cursor": "Seguir el enlace dentro del cual se coloca el cursor",
"insert-date-and-time-to-text": "Insertar fecha y hora actuales en el texto",
"paste-markdown-into-text": "Pega Markdown del portapapeles en la nota de texto",
"cut-into-note": "Corta la selección de la nota actual y crea una subnota con el texto seleccionado",
"add-include-note-to-text": "Abre el cuadro de diálogo para incluir una nota",
"edit-readonly-note": "Editar una nota de sólo lectura",
"attributes-labels-and-relations": "Atributos (etiquetas y relaciones)",
"add-new-label": "Crear nueva etiqueta",
"create-new-relation": "Crear nueva relación",
"ribbon-tabs": "Pestañas de cinta",
"toggle-basic-properties": "Alternar propiedades básicas",
"toggle-file-properties": "Alternar propiedades de archivo",
"toggle-image-properties": "Alternar propiedades de imagen",
"toggle-owned-attributes": "Alternar atributos de propiedad",
"toggle-inherited-attributes": "Alternar atributos heredados",
"toggle-promoted-attributes": "Alternar atributos promocionados",
"toggle-link-map": "Alternar mapa de enlaces",
"toggle-note-info": "Alternar información de nota",
"toggle-note-paths": "Alternar rutas de notas",
"toggle-similar-notes": "Alternar notas similares",
"other": "Otro",
"toggle-right-pane": "Alternar la visualización del panel derecho, que incluye la tabla de contenidos y aspectos destacados",
"print-active-note": "Imprimir nota activa",
"open-note-externally": "Abrir nota como un archivo con la aplicación predeterminada",
"render-active-note": "Renderizar (volver a renderizar) nota activa",
"run-active-note": "Ejecutar nota de código JavaScript activa (frontend/backend)",
"toggle-note-hoisting": "Alterna la elevación de la nota activa",
"unhoist": "Bajar desde cualquier lugar",
"reload-frontend-app": "Recargar frontend de la aplicación",
"open-dev-tools": "Abrir herramientas de desarrollo",
"find-in-text": "Alternar panel de búsqueda",
"toggle-left-note-tree-panel": "Alternar panel izquierdo (árbol de notas)",
"toggle-full-screen": "Alternar pantalla completa",
"zoom-out": "Alejar",
"zoom-in": "Acercar",
"note-navigation": "Navegación de notas",
"reset-zoom-level": "Restablecer nivel de zoom",
"copy-without-formatting": "Copiar el texto seleccionado sin formatear",
"force-save-revision": "Forzar la creación/guardado de una nueva revisión de nota de la nota activa",
"toggle-book-properties": "Alternar propiedades del libro",
"toggle-classic-editor-toolbar": "Alternar la pestaña de formato por el editor con barra de herramientas fija",
"export-as-pdf": "Exporta la nota actual como un PDF",
"toggle-zen-mode": "Habilita/Deshabilita el modo Zen (IU mínima para edición sin distracciones)",
"open-command-palette": "Abrir paleta de comandos",
"clone-notes-to": "Clonar notas seleccionadas",
"move-notes-to": "Mover notas seleccionadas"
},
"login": {
"title": "Iniciar sesión",
"heading": "Iniciar sesión en Trilium",
"incorrect-totp": "El TOTP es incorrecto. Por favor, intente de nuevo.",
"incorrect-password": "La contraseña es incorrecta. Por favor inténtalo de nuevo.",
"password": "Contraseña",
"remember-me": "Recordarme",
"button": "Iniciar sesión",
"sign_in_with_sso": "Iniciar sesión con {{ ssoIssuerName }}"
},
"set_password": {
"title": "Establecer contraseña",
"heading": "Establecer contraseña",
"description": "Antes de poder comenzar a usar Trilium desde la web, primero debe establecer una contraseña. Luego utilizará esta contraseña para iniciar sesión.",
"password": "Contraseña",
"password-confirmation": "Confirmación de contraseña",
"button": "Establecer contraseña"
},
"javascript-required": "Trilium requiere que JavaScript esté habilitado.",
"setup": {
"heading": "Configuración de Trilium Notes",
"new-document": "Soy un usuario nuevo y quiero crear un nuevo documento de Trilium para mis notas",
"sync-from-desktop": "Ya tengo una instancia de escritorio y quiero configurar la sincronización con ella",
"sync-from-server": "Ya tengo una instancia de servidor y quiero configurar la sincronización con ella",
"next": "Siguiente",
"init-in-progress": "Inicialización del documento en curso",
"redirecting": "En breve será redirigido a la aplicación.",
"title": "Configuración"
},
"setup_sync-from-desktop": {
"heading": "Sincronizar desde el escritorio",
"description": "Esta configuración debe iniciarse desde la instancia de escritorio:",
"step1": "Abra su instancia de escritorio de Trilium Notes.",
"step2": "En el menú Trilium, dé clic en Opciones.",
"step3": "Dé clic en la categoría Sincronizar.",
"step4": "Cambie la dirección de la instancia del servidor a: {{- host}} y dé clic en Guardar.",
"step5": "Dé clic en el botón \"Probar sincronización\" para verificar que la conexión fue exitosa.",
"step6": "Una vez que haya completado estos pasos, dé clic en {{- link}}.",
"step6-here": "aquí"
},
"setup_sync-from-server": {
"heading": "Sincronización desde el servidor",
"instructions": "Por favor, ingrese la dirección y las credenciales del servidor Trilium a continuación. Esto descargará todo el documento de Trilium desde el servidor y configurará la sincronización. Dependiendo del tamaño del documento y de la velocidad de su conexión, esto puede tardar un poco.",
"server-host": "Dirección del servidor Trilium",
"server-host-placeholder": "https://<hostname>:<port>",
"proxy-server": "Servidor proxy (opcional)",
"proxy-server-placeholder": "https://<hostname>:<port>",
"note": "Nota:",
"proxy-instruction": "Si deja la configuración de proxy en blanco, se utilizará el proxy del sistema (aplica únicamente a la aplicación de escritorio)",
"password": "Contraseña",
"password-placeholder": "Contraseña",
"back": "Atrás",
"finish-setup": "Finalizar la configuración"
},
"setup_sync-in-progress": {
"heading": "Sincronización en progreso",
"successful": "La sincronización se ha configurado correctamente. La sincronización inicial tardará algún tiempo en finalizar. Una vez hecho esto, será redirigido a la página de inicio de sesión.",
"outstanding-items": "Elementos de sincronización destacados:",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "No encontrado",
"heading": "No encontrado"
},
"share_page": {
"parent": "padre:",
"clipped-from": "Esta nota fue recortada originalmente de {{- url}}",
"child-notes": "Subnotas:",
"no-content": "Esta nota no tiene contenido."
},
"weekdays": {
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo"
},
"weekdayNumber": "Semana {weekNumber}",
"months": {
"january": "Enero",
"february": "Febrero",
"march": "Marzo",
"april": "Abril",
"may": "Mayo",
"june": "Junio",
"july": "Julio",
"august": "Agosto",
"september": "Septiembre",
"october": "Octubre",
"november": "Noviembre",
"december": "Diciembre"
},
"quarterNumber": "Cuarto {quarterNumber}",
"special_notes": {
"search_prefix": "Buscar:"
},
"test_sync": {
"not-configured": "El servidor de sincronización no está configurado. Por favor configure primero la sincronización.",
"successful": "El protocolo de enlace del servidor de sincronización ha sido exitoso, la sincronización ha comenzado."
},
"hidden-subtree": {
"root-title": "Notas ocultas",
"search-history-title": "Buscar historial",
"note-map-title": "Mapa de nota",
"sql-console-history-title": "Historial de consola SQL",
"shared-notes-title": "Notas compartidas",
"bulk-action-title": "Acción en lote",
"backend-log-title": "Registro de Backend",
"user-hidden-title": "Usuario oculto",
"launch-bar-templates-title": "Plantillas de barra de lanzamiento",
"base-abstract-launcher-title": "Lanzador abstracto base",
"command-launcher-title": "Lanzador de comando",
"note-launcher-title": "Lanzador de nota",
"script-launcher-title": "Lanzador de script",
"built-in-widget-title": "Widget integrado",
"spacer-title": "Espaciador",
"custom-widget-title": "Widget personalizado",
"launch-bar-title": "Barra de lanzamiento",
"available-launchers-title": "Lanzadores disponibles",
"go-to-previous-note-title": "Ir a nota previa",
"go-to-next-note-title": "Ir a nota siguiente",
"new-note-title": "Nueva nota",
"search-notes-title": "Buscar notas",
"calendar-title": "Calendario",
"recent-changes-title": "Cambios recientes",
"bookmarks-title": "Marcadores",
"open-today-journal-note-title": "Abrir nota del diario de hoy",
"quick-search-title": "Búsqueda rápida",
"protected-session-title": "Sesión protegida",
"sync-status-title": "Sincronizar estado",
"settings-title": "Ajustes",
"llm-chat-title": "Chat con notas",
"options-title": "Opciones",
"appearance-title": "Apariencia",
"shortcuts-title": "Atajos",
"text-notes": "Notas de texto",
"code-notes-title": "Notas de código",
"images-title": "Imágenes",
"spellcheck-title": "Corrección ortográfica",
"password-title": "Contraseña",
"multi-factor-authentication-title": "MFA",
"etapi-title": "ETAPI",
"backup-title": "Respaldo",
"sync-title": "Sincronizar",
"ai-llm-title": "IA/LLM",
"other": "Otros",
"advanced-title": "Avanzado",
"visible-launchers-title": "Lanzadores visibles",
"user-guide": "Guía de Usuario",
"localization": "Idioma y Región",
"inbox-title": "Bandeja",
"jump-to-note-title": "Saltar a..."
},
"notes": {
"new-note": "Nueva nota",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle}} {{duplicateNoteSuffix}}"
},
"backend_log": {
"log-does-not-exist": "El archivo de registro del backend '{{fileName}}' no existe (aún).",
"reading-log-failed": "Leer el archivo de registro del backend '{{fileName}}' falló."
},
"content_renderer": {
"note-cannot-be-displayed": "Este tipo de nota no puede ser mostrado."
},
"pdf": {
"export_filter": "Documento PDF (*.pdf)",
"unable-to-export-message": "La nota actual no pudo ser exportada como PDF.",
"unable-to-export-title": "No es posible exportar como PDF",
"unable-to-save-message": "No se pudo escribir en el archivo seleccionado. Intente de nuevo o seleccione otro destino."
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Cerrar Trilium",
"recents": "Notas recientes",
"bookmarks": "Marcadores",
"today": "Abrir nota del diario de hoy",
"new-note": "Nueva nota",
"show-windows": "Mostrar ventanas",
"open_new_window": "Abrir nueva ventana"
},
"migration": {
"old_version": "La migración directa desde tu versión actual no está soportada. Por favor actualice a v0.60.4 primero y solo después a esta versión.",
"error_message": "Error durante la migración a la versión {{version}}: {{stack}}",
"wrong_db_version": "La versión de la DB {{version}} es más nueva que la versión de la DB actual {{targetVersion}}, lo que significa que fue creada por una versión más reciente e incompatible de Trilium. Actualice a la última versión de Trilium para resolver este problema."
},
"modals": {
"error_title": "Error"
},
"share_theme": {
"site-theme": "Tema de sitio",
"search_placeholder": "Búsqueda...",
"image_alt": "Imagen de artículo",
"last-updated": "Última actualización en {{-date}}",
"subpages": "Subpáginas:",
"on-this-page": "En esta página",
"expand": "Expandir"
},
"keyboard_action_names": {
"jump-to-note": "Saltar a...",
"command-palette": "Paleta de comandos",
"scroll-to-active-note": "Desplazarse a la nota activa",
"quick-search": "Búsqueda rápida",
"search-in-subtree": "Buscar en subárbol",
"expand-subtree": "Expandir subárbol",
"collapse-tree": "Colapsar árbol",
"collapse-subtree": "Colapsar subárbol",
"sort-child-notes": "Ordenar nodos hijos",
"create-note-after": "Crear nota tras",
"create-note-into": "Crear nota en",
"create-note-into-inbox": "Crear nota en bandeja de entrada",
"delete-notes": "Eliminar notas",
"move-note-up": "Subir nota",
"move-note-down": "Bajar nota",
"move-note-up-in-hierarchy": "Subir nota en la jerarquía",
"move-note-down-in-hierarchy": "Bajar nota en la jerarquía",
"edit-note-title": "Editar título de nota",
"edit-branch-prefix": "Editar prefijo de rama",
"clone-notes-to": "Clonar notas a",
"move-notes-to": "Mover notas a",
"copy-notes-to-clipboard": "Copiar notas al portapapeles",
"paste-notes-from-clipboard": "Pegar notas del portapapeles",
"add-note-above-to-selection": "Añadir nota superior a la selección",
"add-note-below-to-selection": "Añadir nota inferior a la selección",
"duplicate-subtree": "Duplicar subárbol",
"open-new-tab": "Abrir nueva pestaña",
"close-active-tab": "Cerrar pestaña activa",
"reopen-last-tab": "Reabrir última pestaña",
"activate-next-tab": "Activar siguiente pestaña",
"activate-previous-tab": "Activar pestaña anterior",
"open-new-window": "Abrir nueva ventana",
"show-options": "Mostrar opciones",
"show-revisions": "Mostrar revisiones",
"show-recent-changes": "Mostrar cambios recientes",
"show-sql-console": "Mostrar consola SQL",
"switch-to-first-tab": "Ir a la primera pestaña",
"switch-to-second-tab": "Ir a la segunda pestaña",
"switch-to-third-tab": "Ir a la tercera pestaña",
"switch-to-fourth-tab": "Ir a la cuarta pestaña",
"switch-to-fifth-tab": "Ir a la quinta pestaña",
"switch-to-sixth-tab": "Ir a la sexta pestaña",
"switch-to-seventh-tab": "Ir a la séptima pestaña",
"switch-to-eighth-tab": "Ir a la octava pestaña",
"switch-to-ninth-tab": "Ir a la novena pestaña",
"switch-to-last-tab": "Ir a la última pestaña",
"show-note-source": "Mostrar nota fuente",
"show-help": "Mostrar ayuda",
"add-new-label": "Añadir nueva etiqueta",
"add-new-relation": "Añadir nueva relación",
"print-active-note": "Imprimir nota activa",
"export-active-note-as-pdf": "Exportar nota activa como PDF",
"open-note-externally": "Abrir nota externamente",
"find-in-text": "Encontrar en texto",
"copy-without-formatting": "Copiar sin formato",
"reset-zoom-level": "Restablecer el nivel de zoom",
"open-developer-tools": "Abrir herramientas de desarrollo",
"insert-date-and-time-to-text": "Insertar fecha y hora al texto",
"edit-read-only-note": "Editar nota de solo lectura",
"toggle-system-tray-icon": "Mostrar/ocultar icono en la bandeja del sistema",
"toggle-zen-mode": "Activar/desactivar modo Zen",
"add-link-to-text": "Añadir enlace al texto",
"zoom-in": "Acercar",
"zoom-out": "Alejar",
"toggle-full-screen": "Activar/desactivar pantalla completa",
"toggle-left-pane": "Abrir/cerrar panel izquierdo"
},
"hidden_subtree_templates": {
"board_note_first": "Primera nota",
"board_note_second": "Segunda nota",
"board_note_third": "Tercera nota",
"board_status_progress": "En progreso",
"calendar": "Calendario",
"description": "Descripción",
"list-view": "Vista de lista",
"grid-view": "Vista de cuadrícula",
"status": "Estado",
"table": "Tabla"
}
"keyboard_actions": {
"back-in-note-history": "Navegar a la nota previa en el historial",
"forward-in-note-history": "Navegar a la nota siguiente en el historial",
"open-jump-to-note-dialog": "Abrir cuadro de diálogo \"Saltar a nota\"",
"scroll-to-active-note": "Desplazarse a la nota activa en el árbol de notas",
"quick-search": "Activar barra de búisqueda rápida",
"search-in-subtree": "Buscar notas en el subárbol de la nota activa",
"expand-subtree": "Expandir el subárbol de la nota actual",
"collapse-tree": "Colapsa el árbol de notas completo",
"collapse-subtree": "Colapsa el subárbol de la nota actual",
"sort-child-notes": "Ordenar subnotas",
"creating-and-moving-notes": "Creando y moviendo notas",
"create-note-after": "Crear nota después de la nota activa",
"create-note-into": "Crear nota como subnota de la nota activa",
"create-note-into-inbox": "Crear una nota en la bandeja de entrada (si está definida) o nota del día",
"delete-note": "Eliminar nota",
"move-note-up": "Mover nota hacia arriba",
"move-note-down": "Mover nota hacia abajo",
"move-note-up-in-hierarchy": "Mover nota hacia arriba en la jerarquía",
"move-note-down-in-hierarchy": "Mover nota hacia abajo en la jerarquía",
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
"cloneNotesTo": "Clonar notas seleccionadas",
"moveNotesTo": "Mover notas seleccionadas",
"note-clipboard": "Portapapeles de notas",
"copy-notes-to-clipboard": "Copiar las notas seleccionadas al portapapeles",
"paste-notes-from-clipboard": "Pegar las notas del portapapeles en una nota activa",
"cut-notes-to-clipboard": "Cortar las notas seleccionadas al portapapeles",
"select-all-notes-in-parent": "Seleccionar todas las notas del nivel de la nota actual",
"add-note-above-to-the-selection": "Agregar nota arriba de la selección",
"add-note-below-to-selection": "Agregar nota arriba de la selección",
"duplicate-subtree": "Duplicar subárbol",
"tabs-and-windows": "Pestañas y ventanas",
"open-new-tab": "Abre una nueva pestaña",
"close-active-tab": "Cierra la pestaña activa",
"reopen-last-tab": "Vuelve a abrir la última pestaña cerrada",
"activate-next-tab": "Activa la pestaña de la derecha",
"activate-previous-tab": "Activa la pestaña de la izquierda",
"open-new-window": "Abrir nueva ventana vacía",
"toggle-tray": "Muestra/Oculta la aplicación en la bandeja del sistema",
"first-tab": "Activa la primera pestaña de la lista",
"second-tab": "Activa la segunda pestaña de la lista",
"third-tab": "Activa la tercera pestaña de la lista",
"fourth-tab": "Activa la cuarta pestaña de la lista",
"fifth-tab": "Activa la quinta pestaña de la lista",
"sixth-tab": "Activa la sexta pestaña de la lista",
"seventh-tab": "Activa la séptima pestaña de la lista",
"eight-tab": "Activa la octava pestaña de la lista",
"ninth-tab": "Activa la novena pestaña de la lista",
"last-tab": "Activa la última pestaña de la lista",
"dialogs": "Diálogos",
"show-note-source": "Muestra el cuadro de diálogo Fuente de nota",
"show-options": "Muestra el cuadro de diálogo Opciones",
"show-revisions": "Muestra el cuadro de diálogo Revisiones de notas",
"show-recent-changes": "Muestra el cuadro de diálogo Cambios recientes",
"show-sql-console": "Muestra el cuadro de diálogo Consola SQL",
"show-backend-log": "Muestra el cuadro de diálogo Registro de backend",
"show-help": "Muestra ayuda/hoja de referencia integrada",
"show-cheatsheet": "Muestra un modal con operaciones de teclado comunes",
"text-note-operations": "Operaciones de notas de texto",
"add-link-to-text": "Abrir cuadro de diálogo para agregar un enlace al texto",
"follow-link-under-cursor": "Seguir el enlace dentro del cual se coloca el cursor",
"insert-date-and-time-to-text": "Insertar fecha y hora actuales en el texto",
"paste-markdown-into-text": "Pega Markdown del portapapeles en la nota de texto",
"cut-into-note": "Corta la selección de la nota actual y crea una subnota con el texto seleccionado",
"add-include-note-to-text": "Abre el cuadro de diálogo para incluir una nota",
"edit-readonly-note": "Editar una nota de sólo lectura",
"attributes-labels-and-relations": "Atributos (etiquetas y relaciones)",
"add-new-label": "Crear nueva etiqueta",
"create-new-relation": "Crear nueva relación",
"ribbon-tabs": "Pestañas de cinta",
"toggle-basic-properties": "Alternar propiedades básicas",
"toggle-file-properties": "Alternar propiedades de archivo",
"toggle-image-properties": "Alternar propiedades de imagen",
"toggle-owned-attributes": "Alternar atributos de propiedad",
"toggle-inherited-attributes": "Alternar atributos heredados",
"toggle-promoted-attributes": "Alternar atributos promocionados",
"toggle-link-map": "Alternar mapa de enlaces",
"toggle-note-info": "Alternar información de nota",
"toggle-note-paths": "Alternar rutas de notas",
"toggle-similar-notes": "Alternar notas similares",
"other": "Otro",
"toggle-right-pane": "Alternar la visualización del panel derecho, que incluye la tabla de contenidos y aspectos destacados",
"print-active-note": "Imprimir nota activa",
"open-note-externally": "Abrir nota como un archivo con la aplicación predeterminada",
"render-active-note": "Renderizar (volver a renderizar) nota activa",
"run-active-note": "Ejecutar nota de código JavaScript activa (frontend/backend)",
"toggle-note-hoisting": "Alterna la elevación de la nota activa",
"unhoist": "Bajar desde cualquier lugar",
"reload-frontend-app": "Recargar frontend de la aplicación",
"open-dev-tools": "Abrir herramientas de desarrollo",
"find-in-text": "Alternar panel de búsqueda",
"toggle-left-note-tree-panel": "Alternar panel izquierdo (árbol de notas)",
"toggle-full-screen": "Alternar pantalla completa",
"zoom-out": "Alejar",
"zoom-in": "Acercar",
"note-navigation": "Navegación de notas",
"reset-zoom-level": "Restablecer nivel de zoom",
"copy-without-formatting": "Copiar el texto seleccionado sin formatear",
"force-save-revision": "Forzar la creación/guardado de una nueva revisión de nota de la nota activa",
"toggle-book-properties": "Alternar propiedades del libro",
"toggle-classic-editor-toolbar": "Alternar la pestaña de formato por el editor con barra de herramientas fija",
"export-as-pdf": "Exporta la nota actual como un PDF",
"toggle-zen-mode": "Habilita/Deshabilita el modo Zen (IU mínima para edición sin distracciones)"
},
"login": {
"title": "Iniciar sesión",
"heading": "Iniciar sesión en Trilium",
"incorrect-totp": "El TOTP es incorrecto. Por favor, intente de nuevo.",
"incorrect-password": "La contraseña es incorrecta. Por favor inténtalo de nuevo.",
"password": "Contraseña",
"remember-me": "Recordarme",
"button": "Iniciar sesión",
"sign_in_with_sso": "Iniciar sesión con {{ ssoIssuerName }}"
},
"set_password": {
"title": "Establecer contraseña",
"heading": "Establecer contraseña",
"description": "Antes de poder comenzar a usar Trilium desde la web, primero debe establecer una contraseña. Luego utilizará esta contraseña para iniciar sesión.",
"password": "Contraseña",
"password-confirmation": "Confirmación de contraseña",
"button": "Establecer contraseña"
},
"javascript-required": "Trilium requiere que JavaScript esté habilitado.",
"setup": {
"heading": "Configuración de Trilium Notes",
"new-document": "Soy un usuario nuevo y quiero crear un nuevo documento de Trilium para mis notas",
"sync-from-desktop": "Ya tengo una instancia de escritorio y quiero configurar la sincronización con ella",
"sync-from-server": "Ya tengo una instancia de servidor y quiero configurar la sincronización con ella",
"next": "Siguiente",
"init-in-progress": "Inicialización del documento en curso",
"redirecting": "En breve será redirigido a la aplicación.",
"title": "Configuración"
},
"setup_sync-from-desktop": {
"heading": "Sincronizar desde el escritorio",
"description": "Esta configuración debe iniciarse desde la instancia de escritorio:",
"step1": "Abra su instancia de escritorio de Trilium Notes.",
"step2": "En el menú Trilium, dé clic en Opciones.",
"step3": "Dé clic en la categoría Sincronizar.",
"step4": "Cambie la dirección de la instancia del servidor a: {{- host}} y dé clic en Guardar.",
"step5": "Dé clic en el botón \"Probar sincronización\" para verificar que la conexión fue exitosa.",
"step6": "Una vez que haya completado estos pasos, dé clic en {{- link}}.",
"step6-here": "aquí"
},
"setup_sync-from-server": {
"heading": "Sincronización desde el servidor",
"instructions": "Por favor, ingrese la dirección y las credenciales del servidor Trilium a continuación. Esto descargará todo el documento de Trilium desde el servidor y configurará la sincronización. Dependiendo del tamaño del documento y de la velocidad de su conexión, esto puede tardar un poco.",
"server-host": "Dirección del servidor Trilium",
"server-host-placeholder": "https://<hostname>:<port>",
"proxy-server": "Servidor proxy (opcional)",
"proxy-server-placeholder": "https://<hostname>:<port>",
"note": "Nota:",
"proxy-instruction": "Si deja la configuración de proxy en blanco, se utilizará el proxy del sistema (aplica únicamente a la aplicación de escritorio)",
"password": "Contraseña",
"password-placeholder": "Contraseña",
"back": "Atrás",
"finish-setup": "Finalizar la configuración"
},
"setup_sync-in-progress": {
"heading": "Sincronización en progreso",
"successful": "La sincronización se ha configurado correctamente. La sincronización inicial tardará algún tiempo en finalizar. Una vez hecho esto, será redirigido a la página de inicio de sesión.",
"outstanding-items": "Elementos de sincronización destacados:",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "No encontrado",
"heading": "No encontrado"
},
"share_page": {
"parent": "padre:",
"clipped-from": "Esta nota fue recortada originalmente de {{- url}}",
"child-notes": "Subnotas:",
"no-content": "Esta nota no tiene contenido."
},
"weekdays": {
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo"
},
"weekdayNumber": "Semana {weekNumber}",
"months": {
"january": "Enero",
"february": "Febrero",
"march": "Marzo",
"april": "Abril",
"may": "Mayo",
"june": "Junio",
"july": "Julio",
"august": "Agosto",
"september": "Septiembre",
"october": "Octubre",
"november": "Noviembre",
"december": "Diciembre"
},
"quarterNumber": "Cuarto {quarterNumber}",
"special_notes": {
"search_prefix": "Buscar:"
},
"test_sync": {
"not-configured": "El servidor de sincronización no está configurado. Por favor configure primero la sincronización.",
"successful": "El protocolo de enlace del servidor de sincronización ha sido exitoso, la sincronización ha comenzado."
},
"hidden-subtree": {
"root-title": "Notas ocultas",
"search-history-title": "Buscar historial",
"note-map-title": "Mapa de nota",
"sql-console-history-title": "Historial de consola SQL",
"shared-notes-title": "Notas compartidas",
"bulk-action-title": "Acción en lote",
"backend-log-title": "Registro de Backend",
"user-hidden-title": "Usuario oculto",
"launch-bar-templates-title": "Plantillas de barra de lanzamiento",
"base-abstract-launcher-title": "Lanzador abstracto base",
"command-launcher-title": "Lanzador de comando",
"note-launcher-title": "Lanzador de nota",
"script-launcher-title": "Lanzador de script",
"built-in-widget-title": "Widget integrado",
"spacer-title": "Espaciador",
"custom-widget-title": "Widget personalizado",
"launch-bar-title": "Barra de lanzamiento",
"available-launchers-title": "Lanzadores disponibles",
"go-to-previous-note-title": "Ir a nota previa",
"go-to-next-note-title": "Ir a nota siguiente",
"new-note-title": "Nueva nota",
"search-notes-title": "Buscar notas",
"jump-to-note-title": "Saltar a nota",
"calendar-title": "Calendario",
"recent-changes-title": "Cambios recientes",
"bookmarks-title": "Marcadores",
"open-today-journal-note-title": "Abrir nota del diario de hoy",
"quick-search-title": "Búsqueda rápida",
"protected-session-title": "Sesión protegida",
"sync-status-title": "Sincronizar estado",
"settings-title": "Ajustes",
"llm-chat-title": "Chat con notas",
"options-title": "Opciones",
"appearance-title": "Apariencia",
"shortcuts-title": "Atajos",
"text-notes": "Notas de texto",
"code-notes-title": "Notas de código",
"images-title": "Imágenes",
"spellcheck-title": "Corrección ortográfica",
"password-title": "Contraseña",
"multi-factor-authentication-title": "MFA",
"etapi-title": "ETAPI",
"backup-title": "Respaldo",
"sync-title": "Sincronizar",
"ai-llm-title": "IA/LLM",
"other": "Otros",
"advanced-title": "Avanzado",
"visible-launchers-title": "Lanzadores visibles",
"user-guide": "Guía de Usuario",
"localization": "Idioma y Región",
"inbox-title": "Bandeja"
},
"notes": {
"new-note": "Nueva nota",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle}} {{duplicateNoteSuffix}}"
},
"backend_log": {
"log-does-not-exist": "El archivo de registro del backend '{{fileName}}' no existe (aún).",
"reading-log-failed": "Leer el archivo de registro del backend '{{fileName}}' falló."
},
"content_renderer": {
"note-cannot-be-displayed": "Este tipo de nota no puede ser mostrado."
},
"pdf": {
"export_filter": "Documento PDF (*.pdf)",
"unable-to-export-message": "La nota actual no pudo ser exportada como PDF.",
"unable-to-export-title": "No es posible exportar como PDF",
"unable-to-save-message": "No se pudo escribir en el archivo seleccionado. Intente de nuevo o seleccione otro destino."
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Cerrar Trilium",
"recents": "Notas recientes",
"bookmarks": "Marcadores",
"today": "Abrir nota del diario de hoy",
"new-note": "Nueva nota",
"show-windows": "Mostrar ventanas",
"open_new_window": "Abrir nueva ventana"
},
"migration": {
"old_version": "La migración directa desde tu versión actual no está soportada. Por favor actualice a v0.60.4 primero y solo después a esta versión.",
"error_message": "Error durante la migración a la versión {{version}}: {{stack}}",
"wrong_db_version": "La versión de la DB {{version}} es más nueva que la versión de la DB actual {{targetVersion}}, lo que significa que fue creada por una versión más reciente e incompatible de Trilium. Actualice a la última versión de Trilium para resolver este problema."
},
"modals": {
"error_title": "Error"
},
"share_theme": {
"site-theme": "Tema de sitio",
"search_placeholder": "Búsqueda...",
"image_alt": "Imagen de artículo",
"last-updated": "Última actualización en {{-date}}",
"subpages": "Subpáginas:",
"on-this-page": "En esta página",
"expand": "Expandir"
}
}

View File

@@ -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",

View File

@@ -1,431 +1,280 @@
{
"keyboard_actions": {
"activate-next-tab": "Activează tab-ul din dreapta",
"activate-previous-tab": "Activează tab-ul din stânga",
"add-include-note-to-text": "Deschide fereastra de includere notițe",
"add-link-to-text": "Deschide fereastra de adaugă legături în text",
"add-new-label": "Crează o nouă etichetă",
"add-note-above-to-the-selection": "Adaugă notița deasupra selecției",
"add-note-below-to-selection": "Adaugă notița deasupra selecției",
"attributes-labels-and-relations": "Atribute (etichete și relații)",
"close-active-tab": "Închide tab-ul activ",
"collapse-subtree": "Minimizează ierarhia notiței curente",
"collapse-tree": "Minimizează întreaga ierarhie de notițe",
"copy-notes-to-clipboard": "Copiază notițele selectate în clipboard",
"copy-without-formatting": "Copiază textul selectat fără formatare",
"create-new-relation": "Crează o nouă relație",
"create-note-into-inbox": "Crează o notiță și o deschide în inbox (dacă este definit) sau notița zilnică",
"cut-notes-to-clipboard": "Decupează notițele selectate în clipboard",
"creating-and-moving-notes": "Crearea și mutarea notițelor",
"cut-into-note": "Decupează selecția din notița curentă și crează o subnotiță cu textul selectat",
"delete-note": "Șterge notița",
"dialogs": "Ferestre",
"duplicate-subtree": "Dublifică subnotițele",
"edit-branch-prefix": "Afișează ecranul „Editează prefixul ramurii”",
"edit-note-title": "Sare de la arborele notițelor la detaliile notiței și editează titlul",
"edit-readonly-note": "Editează o notiță care este în modul doar în citire",
"eight-tab": "Activează cel de-al optelea tab din listă",
"expand-subtree": "Expandează ierarhia notiței curente",
"fifth-tab": "Activează cel de-al cincelea tab din listă",
"first-tab": "Activează cel primul tab din listă",
"follow-link-under-cursor": "Urmărește legătura de sub poziția cursorului",
"force-save-revision": "Forțează crearea/salvarea unei noi revizii ale notiției curente",
"fourth-tab": "Activează cel de-al patrulea tab din listă",
"insert-date-and-time-to-text": "Inserează data curentă și timpul în text",
"last-tab": "Activează ultimul tab din listă",
"move-note-down": "Mută notița mai jos",
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
"move-note-up": "Mută notița mai sus",
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
"ninth-tab": "Activează cel de-al nouălea tab din listă",
"note-clipboard": "Clipboard-ul notițelor",
"note-navigation": "Navigarea printre notițe",
"open-dev-tools": "Deschide uneltele de dezvoltator",
"open-jump-to-note-dialog": "Deschide ecranul „Sari la notiță”",
"open-new-tab": "Deschide un tab nou",
"open-new-window": "Deschide o nouă fereastră goală",
"open-note-externally": "Deschide notița ca un fișier utilizând aplicația implicită",
"other": "Altele",
"paste-markdown-into-text": "Lipește text Markdown din clipboard în notița de tip text",
"paste-notes-from-clipboard": "Lipește notițele din clipboard în notița activă",
"print-active-note": "Imprimă notița activă",
"reload-frontend-app": "Reîncarcă interfața grafică",
"render-active-note": "Reîmprospătează notița activă",
"reopen-last-tab": "Deschide din nou ultimul tab închis",
"reset-zoom-level": "Resetează nivelul de magnificare",
"ribbon-tabs": "Tab-urile din panglică",
"run-active-note": "Rulează notița curentă de tip JavaScript (frontend sau backend)",
"search-in-subtree": "Caută notițe în ierarhia notiței active",
"second-tab": "Activează cel de-al doilea tab din listă",
"select-all-notes-in-parent": "Selectează toate notițele din nivelul notiței curente",
"seventh-tab": "Activează cel de-al șaptelea tab din listă",
"show-backend-log": "Afișează fereastra „Log-uri din backend”",
"show-help": "Afișează informații utile",
"show-note-source": "Afișează fereastra „Sursa notiței”",
"show-options": "Afișează fereastra de opțiuni",
"show-recent-changes": "Afișează fereastra „Schimbări recente”",
"show-revisions": "Afișează fereastra „Revizii ale notiței”",
"show-sql-console": "Afișează ecranul „Consolă SQL”",
"sixth-tab": "Activează cel de-al șaselea tab din listă",
"sort-child-notes": "Ordonează subnotițele",
"tabs-and-windows": "Tab-uri și ferestre",
"text-note-operations": "Operații asupra notițelor text",
"third-tab": "Activează cel de-al treilea tab din listă",
"toggle-basic-properties": "Comută tab-ul „Proprietăți de bază”",
"toggle-book-properties": "Comută tab-ul „Proprietăți ale cărții”",
"toggle-file-properties": "Comută tab-ul „Proprietăți fișier”",
"toggle-full-screen": "Comută modul ecran complet",
"toggle-image-properties": "Comută tab-ul „Proprietăți imagini”",
"toggle-inherited-attributes": "Comută tab-ul „Atribute moștenite”",
"toggle-left-note-tree-panel": "Comută panoul din stânga (arborele notițelor)",
"toggle-link-map": "Comută harta legăturilor",
"toggle-note-hoisting": "Comută focalizarea pe notița curentă",
"toggle-note-info": "Comută tab-ul „Informații despre notiță”",
"toggle-note-paths": "Comută tab-ul „Căile notiței”",
"toggle-owned-attributes": "Comută tab-ul „Atribute proprii”",
"toggle-promoted-attributes": "Comută tab-ul „Atribute promovate”",
"toggle-right-pane": "Comută afișarea panoului din dreapta, ce include tabela de conținut și evidențieri",
"toggle-similar-notes": "Comută tab-ul „Notițe similare”",
"toggle-tray": "Afișează/ascunde aplicația din tray-ul de sistem",
"unhoist": "Defocalizează complet",
"zoom-in": "Mărește zoom-ul",
"zoom-out": "Micșorează zoom-ul",
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă",
"export-as-pdf": "Exportă notița curentă ca PDF",
"show-cheatsheet": "Afișează o fereastră cu scurtături de la tastatură comune",
"toggle-zen-mode": "Activează/dezactivează modul zen (o interfață minimală, fără distrageri)",
"back-in-note-history": "Mergi la notița anterioară din istoric",
"forward-in-note-history": "Mergi la următoarea notiță din istoric",
"scroll-to-active-note": "Derulează la notița activă în lista de notițe",
"quick-search": "Mergi la bara de căutare rapidă",
"create-note-after": "Crează o notiță după cea activă",
"create-note-into": "Crează notiță ca subnotiță a notiței active",
"clone-notes-to": "Clonează notițele selectate",
"move-notes-to": "Mută notițele selectate",
"find-in-text": "Afișează/ascunde panoul de căutare",
"open-command-palette": "Deschide paleta de comenzi"
},
"login": {
"button": "Autentifică",
"heading": "Autentificare în Trilium",
"incorrect-password": "Parola nu este corectă. Încercați din nou.",
"password": "Parolă",
"remember-me": "Ține-mă minte",
"title": "Autentificare",
"incorrect-totp": "TOTP-ul este incorect. Încercați din nou.",
"sign_in_with_sso": "Autentificare cu {{ ssoIssuerName }}"
},
"set_password": {
"title": "Setare parolă",
"heading": "Setare parolă",
"button": "Setează parola",
"description": "Înainte de a putea utiliza Trilium din navigator, trebuie mai întâi setată o parolă. Ulterior această parolă va fi folosită la autentificare.",
"password": "Parolă",
"password-confirmation": "Confirmarea parolei"
},
"javascript-required": "Trilium necesită JavaScript să fie activat pentru a putea funcționa.",
"setup": {
"heading": "Instalarea Trilium Notes",
"init-in-progress": "Se inițializează documentul",
"new-document": "Sunt un utilizator nou și doresc să creez un document Trilium pentru notițele mele",
"next": "Mai departe",
"redirecting": "În scurt timp veți fi redirecționat la aplicație.",
"sync-from-desktop": "Am deja o instanță de desktop și aș dori o sincronizare cu aceasta",
"sync-from-server": "Am deja o instanță de server și doresc o sincronizare cu aceasta",
"title": "Inițializare"
},
"setup_sync-from-desktop": {
"description": "Acești pași trebuie urmați de pe aplicația de desktop:",
"heading": "Sincronizare cu aplicația desktop",
"step1": "Deschideți aplicația Trilium Notes pentru desktop.",
"step2": "Din meniul Trilium, dați clic pe Opțiuni.",
"step3": "Clic pe categoria „Sincronizare”.",
"step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
"step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
"step6": "După ce ați completat pașii, dați click {{- link}}.",
"step6-here": "aici"
},
"setup_sync-from-server": {
"back": "Înapoi",
"finish-setup": "Finalizează inițializarea",
"heading": "Sincronizare cu server-ul",
"instructions": "Introduceți adresa server-ului Trilium și credențialele în secțiunea de jos. Astfel se va descărca întregul document Trilium de pe server și se va configura sincronizarea cu acesta. În funcție de dimensiunea documentului și viteza rețelei, acest proces poate dura.",
"note": "De remarcat:",
"password": "Parolă",
"proxy-instruction": "Dacă lăsați câmpul de proxy nesetat, proxy-ul de sistem va fi folosit (valabil doar pentru aplicația de desktop)",
"proxy-server": "Server-ul proxy (opțional)",
"proxy-server-placeholder": "https://<sistem>:<port>",
"server-host": "Adresa server-ului Trilium",
"server-host-placeholder": "https://<sistem>:<port>",
"password-placeholder": "Parolă"
},
"setup_sync-in-progress": {
"heading": "Sincronizare în curs",
"outstanding-items": "Elemente de sincronizat:",
"outstanding-items-default": "-",
"successful": "Sincronizarea a fost configurată cu succes. Poate dura ceva timp pentru a finaliza sincronizarea inițială. După efectuarea ei se va redirecționa către pagina de autentificare."
},
"share_404": {
"heading": "Pagină negăsită",
"title": "Pagină negăsită"
},
"share_page": {
"child-notes": "Subnotițe:",
"clipped-from": "Această notiță a fost decupată inițial de pe {{- url}}",
"no-content": "Această notiță nu are conținut.",
"parent": "părinte:"
},
"weekdays": {
"monday": "Luni",
"tuesday": "Mai",
"wednesday": "Miercuri",
"thursday": "Joi",
"friday": "Vineri",
"saturday": "Sâmbătă",
"sunday": "Duminică"
},
"months": {
"january": "Ianuarie",
"february": "Februarie",
"march": "Martie",
"april": "Aprilie",
"may": "Mai",
"june": "Iunie",
"july": "Iulie",
"august": "August",
"september": "Septembrie",
"october": "Octombrie",
"november": "Noiembrie",
"december": "Decembrie"
},
"special_notes": {
"search_prefix": "Căutare:"
},
"test_sync": {
"not-configured": "Calea către serverul de sincronizare nu este configurată. Configurați sincronizarea înainte.",
"successful": "Comunicarea cu serverul de sincronizare a avut loc cu succes, s-a început sincronizarea."
},
"hidden-subtree": {
"advanced-title": "Setări avansate",
"appearance-title": "Aspect",
"available-launchers-title": "Lansatoare disponibile",
"backup-title": "Copii de siguranță",
"base-abstract-launcher-title": "Lansator abstract de bază",
"bookmarks-title": "Semne de carte",
"built-in-widget-title": "Widget predefinit",
"bulk-action-title": "Acțiuni în masă",
"calendar-title": "Calendar",
"code-notes-title": "Notițe de cod",
"command-launcher-title": "Lansator de comenzi",
"custom-widget-title": "Widget personalizat",
"etapi-title": "ETAPI",
"go-to-previous-note-title": "Mergi la notița anterioară",
"images-title": "Imagini",
"launch-bar-title": "Bară de lansare",
"new-note-title": "Notiță nouă",
"note-launcher-title": "Lansator de notițe",
"note-map-title": "Harta notițelor",
"open-today-journal-note-title": "Deschide notița de astăzi",
"options-title": "Opțiuni",
"other": "Diverse",
"password-title": "Parolă",
"protected-session-title": "Sesiune protejată",
"recent-changes-title": "Schimbări recente",
"script-launcher-title": "Lansator de script-uri",
"search-history-title": "Istoric de căutare",
"settings-title": "Setări",
"shared-notes-title": "Notițe partajate",
"quick-search-title": "Căutare rapidă",
"root-title": "Notițe ascunse",
"search-notes-title": "Căutare notițe",
"shortcuts-title": "Scurtături",
"spellcheck-title": "Corectare gramaticală",
"sync-status-title": "Starea sincronizării",
"sync-title": "Sincronizare",
"text-notes": "Notițe text",
"user-hidden-title": "Definite de utilizator",
"backend-log-title": "Log backend",
"spacer-title": "Separator",
"sql-console-history-title": "Istoricul consolei SQL",
"go-to-next-note-title": "Mergi la notița următoare",
"launch-bar-templates-title": "Șabloane bară de lansare",
"visible-launchers-title": "Lansatoare vizibile",
"user-guide": "Ghidul de utilizare",
"jump-to-note-title": "Sari la...",
"llm-chat-title": "Întreabă Notes",
"multi-factor-authentication-title": "Autentificare multi-factor",
"ai-llm-title": "AI/LLM",
"localization": "Limbă și regiune",
"inbox-title": "Inbox"
},
"notes": {
"new-note": "Notiță nouă",
"duplicate-note-suffix": "(dupl.)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).",
"reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”."
},
"geo-map": {
"create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța."
},
"content_renderer": {
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "Notița curentă nu a putut fi exportată ca PDF.",
"unable-to-export-title": "Nu s-a putut exporta ca PDF",
"unable-to-save-message": "Nu s-a putut scrie fișierul selectat. Încercați din nou sau selectați altă destinație."
},
"tray": {
"bookmarks": "Semne de carte",
"close": "Închide Trilium",
"new-note": "Notiță nouă",
"recents": "Notițe recente",
"today": "Mergi la notița de astăzi",
"tooltip": "Trilium Notes",
"show-windows": "Afișează ferestrele",
"open_new_window": "Deschide fereastră nouă"
},
"migration": {
"error_message": "Eroare la migrarea către versiunea {{version}}: {{stack}}",
"old_version": "Nu se poate migra la ultima versiune direct de la versiunea dvs. Actualizați mai întâi la versiunea v0.60.4 și ulterior la această versiune.",
"wrong_db_version": "Versiunea actuală a bazei de date ({{version}}) este mai nouă decât versiunea de bază de date suportată de aplicație ({{targetVersion}}), ceea ce înseamnă că a fost creată de către o versiune mai nouă de Trilium. Actualizați aplicația la ultima versiune pentru a putea continua."
},
"modals": {
"error_title": "Eroare"
},
"keyboard_action_names": {
"quick-search": "Căutare rapidă",
"back-in-note-history": "Înapoi în istoricul notițelor",
"forward-in-note-history": "Înainte în istoricul notițelor",
"jump-to-note": "Mergi la...",
"scroll-to-active-note": "Derulează la notița activă",
"search-in-subtree": "Caută în subnotițe",
"expand-subtree": "Expandează subnotițele",
"collapse-tree": "Minimizează arborele de notițe",
"collapse-subtree": "Ascunde subnotițele",
"sort-child-notes": "Ordonează subnotițele",
"create-note-after": "Crează notiță după",
"create-note-into": "Crează subnotiță în",
"create-note-into-inbox": "Crează notiță în inbox",
"delete-notes": "Șterge notițe",
"move-note-up": "Mută notița deasupra",
"move-note-down": "Mută notița dedesubt",
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
"edit-note-title": "Editează titlul notiței",
"edit-branch-prefix": "Editează prefixul ramurii",
"clone-notes-to": "Clonează notițele în",
"move-notes-to": "Mută notițele în",
"copy-notes-to-clipboard": "Copiază notițele în clipboard",
"paste-notes-from-clipboard": "Lipește notițele din clipboard",
"cut-notes-to-clipboard": "Decupează notițele în clipboard",
"select-all-notes-in-parent": "Selectează toate notițele din părinte",
"add-note-above-to-selection": "Adaugă notița de deasupra la selecție",
"add-note-below-to-selection": "Adaugă notița de dedesubt la selecție",
"duplicate-subtree": "Dublifică ierarhia",
"open-new-tab": "Deschide în tab nou",
"close-active-tab": "Închide tab-ul activ",
"reopen-last-tab": "Redeschide ultimul tab",
"activate-next-tab": "Activează tab-ul următorul",
"activate-previous-tab": "Activează tab-ul anterior",
"open-new-window": "Deschide în fereastră nouă",
"toggle-system-tray-icon": "Afișează/ascunde iconița din bara de sistem",
"toggle-zen-mode": "Activează/dezactivează modul zen",
"switch-to-first-tab": "Mergi la primul tab",
"switch-to-second-tab": "Mergi la al doilea tab",
"switch-to-third-tab": "Mergi la al treilea tab",
"switch-to-fourth-tab": "Mergi la al patrulea tab",
"switch-to-fifth-tab": "Mergi la al cincelea tab",
"switch-to-sixth-tab": "Mergi la al șaselea tab",
"switch-to-seventh-tab": "Mergi la al șaptelea tab",
"switch-to-eighth-tab": "Mergi la al optelea tab",
"switch-to-ninth-tab": "Mergi la al nouălea tab",
"switch-to-last-tab": "Mergi la ultimul tab",
"show-note-source": "Afișează sursa notiței",
"show-options": "Afișează opțiunile",
"show-revisions": "Afișează reviziile",
"show-recent-changes": "Afișează modificările recente",
"show-sql-console": "Afișează consola SQL",
"show-backend-log": "Afișează log-urile din backend",
"show-help": "Afișează ghidul",
"show-cheatsheet": "Afișează ghidul rapid",
"add-link-to-text": "Inserează o legătură în text",
"follow-link-under-cursor": "Urmează legătura de la poziția curentă",
"insert-date-and-time-to-text": "Inserează data și timpul în text",
"paste-markdown-into-text": "Lipește Markdown în text",
"cut-into-note": "Decupează în subnotiță",
"add-include-note-to-text": "Adaugă o includere de notiță în text",
"edit-read-only-note": "Editează notiță ce este în modul citire",
"add-new-label": "Adaugă o nouă etichetă",
"add-new-relation": "Adaugă o nouă relație",
"toggle-ribbon-tab-classic-editor": "Comută la tab-ul de panglică pentru formatare text",
"toggle-ribbon-tab-basic-properties": "Comută la tab-ul de panglică pentru proprietăți de bază",
"toggle-ribbon-tab-book-properties": "Comută la tab-ul de panglică pentru proprietăți colecție",
"toggle-ribbon-tab-file-properties": "Comută la tab-ul de panglică pentru proprietăți fișier",
"toggle-ribbon-tab-image-properties": "Comută la tab-ul de panglică pentru proprietăți imagini",
"toggle-ribbon-tab-owned-attributes": "Comută la tab-ul de panglică pentru atribute proprii",
"toggle-ribbon-tab-inherited-attributes": "Comută la tab-ul de panglică pentru atribute moștenite",
"toggle-ribbon-tab-promoted-attributes": "Comută la tab-ul de panglică pentru atribute promovate",
"toggle-ribbon-tab-note-map": "Comută la tab-ul de panglică pentru harta notiței",
"toggle-ribbon-tab-note-info": "Comută la tab-ul de panglică pentru informații despre notiță",
"toggle-ribbon-tab-note-paths": "Comută la tab-ul de panglică pentru căile notiței",
"toggle-ribbon-tab-similar-notes": "Comută la tab-ul de panglică pentru notițe similare",
"toggle-right-pane": "Comută panoul din dreapta",
"print-active-note": "Imprimă notița activă",
"export-active-note-as-pdf": "Exportă notița activă ca PDF",
"open-note-externally": "Deschide notița într-o aplicație externă",
"render-active-note": "Randează notița activă",
"run-active-note": "Rulează notița activă",
"toggle-note-hoisting": "Comută focalizarea notiței",
"unhoist-note": "Defocalizează notița",
"reload-frontend-app": "Reîmprospătează aplicația",
"open-developer-tools": "Deschide unelete de dezvoltare",
"find-in-text": "Caută în text",
"toggle-left-pane": "Comută panoul din stânga",
"toggle-full-screen": "Comută mod pe tot ecranul",
"zoom-out": "Micșorare",
"zoom-in": "Mărire",
"reset-zoom-level": "Resetează nivelul de zoom",
"copy-without-formatting": "Copiază fără formatare",
"force-save-revision": "Forțează salvarea unei revizii",
"command-palette": "Paleta de comenzi"
},
"weekdayNumber": "Săptămâna {weekNumber}",
"quarterNumber": "Trimestrul {quarterNumber}",
"share_theme": {
"site-theme": "Tema site-ului",
"search_placeholder": "Caută...",
"image_alt": "Imaginea articolului",
"last-updated": "Ultima actualizare: {{- date}}",
"subpages": "Subpagini:",
"on-this-page": "Pe această pagină",
"expand": "Expandează"
},
"hidden_subtree_templates": {
"text-snippet": "Fragment de text",
"description": "Descriere",
"list-view": "Mod listă",
"grid-view": "Mod grilă",
"calendar": "Calendar",
"table": "Tabel",
"geo-map": "Hartă geografică",
"start-date": "Dată de început",
"end-date": "Dată de sfârșit",
"start-time": "Timp de început",
"end-time": "Timp de sfârșit",
"geolocation": "Geolocație",
"built-in-templates": "Șabloane predefinite",
"board": "Tablă Kanban",
"status": "Stare",
"board_note_first": "Prima notiță",
"board_note_second": "A doua notiță",
"board_note_third": "A treia notiță",
"board_status_todo": "De făcut",
"board_status_progress": "În lucru",
"board_status_done": "Finalizat"
}
"keyboard_actions": {
"activate-next-tab": "Activează tab-ul din dreapta",
"activate-previous-tab": "Activează tab-ul din stânga",
"add-include-note-to-text": "Deschide fereastra de includere notițe",
"add-link-to-text": "Deschide fereastra de adaugă legături în text",
"add-new-label": "Crează o nouă etichetă",
"add-note-above-to-the-selection": "Adaugă notița deasupra selecției",
"add-note-below-to-selection": "Adaugă notița deasupra selecției",
"attributes-labels-and-relations": "Atribute (etichete și relații)",
"close-active-tab": "Închide tab-ul activ",
"collapse-subtree": "Minimizează ierarhia notiței curente",
"collapse-tree": "Minimizează întreaga ierarhie de notițe",
"copy-notes-to-clipboard": "Copiază notițele selectate în clipboard",
"copy-without-formatting": "Copiază textul selectat fără formatare",
"create-new-relation": "Crează o nouă relație",
"create-note-into-inbox": "Crează o notiță și o deschide în inbox (dacă este definit) sau notița zilnică",
"cut-notes-to-clipboard": "Decupează notițele selectate în clipboard",
"creating-and-moving-notes": "Crearea și mutarea notițelor",
"cut-into-note": "Decupează selecția din notița curentă și crează o subnotiță cu textul selectat",
"delete-note": "Șterge notița",
"dialogs": "Ferestre",
"duplicate-subtree": "Dublifică subnotițele",
"edit-branch-prefix": "Afișează ecranul „Editează prefixul ramurii”",
"edit-note-title": "Sare de la arborele notițelor la detaliile notiței și editează titlul",
"edit-readonly-note": "Editează o notiță care este în modul doar în citire",
"eight-tab": "Activează cel de-al optelea tab din listă",
"expand-subtree": "Expandează ierarhia notiței curente",
"fifth-tab": "Activează cel de-al cincelea tab din listă",
"first-tab": "Activează cel primul tab din listă",
"follow-link-under-cursor": "Urmărește legătura de sub poziția cursorului",
"force-save-revision": "Forțează crearea/salvarea unei noi revizii ale notiției curente",
"fourth-tab": "Activează cel de-al patrulea tab din listă",
"insert-date-and-time-to-text": "Inserează data curentă și timpul în text",
"last-tab": "Activează ultimul tab din listă",
"move-note-down": "Mută notița mai jos",
"move-note-down-in-hierarchy": "Mută notița mai jos în ierarhie",
"move-note-up": "Mută notița mai sus",
"move-note-up-in-hierarchy": "Mută notița mai sus în ierarhie",
"ninth-tab": "Activează cel de-al nouălea tab din listă",
"note-clipboard": "Clipboard-ul notițelor",
"note-navigation": "Navigarea printre notițe",
"open-dev-tools": "Deschide uneltele de dezvoltator",
"open-jump-to-note-dialog": "Deschide ecranul „Sari la notiță”",
"open-new-tab": "Deschide un tab nou",
"open-new-window": "Deschide o nouă fereastră goală",
"open-note-externally": "Deschide notița ca un fișier utilizând aplicația implicită",
"other": "Altele",
"paste-markdown-into-text": "Lipește text Markdown din clipboard în notița de tip text",
"paste-notes-from-clipboard": "Lipește notițele din clipboard în notița activă",
"print-active-note": "Imprimă notița activă",
"reload-frontend-app": "Reîncarcă interfața grafică",
"render-active-note": "Reîmprospătează notița activă",
"reopen-last-tab": "Deschide din nou ultimul tab închis",
"reset-zoom-level": "Resetează nivelul de magnificare",
"ribbon-tabs": "Tab-urile din panglică",
"run-active-note": "Rulează notița curentă de tip JavaScript (frontend sau backend)",
"search-in-subtree": "Caută notițe în ierarhia notiței active",
"second-tab": "Activează cel de-al doilea tab din listă",
"select-all-notes-in-parent": "Selectează toate notițele din nivelul notiței curente",
"seventh-tab": "Activează cel de-al șaptelea tab din listă",
"show-backend-log": "Afișează fereastra „Log-uri din backend”",
"show-help": "Afișează informații utile",
"show-note-source": "Afișează fereastra „Sursa notiței”",
"show-options": "Afișează fereastra de opțiuni",
"show-recent-changes": "Afișează fereastra „Schimbări recente”",
"show-revisions": "Afișează fereastra „Revizii ale notiței”",
"show-sql-console": "Afișează ecranul „Consolă SQL”",
"sixth-tab": "Activează cel de-al șaselea tab din listă",
"sort-child-notes": "Ordonează subnotițele",
"tabs-and-windows": "Tab-uri și ferestre",
"text-note-operations": "Operații asupra notițelor text",
"third-tab": "Activează cel de-al treilea tab din listă",
"toggle-basic-properties": "Comută tab-ul „Proprietăți de bază”",
"toggle-book-properties": "Comută tab-ul „Proprietăți ale cărții”",
"toggle-file-properties": "Comută tab-ul „Proprietăți fișier”",
"toggle-full-screen": "Comută modul ecran complet",
"toggle-image-properties": "Comută tab-ul „Proprietăți imagini”",
"toggle-inherited-attributes": "Comută tab-ul „Atribute moștenite”",
"toggle-left-note-tree-panel": "Comută panoul din stânga (arborele notițelor)",
"toggle-link-map": "Comută harta legăturilor",
"toggle-note-hoisting": "Comută focalizarea pe notița curentă",
"toggle-note-info": "Comută tab-ul „Informații despre notiță”",
"toggle-note-paths": "Comută tab-ul „Căile notiței”",
"toggle-owned-attributes": "Comută tab-ul „Atribute proprii”",
"toggle-promoted-attributes": "Comută tab-ul „Atribute promovate”",
"toggle-right-pane": "Comută afișarea panoului din dreapta, ce include tabela de conținut și evidențieri",
"toggle-similar-notes": "Comută tab-ul „Notițe similare”",
"toggle-tray": "Afișează/ascunde aplicația din tray-ul de sistem",
"unhoist": "Defocalizează complet",
"zoom-in": "Mărește zoom-ul",
"zoom-out": "Micșorează zoom-ul",
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă",
"export-as-pdf": "Exportă notița curentă ca PDF",
"show-cheatsheet": "Afișează o fereastră cu scurtături de la tastatură comune",
"toggle-zen-mode": "Activează/dezactivează modul zen (o interfață minimală, fără distrageri)"
},
"login": {
"button": "Autentifică",
"heading": "Autentificare în Trilium",
"incorrect-password": "Parola nu este corectă. Încercați din nou.",
"password": "Parolă",
"remember-me": "Ține-mă minte",
"title": "Autentificare"
},
"set_password": {
"title": "Setare parolă",
"heading": "Setare parolă",
"button": "Setează parola",
"description": "Înainte de a putea utiliza Trilium din navigator, trebuie mai întâi setată o parolă. Ulterior această parolă va fi folosită la autentificare.",
"password": "Parolă",
"password-confirmation": "Confirmarea parolei"
},
"javascript-required": "Trilium necesită JavaScript să fie activat pentru a putea funcționa.",
"setup": {
"heading": "Instalarea Trilium Notes",
"init-in-progress": "Se inițializează documentul",
"new-document": "Sunt un utilizator nou și doresc să creez un document Trilium pentru notițele mele",
"next": "Mai departe",
"redirecting": "În scurt timp veți fi redirecționat la aplicație.",
"sync-from-desktop": "Am deja o instanță de desktop și aș dori o sincronizare cu aceasta",
"sync-from-server": "Am deja o instanță de server și doresc o sincronizare cu aceasta",
"title": "Inițializare"
},
"setup_sync-from-desktop": {
"description": "Acești pași trebuie urmați de pe aplicația de desktop:",
"heading": "Sincronizare cu aplicația desktop",
"step1": "Deschideți aplicația Trilium Notes pentru desktop.",
"step2": "Din meniul Trilium, dați clic pe Opțiuni.",
"step3": "Clic pe categoria „Sincronizare”.",
"step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
"step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
"step6": "După ce ați completat pașii, dați click {{- link}}.",
"step6-here": "aici"
},
"setup_sync-from-server": {
"back": "Înapoi",
"finish-setup": "Finalizează inițializarea",
"heading": "Sincronizare cu server-ul",
"instructions": "Introduceți adresa server-ului Trilium și credențialele în secțiunea de jos. Astfel se va descărca întregul document Trilium de pe server și se va configura sincronizarea cu acesta. În funcție de dimensiunea documentului și viteza rețelei, acest proces poate dura.",
"note": "De remarcat:",
"password": "Parolă",
"proxy-instruction": "Dacă lăsați câmpul de proxy nesetat, proxy-ul de sistem va fi folosit (valabil doar pentru aplicația de desktop)",
"proxy-server": "Server-ul proxy (opțional)",
"proxy-server-placeholder": "https://<sistem>:<port>",
"server-host": "Adresa server-ului Trilium",
"server-host-placeholder": "https://<sistem>:<port>",
"password-placeholder": "Parolă"
},
"setup_sync-in-progress": {
"heading": "Sincronizare în curs",
"outstanding-items": "Elemente de sincronizat:",
"outstanding-items-default": "-",
"successful": "Sincronizarea a fost configurată cu succes. Poate dura ceva timp pentru a finaliza sincronizarea inițială. După efectuarea ei se va redirecționa către pagina de autentificare."
},
"share_404": {
"heading": "Pagină negăsită",
"title": "Pagină negăsită"
},
"share_page": {
"child-notes": "Subnotițe:",
"clipped-from": "Această notiță a fost decupată inițial de pe {{- url}}",
"no-content": "Această notiță nu are conținut.",
"parent": "părinte:"
},
"weekdays": {
"monday": "Luni",
"tuesday": "Marți",
"wednesday": "Miercuri",
"thursday": "Joi",
"friday": "Vineri",
"saturday": "Sâmbătă",
"sunday": "Duminică"
},
"months": {
"january": "Ianuarie",
"february": "Februarie",
"march": "Martie",
"april": "Aprilie",
"may": "Mai",
"june": "Iunie",
"july": "Iulie",
"august": "August",
"september": "Septembrie",
"october": "Octombrie",
"november": "Noiembrie",
"december": "Decembrie"
},
"special_notes": {
"search_prefix": "Căutare:"
},
"test_sync": {
"not-configured": "Calea către serverul de sincronizare nu este configurată. Configurați sincronizarea înainte.",
"successful": "Comunicarea cu serverul de sincronizare a avut loc cu succes, s-a început sincronizarea."
},
"hidden-subtree": {
"advanced-title": "Setări avansate",
"appearance-title": "Aspect",
"available-launchers-title": "Lansatoare disponibile",
"backup-title": "Copii de siguranță",
"base-abstract-launcher-title": "Lansator abstract de bază",
"bookmarks-title": "Semne de carte",
"built-in-widget-title": "Widget predefinit",
"bulk-action-title": "Acțiuni în masă",
"calendar-title": "Calendar",
"code-notes-title": "Notițe de cod",
"command-launcher-title": "Lansator de comenzi",
"custom-widget-title": "Widget personalizat",
"etapi-title": "ETAPI",
"go-to-previous-note-title": "Mergi la notița anterioară",
"images-title": "Imagini",
"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",
"note-map-title": "Harta notițelor",
"open-today-journal-note-title": "Deschide notița de astăzi",
"options-title": "Opțiuni",
"other": "Diverse",
"password-title": "Parolă",
"protected-session-title": "Sesiune protejată",
"recent-changes-title": "Schimbări recente",
"script-launcher-title": "Lansator de script-uri",
"search-history-title": "Istoric de căutare",
"settings-title": "Setări",
"shared-notes-title": "Notițe partajate",
"quick-search-title": "Căutare rapidă",
"root-title": "Notițe ascunse",
"search-notes-title": "Căutare notițe",
"shortcuts-title": "Scurtături",
"spellcheck-title": "Corectare gramaticală",
"sync-status-title": "Starea sincronizării",
"sync-title": "Sincronizare",
"text-notes": "Notițe text",
"user-hidden-title": "Definite de utilizator",
"backend-log-title": "Log backend",
"spacer-title": "Separator",
"sql-console-history-title": "Istoricul consolei SQL",
"go-to-next-note-title": "Mergi la notița următoare",
"launch-bar-templates-title": "Șabloane bară de lansare",
"visible-launchers-title": "Lansatoare vizibile",
"user-guide": "Ghidul de utilizare"
},
"notes": {
"new-note": "Notiță nouă"
},
"backend_log": {
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).",
"reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”."
},
"geo-map": {
"create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța."
},
"content_renderer": {
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "Notița curentă nu a putut fi exportată ca PDF.",
"unable-to-export-title": "Nu s-a putut exporta ca PDF",
"unable-to-save-message": "Nu s-a putut scrie fișierul selectat. Încercați din nou sau selectați altă destinație."
},
"tray": {
"bookmarks": "Semne de carte",
"close": "Închide Trilium",
"new-note": "Notiță nouă",
"recents": "Notițe recente",
"today": "Mergi la notița de astăzi",
"tooltip": "Trilium Notes",
"show-windows": "Afișează ferestrele"
},
"migration": {
"error_message": "Eroare la migrarea către versiunea {{version}}: {{stack}}",
"old_version": "Nu se poate migra la ultima versiune direct de la versiunea dvs. Actualizați mai întâi la versiunea v0.60.4 și ulterior la această versiune.",
"wrong_db_version": "Versiunea actuală a bazei de date ({{version}}) este mai nouă decât versiunea de bază de date suportată de aplicație ({{targetVersion}}), ceea ce înseamnă că a fost creată de către o versiune mai nouă de Trilium. Actualizați aplicația la ultima versiune pentru a putea continua."
},
"modals": {
"error_title": "Eroare"
}
}

View File

@@ -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("_", ""));

View File

@@ -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 {

View 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;

View 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;

View File

@@ -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) {

View File

@@ -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();
});
});
});
});

View File

@@ -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");
}
});

View File

@@ -6,6 +6,64 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Add file system mapping support
{
version: 234,
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
{
version: 233,

View 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;

View File

@@ -93,6 +93,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"redirectBareDomain",
"showLoginInShareTheme",
"splitEditorOrientation",
"fileSystemSyncEnabled",
// AI/LLM integration options
"aiEnabled",

View File

@@ -59,6 +59,7 @@ import openaiRoute from "./api/openai.js";
import anthropicRoute from "./api/anthropic.js";
import llmRoute from "./api/llm.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";
@@ -385,6 +386,9 @@ function register(app: express.Application) {
asyncApiRoute(GET, "/api/llm/providers/openai/models", openaiRoute.listModels);
asyncApiRoute(GET, "/api/llm/providers/anthropic/models", anthropicRoute.listModels);
// File system sync API
app.use("/api/file-system-sync", [auth.checkApiAuthOrElectron, csrfMiddleware], fileSystemSyncRoute);
// API Documentation
apiDocsRoute(app);

View File

@@ -3,7 +3,7 @@ import build from "./build.js";
import packageJson from "../../package.json" with { type: "json" };
import dataDir from "./data_dir.js";
const APP_DB_VERSION = 233;
const APP_DB_VERSION = 234;
const SYNC_VERSION = 36;
const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@@ -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);
});
});

View File

@@ -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);

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;
});
});
});

View File

@@ -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
};

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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' });
});
});
});
});

View File

@@ -211,6 +211,9 @@ const defaultOptions: DefaultOption[] = [
{ name: "aiTemperature", value: "0.7", isSynced: true },
{ name: "aiSystemPrompt", value: "", isSynced: true },
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
// File system sync options
{ name: "fileSystemSyncEnabled", value: "false", isSynced: false },
];
/**
@@ -251,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

View File

@@ -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();

View File

@@ -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 ...");

View File

@@ -295,7 +295,7 @@ async function registerGlobalShortcuts() {
const allActions = keyboardActionsService.getKeyboardActions();
for (const action of allActions) {
if (!("effectiveShortcuts" in action) || !action.effectiveShortcuts) {
if (!action.effectiveShortcuts) {
continue;
}

View File

@@ -61,32 +61,6 @@
"attachments": [],
"dirFileName": "Release Notes",
"children": [
{
"isClone": false,
"noteId": "lvOuiWsLDv8F",
"notePath": [
"hD3V4hiu2VW4",
"lvOuiWsLDv8F"
],
"title": "v0.97.2",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "template",
"value": "wyurrlcDl416",
"isInheritable": false,
"position": 60
}
],
"format": "markdown",
"dataFileName": "v0.97.2.md",
"attachments": []
},
{
"isClone": false,
"noteId": "OtFZ6Nd9vM3n",
@@ -95,7 +69,7 @@
"OtFZ6Nd9vM3n"
],
"title": "v0.97.1",
"notePosition": 20,
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -121,7 +95,7 @@
"SJZ5PwfzHSQ1"
],
"title": "v0.97.0",
"notePosition": 30,
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -147,7 +121,7 @@
"mYXFde3LuNR7"
],
"title": "v0.96.0",
"notePosition": 40,
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -173,7 +147,7 @@
"jthwbL0FdaeU"
],
"title": "v0.95.0",
"notePosition": 50,
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -199,7 +173,7 @@
"7HGYsJbLuhnv"
],
"title": "v0.94.1",
"notePosition": 60,
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -225,7 +199,7 @@
"Neq53ujRGBqv"
],
"title": "v0.94.0",
"notePosition": 70,
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -251,7 +225,7 @@
"VN3xnce1vLkX"
],
"title": "v0.93.0",
"notePosition": 80,
"notePosition": 70,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -269,7 +243,7 @@
"WRaBfQqPr6qo"
],
"title": "v0.92.7",
"notePosition": 90,
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -295,7 +269,7 @@
"a2rwfKNmUFU1"
],
"title": "v0.92.6",
"notePosition": 100,
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -313,7 +287,7 @@
"fEJ8qErr0BKL"
],
"title": "v0.92.5-beta",
"notePosition": 110,
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -331,7 +305,7 @@
"kkkZQQGSXjwy"
],
"title": "v0.92.4",
"notePosition": 120,
"notePosition": 110,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -349,7 +323,7 @@
"vAroNixiezaH"
],
"title": "v0.92.3-beta",
"notePosition": 130,
"notePosition": 120,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -367,7 +341,7 @@
"mHEq1wxAKNZd"
],
"title": "v0.92.2-beta",
"notePosition": 140,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -385,7 +359,7 @@
"IykjoAmBpc61"
],
"title": "v0.92.1-beta",
"notePosition": 150,
"notePosition": 140,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -403,7 +377,7 @@
"dq2AJ9vSBX4Y"
],
"title": "v0.92.0-beta",
"notePosition": 160,
"notePosition": 150,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -421,7 +395,7 @@
"3a8aMe4jz4yM"
],
"title": "v0.91.6",
"notePosition": 170,
"notePosition": 160,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -439,7 +413,7 @@
"8djQjkiDGESe"
],
"title": "v0.91.5",
"notePosition": 180,
"notePosition": 170,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -457,7 +431,7 @@
"OylxVoVJqNmr"
],
"title": "v0.91.4-beta",
"notePosition": 190,
"notePosition": 180,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -475,7 +449,7 @@
"tANGQDvnyhrj"
],
"title": "v0.91.3-beta",
"notePosition": 200,
"notePosition": 190,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -493,7 +467,7 @@
"hMoBfwSoj1SC"
],
"title": "v0.91.2-beta",
"notePosition": 210,
"notePosition": 200,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -511,7 +485,7 @@
"a2XMSKROCl9z"
],
"title": "v0.91.1-beta",
"notePosition": 220,
"notePosition": 210,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -529,7 +503,7 @@
"yqXFvWbLkuMD"
],
"title": "v0.90.12",
"notePosition": 230,
"notePosition": 220,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -547,7 +521,7 @@
"veS7pg311yJP"
],
"title": "v0.90.11-beta",
"notePosition": 240,
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -565,7 +539,7 @@
"sq5W9TQxRqMq"
],
"title": "v0.90.10-beta",
"notePosition": 250,
"notePosition": 240,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -583,7 +557,7 @@
"yFEGVCUM9tPx"
],
"title": "v0.90.9-beta",
"notePosition": 260,
"notePosition": 250,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -601,7 +575,7 @@
"o4wAGqOQuJtV"
],
"title": "v0.90.8",
"notePosition": 270,
"notePosition": 260,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -634,7 +608,7 @@
"i4A5g9iOg9I0"
],
"title": "v0.90.7-beta",
"notePosition": 280,
"notePosition": 270,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -652,7 +626,7 @@
"ThNf2GaKgXUs"
],
"title": "v0.90.6-beta",
"notePosition": 290,
"notePosition": 280,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -670,7 +644,7 @@
"G4PAi554kQUr"
],
"title": "v0.90.5-beta",
"notePosition": 300,
"notePosition": 290,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -697,7 +671,7 @@
"zATRobGRCmBn"
],
"title": "v0.90.4",
"notePosition": 310,
"notePosition": 300,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -715,7 +689,7 @@
"sCDLf8IKn3Iz"
],
"title": "v0.90.3",
"notePosition": 320,
"notePosition": 310,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -733,7 +707,7 @@
"VqqyBu4AuTjC"
],
"title": "v0.90.2-beta",
"notePosition": 330,
"notePosition": 320,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -751,7 +725,7 @@
"RX3Nl7wInLsA"
],
"title": "v0.90.1-beta",
"notePosition": 340,
"notePosition": 330,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -769,7 +743,7 @@
"GyueACukPWjk"
],
"title": "v0.90.0-beta",
"notePosition": 350,
"notePosition": 340,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -787,7 +761,7 @@
"wyurrlcDl416"
],
"title": "Release Template",
"notePosition": 360,
"notePosition": 350,
"prefix": null,
"isExpanded": false,
"type": "text",

View File

@@ -1,61 +0,0 @@
# v0.97.2
> [!NOTE]
> Translations are now easily editable online via Weblate. If you wish to contribute to Trilium by translating to your native language, head on over to [our Weblate page](https://hosted.weblate.org/engage/trilium/).
> [!IMPORTANT]
> If you enjoyed this release, consider showing a token of appreciation by:
>
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Notes) (top-right).
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
## 💡 Key highlights
* A new collection type has been added: a board in which child notes are grouped in columns.
* Geo map now comes with a vector map by default.
* The vector styles will provide for a smoother experience, consult the documentation for more information.
* Apart from that, there are multiple styles to choose from in the _Collection properties_ section in the ribbon, including dark themes.
* Apart from that, added an option to display a scale on the map.
* Jump to note was enhanced to allow triggering commands (such as the ones that can have a keyboard shortcut assigned to them) quickly.
* Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to test it out.
* The feature was renamed to simply “Jump to…” to better accommodate the new role.
* For more information, check the user guide for “Jump to…”.
## 🐞 Bugfixes
* [Shared note dark mode text doesn't change](https://github.com/TriliumNext/Trilium/issues/6427)
* [Markdown export of simple tables results in HTML, not Markdown](https://github.com/TriliumNext/Trilium/issues/6366)
* [Cannot edit ReadOnly note in quick edit](https://github.com/TriliumNext/Trilium/issues/6425)
* [Documentation for collections is empty](https://github.com/TriliumNext/Trilium/issues/6420)
* [Laggy "Mermaid Diagram" note type refresh during editing](https://github.com/TriliumNext/Trilium/issues/6443)
* Geomap not reacting to marker icon changes.
* [Checkbox Inputs (in Canvas Node > Export image...) have broken styling](https://github.com/TriliumNext/Trilium/issues/6463)
* [Rename Book note type to Collection](https://github.com/TriliumNext/Trilium/issues/6471)
* [Table view persistence not working in protected notes](https://github.com/TriliumNext/Trilium/issues/6473#issuecomment-3120029185)
* [Table caption print issue](https://github.com/TriliumNext/Trilium/issues/6483)
* [Commonmark import sub and sup tags not working](https://github.com/TriliumNext/Trilium/issues/4307)
* [Migration failing if there is a protected geomap](https://github.com/TriliumNext/Trilium/issues/6489)
* Window refreshing when sorting notes via the dialog.
* [Odd behavior in Safari on macOS (random refreshing+new note creation+user guide opens)](https://github.com/TriliumNext/Trilium/issues/6218)
* Note tooltip showing up in note list
* Copy to clipboard button also opening into note list
## ✨ Improvements
* [Show inline mermaid diagram in share view instead of mermaid diagram code](https://github.com/TriliumNext/Trilium/issues/5438)
* Canvas improvements:
* Add grid to canvas by @Papierkorb2292
* Improve style of toolbars and dropdowns on the Next theme.
* New type for promoted attributes: color.
* Web view note preview (in note list):
* The `#webViewSrc` is now hidden
* Dedicated button to open the link externally.
* [User Guide pages should be searchable](https://github.com/TriliumNext/Trilium/issues/6515)
## 🌍 Internationalization
* 100% translation coverage for Romanian.
* Spanish language improvements by @Aitanuqui
## 🛠️ Technical updates
* The shortcut keys management was completely rewritten as it was based on an older library. **Please raise any issues you might have with your keyboard shortcuts.**

Some files were not shown because too many files have changed in this diff Show More