Merge branch 'main' into fix/at-symbol-in-code-block

This commit is contained in:
chloelee767 2026-02-04 23:45:45 +08:00
commit a23affa148
102 changed files with 3587 additions and 716 deletions

View File

@ -42,5 +42,8 @@
},
"eslint.rules.customizations": [
{ "rule": "*", "severity": "warn" }
],
"cSpell.words": [
"Trilium"
]
}
}

View File

@ -11,7 +11,7 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.28.2",
"devDependencies": {
"@redocly/cli": "2.15.0",
"@redocly/cli": "2.15.1",
"archiver": "7.0.1",
"fs-extra": "11.3.3",
"react": "19.2.4",

View File

@ -13,6 +13,7 @@
<body id="trilium-app">
<noscript>Trilium requires JavaScript to be enabled.</noscript>
<div id="context-menu-cover"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
<!-- Required to match the PWA's top bar color with the theme -->

View File

@ -43,7 +43,7 @@
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.2.0",
"globals": "17.3.0",
"i18next": "25.8.0",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
@ -56,12 +56,12 @@
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.6.1",
"mind-elixir": "5.7.1",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.2",
"preact": "10.28.3",
"react-i18next": "16.5.4",
"react-window": "2.2.5",
"react-window": "2.2.6",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
@ -78,7 +78,7 @@
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.4.0",
"happy-dom": "20.5.0",
"lightningcss": "1.31.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.2.0"

View File

@ -2,18 +2,20 @@ import type AppContext from "../components/app_context.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import ContentHeader from "../widgets/containers/content_header.js";
import FlexContainer from "../widgets/containers/flex_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import FindWidget from "../widgets/find.js";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
@ -21,13 +23,10 @@ import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import QuickSearchWidget from "../widgets/quick_search.js";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
import SearchResult from "../widgets/search_result.jsx";
import SharedInfoWidget from "../widgets/shared_info.js";
import TabRowWidget from "../widgets/tab_row.js";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import { applyModals } from "./layout_commons.js";
@ -147,23 +146,22 @@ export default class MobileLayout {
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.class("title-row note-split-title")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(<NoteBadges />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(<PromotedAttributes />)
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />)
)
.child(<InlineTitle />)
.child(<NoteTitleActions />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
@ -171,6 +169,7 @@ export default class MobileLayout {
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
.child(new FindWidget())
)
)
)
@ -179,7 +178,6 @@ export default class MobileLayout {
new FlexContainer("column")
.contentSized()
.id("mobile-bottom-bar")
.child(new TabRowWidget().css("height", "40px"))
.child(new FlexContainer("row")
.class("horizontal")
.css("height", "53px")

View File

@ -1,8 +1,9 @@
import { KeyboardActionNames } from "@triliumnext/commons";
import { h, JSX, render } from "preact";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { h, JSX, render } from "preact";
export interface ContextMenuOptions<T> {
x: number;
@ -62,17 +63,17 @@ export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEve
class ContextMenu {
private $widget: JQuery<HTMLElement>;
private $cover: JQuery<HTMLElement>;
private $cover?: JQuery<HTMLElement>;
private options?: ContextMenuOptions<any>;
private isMobile: boolean;
constructor() {
this.$widget = $("#context-menu-container");
this.$cover = $("#context-menu-cover");
this.$widget.addClass("dropend");
this.isMobile = utils.isMobile();
if (this.isMobile) {
this.$cover = $("#context-menu-cover");
this.$cover.on("click", () => this.hide());
} else {
$(document).on("click", (e) => this.hide());
@ -91,7 +92,7 @@ class ContextMenu {
}
this.$widget.toggleClass("mobile-bottom-menu", !this.options.forcePositionOnMobile);
this.$cover.addClass("show");
this.$cover?.addClass("show");
$("body").addClass("context-menu-shown");
this.$widget.empty();
@ -140,16 +141,14 @@ class ContextMenu {
} else {
left = this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET;
}
} else if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
// Overflow: right
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
// Overflow: left
left = CONTEXT_MENU_PADDING;
} else {
if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
// Overflow: right
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
// Overflow: left
left = CONTEXT_MENU_PADDING;
} else {
left = this.options.x - CONTEXT_MENU_OFFSET;
}
left = this.options.x - CONTEXT_MENU_OFFSET;
}
this.$widget
@ -249,7 +248,7 @@ class ContextMenu {
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
$icon.addClass([icon, "tn-icon"]);
} else {
$icon.append("&nbsp;");
}
@ -261,7 +260,7 @@ class ContextMenu {
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
for (const badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
@ -352,7 +351,7 @@ class ContextMenu {
async hide() {
this.options?.onHide?.();
this.$widget.removeClass("show");
this.$cover.removeClass("show");
this.$cover?.removeClass("show");
$("body").removeClass("context-menu-shown");
this.$widget.hide();
}

View File

@ -49,7 +49,7 @@ function createClassForColor(colorString: string | null) {
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue");
}
function parseColor(color: string) {
export function parseColor(color: string) {
try {
return Color(color.toLowerCase());
} catch (ex) {
@ -77,7 +77,7 @@ function adjustColorLightness(color: ColorInstance, lightThemeMaxLightness: numb
}
/** Returns the hue of the specified color, or undefined if the color is grayscale. */
function getHue(color: ColorInstance) {
export function getHue(color: ColorInstance) {
const hslColor = color.hsl();
if (hslColor.saturationl() > 0) {
return hslColor.hue();

View File

@ -1,5 +1,6 @@
import { t } from "./i18n";
import options from "./options";
import { isMobile } from "./utils";
export interface ExperimentalFeature {
id: string;
@ -21,7 +22,7 @@ let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
if (featureId === "new-layout") {
return options.is("newLayout");
return (isMobile() || options.is("newLayout"));
}
return getEnabledFeatures().has(featureId);
@ -29,7 +30,7 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId):
export function getEnabledExperimentalFeatureIds() {
const values = [ ...getEnabledFeatures().values() ];
if (options.is("newLayout")) {
if (isMobile() || options.is("newLayout")) {
values.push("new-layout");
}
return values;

View File

@ -224,10 +224,6 @@ body.mobile .modal .modal-dialog {
width: 100%;
}
body.mobile .modal .modal-content {
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
}
.component {
contain: size;
}
@ -458,7 +454,7 @@ body.desktop .tabulator-popup-container,
visibility: hidden;
}
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
.dropdown-menu:not(#context-menu-container) .dropdown-item,
body.desktop .dropdown-menu .dropdown-toggle,
body #context-menu-container .dropdown-item > span,
body.mobile .dropdown .dropdown-submenu > span {
@ -466,6 +462,15 @@ body.mobile .dropdown .dropdown-submenu > span {
align-items: center;
}
body.mobile .dropdown .dropdown-submenu {
flex-wrap: wrap;
& > span {
flex-grow: 1;
}
}
.dropdown-item span.keyboard-shortcut,
.dropdown-item *:not(.keyboard-shortcut) > kbd {
flex-grow: 1;
@ -1255,7 +1260,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
z-index: 1000;
z-index: 2500;
background: rgba(0, 0, 0, 0.1);
}
@ -1534,7 +1539,8 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
@media (max-width: 991px) {
body.mobile #launcher-pane .dropdown.global-menu > .dropdown-menu.show,
body.mobile #launcher-container .dropdown > .dropdown-menu.show {
body.mobile #launcher-container .dropdown > .dropdown-menu.show,
body.mobile .dropdown-menu.mobile-bottom-menu.show {
--dropdown-bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
position: fixed !important;
bottom: var(--dropdown-bottom) !important;
@ -1546,6 +1552,10 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
max-height: calc(var(--tn-modal-max-height) - var(--dropdown-bottom));
}
body.mobile .modal-dialog .dropdown-menu.mobile-bottom-menu.show {
--dropdown-bottom: 0;
}
#mobile-sidebar-container {
position: fixed;
top: 0;
@ -1614,6 +1624,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
body.mobile .modal-content {
overflow-y: auto;
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
}
body.mobile .modal-footer {
@ -1669,6 +1680,15 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
#detail-container {
background: var(--main-background-color);
}
.modal-dialog {
margin: var(--bs-modal-margin);
max-width: 80%;
}
.modal-content {
height: 100%;
}
}
@media (max-width: 991px) {
@ -2617,14 +2637,14 @@ iframe.print-iframe {
}
}
#root-widget.virtual-keyboard-opened .note-split:not(:focus-within) {
/* #root-widget.virtual-keyboard-opened .note-split:not(:focus-within) {
max-height: 80px;
opacity: 0.4;
}
} */
}
}
body.desktop .title-row {
.title-row {
height: 50px;
min-height: 50px;
align-items: center;

View File

@ -134,6 +134,7 @@
--left-pane-collapsed-border-color: #0009;
--left-pane-background-color: #1f1f1f;
--left-pane-text-color: #aaaaaa;
--left-pane-icon-color: #c5c5c5;
--left-pane-item-hover-background: #ffffff0d;
--left-pane-item-selected-background: #ffffff25;
--left-pane-item-selected-color: #dfdfdf;

View File

@ -127,6 +127,7 @@
--left-pane-collapsed-border-color: #0000000d;
--left-pane-background-color: #f2f2f2;
--left-pane-text-color: #383838;
--left-pane-icon-color: currentColor;
--left-pane-item-hover-background: rgba(0, 0, 0, 0.032);
--left-pane-item-selected-background: white;
--left-pane-item-selected-color: black;

View File

@ -47,9 +47,14 @@
}
/* The toolbar show / hide button for the current text block */
.ck.ck-block-toolbar-button {
:root .ck.ck-block-toolbar-button {
--ck-color-block-toolbar-button: var(--muted-text-color);
--ck-color-button-on-background: transparent;
--ck-color-button-on-color: currentColor;
--ck-color-button-on-color: var(--ck-editor-toolbar-button-on-color);
translate: -40% 0;
min-width: 0;
padding: 0;
z-index: 1600;
}
:root .ck.ck-toolbar .ck-button:not(.ck-disabled):active,
@ -517,6 +522,10 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel).ck
* EDITOR'S CONTENT
*/
.note-detail-editable-text-editor > .ck-placeholder {
opacity: .5;
}
/*
* Code Blocks
*/

View File

@ -156,6 +156,10 @@
--preferred-max-content-width: var(--options-card-max-width);
}
.note-split.options .collection-properties {
visibility: hidden;
}
/* Create a gap at the top of the option pages */
.note-detail-content-widget-content.options>*:first-child {
margin-top: var(--options-first-item-top-margin, 1em);

View File

@ -769,9 +769,9 @@ body.mobile .fancytree-node > span {
#left-pane .fancytree-custom-icon {
margin-top: 0; /* Use this to align the icon with the tree view item's caption */
color: var(--custom-color, var(--left-pane-icon-color));
}
#left-pane span.fancytree-active .fancytree-title {
font-weight: normal;
}
@ -1272,7 +1272,7 @@ body.layout-horizontal #rest-pane > .classic-toolbar-widget {
#center-pane .note-split {
padding-top: 2px;
background-color: var(--note-split-background-color, var(--main-background-color));
transition: border-color 250ms ease-in;
transition: border-color 150ms ease-out;
border: 2px solid transparent;
}
@ -1322,7 +1322,7 @@ body.mobile .note-title {
margin-inline-start: 0;
}
.title-row {
body.desktop .title-row {
/* Aligns the "Create new split" button with the note menu button (the three dots button) */
padding-inline-end: 3px;
}

View File

@ -29,7 +29,9 @@
"widget-render-error": {
"title": "فشل عرض عنصر واجهة مستخدم React مخصص"
},
"widget-missing-parent": "لا تحتوي الأداة المخصصة على خاصية إلزامية '{{property}}'.\n\nإذا كان من المفترض تشغيل هذا البرنامج النصي بدون عنصر واجهة مستخدم، فاستخدم '#run=frontendStartup' بدلاً من ذلك."
"widget-missing-parent": "لا تحتوي الأداة المخصصة على خاصية إلزامية '{{property}}'.\n\nإذا كان من المفترض تشغيل هذا البرنامج النصي بدون عنصر واجهة مستخدم، فاستخدم '#run=frontendStartup' بدلاً من ذلك.",
"open-script-note": "فتح ملاحظة برمجية",
"scripting-error": "خطأ في النص البرمجي المخصص: {{title}}"
},
"add_link": {
"add_link": "أضافة رابط",
@ -37,7 +39,9 @@
"search_note": "البحث عن الملاحظة بالاسم",
"link_title": "عنوان الرابط",
"button_add_link": "اضافة رابط",
"help_on_links": "مساعدة حول الارتباطات التشعبية"
"help_on_links": "مساعدة حول الارتباطات التشعبية",
"link_title_mirrors": "عنوان الرابط يعكس العنوان الحالي للملاحظة",
"link_title_arbitrary": "يمكن تغيير عنوان الرابط حسب الرغبة"
},
"branch_prefix": {
"edit_branch_prefix": "تعديل بادئة الفرع",

View File

@ -1782,8 +1782,8 @@
"desktop-application": "桌面应用程序",
"native-title-bar": "原生标题栏",
"native-title-bar-description": "对于 Windows 和 macOS关闭原生标题栏可使应用程序看起来更紧凑。在 Linux 上,保留原生标题栏可以更好地与系统集成。",
"background-effects": "启用背景效果(仅适用于 Windows 11",
"background-effects-description": "Mica 效果为应用窗口添加模糊且时尚的背景,营造出深度感和现代外观。「原生标题栏」必須被禁用。",
"background-effects": "启用背景效果",
"background-effects-description": "为应用窗口添加模糊且时尚的背景,营造出深度感和现代外观。「原生标题栏」必須被禁用。",
"restart-app-button": "重启应用程序以查看更改",
"zoom-factor": "缩放系数"
},
@ -1802,7 +1802,8 @@
"geo-map": {
"create-child-note-title": "创建一个新的子笔记并将其添加到地图中",
"create-child-note-instruction": "单击地图以在该位置创建新笔记,或按 Escape 以取消。",
"unable-to-load-map": "无法加载地图。"
"unable-to-load-map": "无法加载地图。",
"create-child-note-text": "添加标记"
},
"geo-map-context": {
"open-location": "打开位置",
@ -2117,7 +2118,7 @@
},
"call_to_action": {
"background_effects_title": "背景效果现已推出稳定版本",
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
"background_effects_message": "在 Windows 和 macOS 设备上,背景效果现在已稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。",
"background_effects_button": "启用背景效果",
"next_theme_title": "试用新 Trilium 主题",
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
@ -2253,5 +2254,12 @@
"pages_alt": "第{{pageNumber}}页",
"pages_loading": "加载中...",
"layers_other": "{{count}} 层"
},
"platform_indicator": {
"available_on": "在 {{platform}} 上可用"
},
"mobile_tab_switcher": {
"title_other": "{{count}} 选项卡",
"more_options": "更多选项"
}
}

View File

@ -1771,7 +1771,8 @@
"geo-map": {
"create-child-note-title": "Neue Unternotiz anlegen und zur Karte hinzufügen",
"create-child-note-instruction": "Auf die Karte klicken, um eine neue Notiz an der Stelle zu erstellen oder Escape drücken um abzubrechen.",
"unable-to-load-map": "Karte konnte nicht geladen werden."
"unable-to-load-map": "Karte konnte nicht geladen werden.",
"create-child-note-text": "Marker hinzufügen"
},
"geo-map-context": {
"open-location": "Ort öffnen",
@ -2270,5 +2271,10 @@
},
"platform_indicator": {
"available_on": "Verfügbar auf {{platform}}"
},
"mobile_tab_switcher": {
"title_one": "{{count}} Tab",
"title_other": "{{count}} Tabs",
"more_options": "Weitere Optionen"
}
}

View File

@ -745,7 +745,7 @@
"button_title": "Export diagram as SVG"
},
"relation_map_buttons": {
"create_child_note_title": "Create new child note and add it into this relation map",
"create_child_note_title": "Create child note and add it to map",
"reset_pan_zoom_title": "Reset pan & zoom to initial coordinates and magnification",
"zoom_in_title": "Zoom In",
"zoom_out_title": "Zoom Out"
@ -760,7 +760,8 @@
"delete_this_note": "Delete this note",
"note_revisions": "Note revisions",
"error_cannot_get_branch_id": "Cannot get branchId for notePath '{{notePath}}'",
"error_unrecognized_command": "Unrecognized command {{command}}"
"error_unrecognized_command": "Unrecognized command {{command}}",
"backlinks": "Backlinks"
},
"note_icon": {
"change_note_icon": "Change note icon",
@ -2271,5 +2272,10 @@
},
"platform_indicator": {
"available_on": "Available on {{platform}}"
},
"mobile_tab_switcher": {
"title_one": "{{count}} tab",
"title_other": "{{count}} tabs",
"more_options": "More options"
}
}

View File

@ -1614,7 +1614,7 @@
},
"bookmark_switch": {
"bookmark": "Marcador",
"bookmark_this_note": "Añadir esta nota a marcadores en el panel lateral izquierdo",
"bookmark_this_note": "Agregar esta nota a marcadores en el panel lateral izquierdo",
"remove_bookmark": "Eliminar marcador"
},
"editability_select": {
@ -1964,7 +1964,8 @@
"geo-map": {
"create-child-note-title": "Crear una nueva subnota y agregarla al mapa",
"create-child-note-instruction": "Dé clic en el mapa para crear una nueva nota en esa ubicación o presione Escape para cancelar.",
"unable-to-load-map": "No se puede cargar el mapa."
"unable-to-load-map": "No se puede cargar el mapa.",
"create-child-note-text": "Agregar marcador"
},
"geo-map-context": {
"open-location": "Abrir ubicación",
@ -2284,5 +2285,11 @@
},
"platform_indicator": {
"available_on": "Disponible en {{platform}}"
},
"mobile_tab_switcher": {
"title_one": "{{count}} pestaña",
"title_many": "{{count}} pestañas",
"title_other": "{{count}} pestañas",
"more_options": "Más opciones"
}
}

View File

@ -0,0 +1,5 @@
{
"global_menu": {
"about": "Maidir le Trilium Notes"
}
}

View File

@ -2008,7 +2008,8 @@
"geo-map": {
"create-child-note-title": "新しい子ノートを作成し、マップに追加する",
"create-child-note-instruction": "地図をクリックしてその場所に新しいートを作成するか、Esc キーを押して閉じます。",
"unable-to-load-map": "マップを読み込めません。"
"unable-to-load-map": "マップを読み込めません。",
"create-child-note-text": "マーカーを追加"
},
"geo-map-context": {
"open-location": "現在位置を表示",
@ -2256,5 +2257,9 @@
},
"platform_indicator": {
"available_on": "{{platform}} で利用可能"
},
"mobile_tab_switcher": {
"title_other": "{{count}} タブ",
"more_options": "その他のオプション"
}
}

View File

@ -1761,8 +1761,8 @@
"show-recent-notes": "Afișează notițele recente"
},
"electron_integration": {
"background-effects": "Activează efectele de fundal (doar pentru Windows 11)",
"background-effects-description": "Efectul Mica adaugă un fundal estompat și elegant ferestrelor aplicațiilor, creând profunzime și un aspect modern. Opțiunea „Bară de titlu nativă” trebuie să fie dezactivată.",
"background-effects": "Activează efectele de fundal",
"background-effects-description": "Adaugă un fundal estompat și elegant ferestrelor aplicațiilor, creând profunzime și un aspect modern. Opțiunea „Bară de titlu nativă” trebuie să fie dezactivată.",
"desktop-application": "Aplicația desktop",
"native-title-bar": "Bară de titlu nativă",
"native-title-bar-description": "Pentru Windows și macOS, dezactivarea bării de titlu native face aplicația să pară mai compactă. Pe Linux, păstrarea bării integrează mai bine aplicația cu restul sistemului de operare.",
@ -1781,7 +1781,8 @@
"geo-map": {
"create-child-note-title": "Crează o notiță nouă și adaug-o pe hartă",
"unable-to-load-map": "Nu s-a putut încărca harta.",
"create-child-note-instruction": "Click pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a anula."
"create-child-note-instruction": "Click pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a anula.",
"create-child-note-text": "Adaugă marcaj"
},
"duration": {
"days": "zile",
@ -2127,7 +2128,7 @@
},
"call_to_action": {
"background_effects_title": "Efectele de fundal sunt acum stabile",
"background_effects_message": "Pe dispozitive cu Windows, efectele de fundal sunt complet stabile. Acestea adaugă un strop de culoare interfeței grafice prin estomparea fundalului din spatele ferestrei. Această tehnică este folosită și în alte aplicații precum Windows Explorer.",
"background_effects_message": "Pe dispozitive cu Windows și macOS, efectele de fundal sunt stabile. Acestea adaugă un strop de culoare interfeței grafice prin estomparea fundalului din spatele ferestrei.",
"background_effects_button": "Activează efectele de fundal",
"next_theme_title": "Încercați noua temă Trilium",
"next_theme_message": "Utilizați tema clasică, doriți să încercați noua temă?",
@ -2281,5 +2282,14 @@
"pages_other": "{{count}} de pagini",
"pages_alt": "Pagina {{pageNumber}}",
"pages_loading": "Încărcare..."
},
"platform_indicator": {
"available_on": "Disponibil pe {{platform}}"
},
"mobile_tab_switcher": {
"title_one": "{{count}} tab",
"title_few": "{{count}} taburi",
"title_other": "{{count}} de taburi",
"more_options": "Mai multe opțiuni"
}
}

View File

@ -0,0 +1,83 @@
.tn-backlinks-widget .backlinks-items {
list-style-type: none;
margin: 0;
padding: 0;
position: static;
width: unset;
> li {
--border-radius: 8px;
max-width: 600px;
padding: 10px 20px;
background: var(--card-background-color);
& + li {
margin-top: 2px;
}
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
/* Card header */
& > span:first-child {
display: block;
> span {
display: flex;
flex-wrap: wrap;
align-items: center;
/* Note path */
> small {
flex: 100%;
order: -1;
font-size: .65rem;
.note-path {
padding: 0;
}
}
/* Note icon */
> .tn-icon {
color: var(--menu-item-icon-color);
}
/* Note title */
> a {
margin-inline-start: 4px;
color: currentColor;
font-weight: 500;
}
}
}
/* Card content - excerpt */
.backlink-excerpt {
all: unset; /* TODO: Remove after disposing the old style from FloatingButtons.css */
display: block;
margin: 8px 0;
border-radius: 4px;
background: var(--quick-search-result-content-background);
padding: 8px;
font-size: .75rem;
a {
background: transparent;
color: var(--quick-search-result-highlight-color);
text-decoration: underline;
}
p {
margin: 0;
}
}
}
}

View File

@ -1,3 +1,5 @@
import "./Backlinks.css";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
import { VNode } from "preact";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
@ -60,14 +62,6 @@ export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
Backlinks
];
export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
RefreshBackendLogButton,
EditButton,
RelationMapButtons,
ExportImageButtons,
Backlinks
];
/**
* Floating buttons that should be hidden in popup editor (Quick edit).
*/

View File

@ -5,6 +5,7 @@ import clsx from "clsx";
import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
import NoteContext from "../components/note_context";
import FAttribute from "../entities/fattribute";
import FNote from "../entities/fnote";
import { Attribute } from "../services/attribute_parser";
@ -40,8 +41,8 @@ type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | J
type OnChangeListener = (e: OnChangeEventData) => Promise<void>;
export default function PromotedAttributes() {
const { note, componentId } = useNoteContext();
const [ cells, setCells ] = usePromotedAttributeData(note, componentId);
const { note, componentId, noteContext } = useNoteContext();
const [ cells, setCells ] = usePromotedAttributeData(note, componentId, noteContext);
return <PromotedAttributesContent note={note} componentId={componentId} cells={cells} setCells={setCells} />;
}
@ -74,12 +75,12 @@ export function PromotedAttributesContent({ note, componentId, cells, setCells }
*
* The cells are returned as a state since they can also be altered internally if needed, for example to add a new empty cell.
*/
export function usePromotedAttributeData(note: FNote | null | undefined, componentId: string): [ Cell[] | undefined, Dispatch<StateUpdater<Cell[] | undefined>> ] {
export function usePromotedAttributeData(note: FNote | null | undefined, componentId: string, noteContext: NoteContext | undefined): [ Cell[] | undefined, Dispatch<StateUpdater<Cell[] | undefined>> ] {
const [ viewType ] = useNoteLabel(note, "viewType");
const [ cells, setCells ] = useState<Cell[]>();
function refresh() {
if (!note || viewType === "table") {
if (!note || viewType === "table" || noteContext?.viewScope?.viewMode !== "default") {
setCells([]);
return;
}
@ -124,7 +125,7 @@ export function usePromotedAttributeData(note: FNote | null | undefined, compone
setCells(cells);
}
useEffect(refresh, [ note, viewType ]);
useEffect(refresh, [ note, viewType, noteContext ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
refresh();

View File

@ -29,7 +29,6 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
const isVerticalLayout = !isHorizontalLayout;
const parentComponent = useContext(ParentComponent);
const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus();
const isMobileLocal = isMobile();
const logoRef = useRef<SVGSVGElement>(null);
useStaticTooltip(logoRef);
@ -44,8 +43,7 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
</div>}
</>}
noDropdownListStyle
onShown={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.add("show", "global-menu-cover") : undefined}
onHidden={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.remove("show", "global-menu-cover") : undefined}
mobileBackdrop
>
<MenuItem command="openNewWindow" icon="bx bx-window-open" text={t("global_menu.open_new_window")} />
@ -107,8 +105,7 @@ function BrowserOnlyOptions() {
function DevelopmentOptions({ dropStart }: { dropStart: boolean }) {
return <>
<FormDropdownDivider />
<FormListItem disabled>Development Options</FormListItem>
<FormListHeader text="Development Options"></FormListHeader>
<FormDropdownSubmenu icon="bx bx-test-tube" title="Experimental features" dropStart={dropStart}>
{experimentalFeatures.map((feature) => (
<ExperimentalFeatureToggle key={feature.id} experimentalFeature={feature as ExperimentalFeature} />

View File

@ -155,7 +155,7 @@ function NoteAttributes({ note }: { note: FNote }) {
return <span className="note-list-attributes" ref={ref} />;
}
function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
note: FNote;
trim?: boolean;
noChildrenList?: boolean;

View File

@ -1,14 +1,22 @@
.scrolling-container {
--content-margin-inline: 24px;
overflow: auto;
scroll-behavior: smooth;
position: relative;
> .inline-title,
> .note-detail > .note-detail-editable-text,
> .note-detail > .note-detail-editable-text > .note-detail-editable-text-editor,
> .note-list-widget:not(.full-height) .note-list-wrapper {
padding-inline: 24px;
margin-inline: var(--content-margin-inline);
}
> .inline-title {
padding-inline: var(--content-margin-inline);
}
> .note-detail > .note-detail-editable-text > .note-detail-editable-text-editor {
overflow: unset;
}
}
.note-split.type-code:not(.mime-text-x-sqlite) {

View File

@ -91,8 +91,9 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
height: 100%;
}
.modal.popup-editor-dialog .note-detail-editable-text {
padding: 0 1em;
.modal.popup-editor-dialog .note-detail-editable-text-editor {
margin: 0 28px;
overflow: visible; /* Allow selection rectangle to go outside of the editor area */
}
.modal.popup-editor-dialog .note-detail-file {

View File

@ -5,13 +5,15 @@ import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import NoteContext from "../../components/note_context";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import tree from "../../services/tree";
import utils from "../../services/utils";
import NoteList from "../collections/NoteList";
import FloatingButtons from "../FloatingButtons";
import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
import { DESKTOP_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
import NoteBadges from "../layout/NoteBadges";
import NoteIcon from "../note_icon";
import NoteTitleWidget from "../note_title";
import NoteDetail from "../NoteDetail";
@ -23,8 +25,6 @@ import ReadOnlyNoteInfoBar from "../ReadOnlyNoteInfoBar";
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar";
import NoteBadges from "../layout/NoteBadges";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
@ -34,7 +34,7 @@ export default function PopupEditor() {
const [ noteContext, setNoteContext ] = useState(new NoteContext("_popup-editor"));
const isMobile = utils.isMobile();
const items = useMemo(() => {
const baseItems = isMobile ? MOBILE_FLOATING_BUTTONS : DESKTOP_FLOATING_BUTTONS;
const baseItems = isMobile ? [] : DESKTOP_FLOATING_BUTTONS;
return baseItems.filter(item => !POPUP_HIDDEN_FLOATING_BUTTONS.includes(item));
}, [ isMobile ]);

View File

@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { isDesktop, isMobile } from "../../services/utils";
import TabSwitcher from "../mobile_widgets/TabSwitcher";
import { useTriliumEvent } from "../react/hooks";
import { onWheelHorizontalScroll } from "../widget_utils";
import BookmarkButtons from "./BookmarkButtons";
@ -97,6 +98,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
return <QuickSearchLauncherWidget />;
case "aiChatLauncher":
return <AiChatButton launcherNote={note} />;
case "mobileTabSwitcher":
return <TabSwitcher />;
default:
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import { createContext } from "preact";
import { useContext } from "preact/hooks";
@ -18,12 +19,12 @@ export interface LauncherNoteProps {
launcherNote: FNote;
}
export function LaunchBarActionButton(props: Omit<ActionButtonProps, "className" | "noIconActionClass" | "titlePosition">) {
export function LaunchBarActionButton({ className, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition">) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
return (
<ActionButton
className="button-widget launcher-button"
className={clsx("button-widget launcher-button", className)}
noIconActionClass
titlePosition={isHorizontalLayout ? "bottom" : "right"}
{...props}

View File

@ -5,6 +5,7 @@ import { Tooltip } from "bootstrap";
import clsx from "clsx";
import { ComponentChild } from "preact";
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import FNote from "../../entities/fnote";

View File

@ -48,7 +48,7 @@ function PromotedAttributes({ note, componentId, noteContext }: {
componentId: string,
noteContext: NoteContext | undefined
}) {
const [ cells, setCells ] = usePromotedAttributeData(note, componentId);
const [ cells, setCells ] = usePromotedAttributeData(note, componentId, noteContext);
const [ expanded, setExpanded ] = useState(false);
useEffect(() => {

View File

@ -91,68 +91,6 @@
.note-paths-widget {
padding: 0.5em;
}
.note-path-intro {
color: var(--muted-text-color);
}
.note-path-list {
margin: 12px 0;
padding: 0;
list-style: none;
/* Note path card */
li {
--border-radius: 6px;
position: relative;
background: var(--card-background-color);
padding: 8px 20px 8px 25px;
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
& + li {
margin-top: 2px;
}
/* Current path arrow */
&.path-current::before {
position: absolute;
display: flex;
justify-content: flex-end;
align-items: center;
content: "\ee8f";
top: 0;
left: 0;
width: 20px;
bottom: 0;
font-family: "boxicons";
font-size: .75em;
color: var(--menu-item-icon-color);
}
}
/* Note path segment */
a {
margin-inline: 2px;
padding-inline: 2px;
color: currentColor;
font-weight: normal;
text-decoration: none;
/* The last segment of the note path */
&.basename {
color: var(--muted-text-color);
}
}
}
}
.backlinks-widget > .dropdown-menu {
@ -160,84 +98,6 @@
max-height: 60vh;
overflow-y: scroll;
/* Backlink card */
li {
--border-radius: 8px;
max-width: 600px;
padding: 10px 20px;
background: var(--card-background-color);
& + li {
margin-top: 2px;
}
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
/* Card header */
& > span:first-child {
display: block;
> span {
display: flex;
flex-wrap: wrap;
align-items: center;
/* Note path */
> small {
flex: 100%;
order: -1;
font-size: .65rem;
.note-path {
padding: 0;
}
}
/* Note icon */
> .tn-icon {
color: var(--menu-item-icon-color);
}
/* Note title */
> a {
margin-inline-start: 4px;
color: currentColor;
font-weight: 500;
}
}
}
/* Card content - excerpt */
& > span:nth-child(2) > div {
all: unset; /* TODO: Remove after disposing the old style from FloatingButtons.css */
display: block;
margin: 8px 0;
border-radius: 4px;
background: var(--quick-search-result-content-background);
padding: 8px;
font-size: .75rem;
a {
background: transparent;
color: var(--quick-search-result-highlight-color);
text-decoration: underline;
}
p {
margin: 0;
}
}
}
}
}

View File

@ -56,7 +56,7 @@ export default function StatusBar() {
similarNotesShown: activePane === "similar-notes",
setSimilarNotesShown: (shown) => setActivePane(shown && "similar-notes")
};
const isHiddenNote = note?.isInHiddenSubtree();
const isHiddenNote = note?.isHiddenCompletely();
return (
<div className="status-bar">
@ -300,7 +300,7 @@ function BacklinksBadge({ note, viewScope }: StatusBarContext) {
const count = useBacklinkCount(note, viewScope?.viewMode === "default");
return (note && count > 0 &&
<StatusBarDropdown
className="backlinks-badge backlinks-widget"
className="backlinks-badge backlinks-widget tn-backlinks-widget"
icon="bx bx-link"
text={t("status_bar.backlinks", { count })}
title={t("status_bar.backlinks_title", { count })}

View File

@ -0,0 +1,133 @@
#launcher-container .mobile-tab-switcher {
position: relative;
&::after {
content: attr(data-tab-count);
font-family: var(--main-font-family);
font-size: 10px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.modal.tab-bar-modal {
.modal-dialog {
min-height: 85vh;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
@media (min-width: 850px) {
grid-template-columns: 1fr 1fr 1fr;
}
.tab-card {
background: var(--card-background-color);
border-radius: 1em;
min-width: 0;
overflow: hidden;
height: 200px;
display: flex;
flex-direction: column;
&.with-hue {
background-color: hsl(var(--bg-hue), 8.8%, 11.2%);
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
}
&.active {
outline: 4px solid var(--more-accented-background-color);
background: var(--card-background-hover-color);
.title {
font-weight: bold;
}
}
header {
padding: 0.4em 0.5em;
border-bottom: 1px solid rgba(150, 150, 150, 0.1);
display: flex;
overflow: hidden;
align-items: center;
color: var(--custom-color, inherit);
flex-shrink: 0;
&:not(:first-of-type) {
border-top: 1px solid rgba(150, 150, 150, 0.1);
}
>.tn-icon {
margin-inline-end: 0.4em;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
font-size: 0.9em;
flex-grow: 1;
}
.icon-action {
flex-shrink: 0;
}
}
.tab-preview {
flex-grow: 1;
height: 100%;
overflow: hidden;
font-size: 0.5em;
user-select: none;
pointer-events: none;
&.type-text {
padding: 10px;
}
&.type-book,
&.type-contentWidget,
&.type-search,
&.type-empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25em;
color: var(--muted-text-color);
}
.preview-placeholder {
font-size: 500%;
}
p { margin-bottom: 0.2em;}
h2 { font-size: 1.20em; }
h3 { font-size: 1.15em; }
h4 { font-size: 1.10em; }
h5 { font-size: 1.05em}
h6 { font-size: 1em; }
}
&.with-split {
.preview-placeholder {
font-size: 250%;
}
}
}
}
.modal-footer {
.tn-link {
color: var(--main-text-color);
width: 40%;
text-align: center;
text-decoration: none;
}
}
}

View File

@ -0,0 +1,240 @@
import "./TabSwitcher.css";
import clsx from "clsx";
import { createPortal, Fragment } from "preact/compat";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import appContext, { CommandNames } from "../../components/app_context";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import contextMenu from "../../menus/context_menu";
import { getHue, parseColor } from "../../services/css_class_manager";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import { NoteContent } from "../collections/legacy/ListOrGridView";
import { LaunchBarActionButton } from "../launch_bar/launch_bar_widgets";
import { ICON_MAPPINGS } from "../note_bars/CollectionProperties";
import ActionButton from "../react/ActionButton";
import { useActiveNoteContext, useNoteIcon, useTriliumEvents } from "../react/hooks";
import Icon from "../react/Icon";
import LinkButton from "../react/LinkButton";
import Modal from "../react/Modal";
export default function TabSwitcher() {
const [ shown, setShown ] = useState(false);
const mainNoteContexts = useMainNoteContexts();
return (
<>
<LaunchBarActionButton
className="mobile-tab-switcher"
icon="bx bx-rectangle"
text="Tabs"
onClick={() => setShown(true)}
data-tab-count={mainNoteContexts.length > 99 ? "∞" : mainNoteContexts.length}
/>
{createPortal(<TabBarModal mainNoteContexts={mainNoteContexts} shown={shown} setShown={setShown} />, document.body)}
</>
);
}
function TabBarModal({ mainNoteContexts, shown, setShown }: {
mainNoteContexts: NoteContext[];
shown: boolean;
setShown: (newValue: boolean) => void;
}) {
const [ fullyShown, setFullyShown ] = useState(false);
const selectTab = useCallback((noteContextToActivate: NoteContext) => {
appContext.tabManager.activateNoteContext(noteContextToActivate.ntxId);
setShown(false);
}, [ setShown ]);
return (
<Modal
className="tab-bar-modal"
size="xl"
title={t("mobile_tab_switcher.title", { count: mainNoteContexts.length})}
show={shown}
onShown={() => setFullyShown(true)}
customTitleBarButtons={[
{
iconClassName: "bx bx-dots-vertical-rounded",
title: t("mobile_tab_switcher.more_options"),
onClick(e) {
contextMenu.show<CommandNames>({
x: e.pageX,
y: e.pageY,
items: [
{ title: t("tab_row.new_tab"), command: "openNewTab", uiIcon: "bx bx-plus" },
{ title: t("tab_row.reopen_last_tab"), command: "reopenLastTab", uiIcon: "bx bx-undo", enabled: appContext.tabManager.recentlyClosedTabs.length !== 0 },
{ kind: "separator" },
{ title: t("tab_row.close_all_tabs"), command: "closeAllTabs", uiIcon: "bx bx-trash destructive-action-icon" },
],
selectMenuItemHandler: ({ command }) => {
if (command) {
appContext.triggerCommand(command);
}
}
});
},
}
]}
footer={<>
<LinkButton
text={t("tab_row.new_tab")}
onClick={() => {
appContext.triggerCommand("openNewTab");
setShown(false);
}}
/>
</>}
scrollable
onHidden={() => {
setShown(false);
setFullyShown(false);
}}
>
<TabBarModelContent mainNoteContexts={mainNoteContexts} selectTab={selectTab} shown={fullyShown} />
</Modal>
);
}
function TabBarModelContent({ mainNoteContexts, selectTab, shown }: {
mainNoteContexts: NoteContext[];
shown: boolean;
selectTab: (noteContextToActivate: NoteContext) => void;
}) {
const activeNoteContext = useActiveNoteContext();
const tabRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Scroll to active tab.
useEffect(() => {
if (!shown || !activeNoteContext?.ntxId) return;
const correspondingEl = tabRefs.current[activeNoteContext.ntxId];
requestAnimationFrame(() => {
correspondingEl?.scrollIntoView();
});
}, [ activeNoteContext, shown ]);
return (
<div className="tabs">
{mainNoteContexts.map((noteContext) => (
<Tab
key={noteContext.ntxId}
noteContext={noteContext}
activeNtxId={activeNoteContext.ntxId}
selectTab={selectTab}
containerRef={el => (tabRefs.current[noteContext.ntxId ?? ""] = el)}
/>
))}
</div>
);
}
function Tab({ noteContext, containerRef, selectTab, activeNtxId }: {
containerRef: (el: HTMLDivElement | null) => void;
noteContext: NoteContext;
selectTab: (noteContextToActivate: NoteContext) => void;
activeNtxId: string | null | undefined;
}) {
const { note } = noteContext;
const iconClass = useNoteIcon(note);
const colorClass = note?.getColorClass() || '';
const workspaceTabBackgroundColorHue = getWorkspaceTabBackgroundColorHue(noteContext);
const subContexts = noteContext.getSubContexts();
return (
<div
ref={containerRef}
class={clsx("tab-card", {
active: noteContext.ntxId === activeNtxId,
"with-hue": workspaceTabBackgroundColorHue !== undefined,
"with-split": subContexts.length > 1
})}
onClick={() => selectTab(noteContext)}
style={{
"--bg-hue": workspaceTabBackgroundColorHue
}}
>
{subContexts.map(subContext => (
<Fragment key={subContext.ntxId}>
<header className={colorClass}>
{subContext.note && <Icon icon={iconClass} />}
<span className="title">{subContext.note?.title ?? t("tab_row.new_tab")}</span>
{subContext.isMainContext() && <ActionButton
icon="bx bx-x"
text={t("tab_row.close_tab")}
onClick={(e) => {
// We are closing a tab, so we need to prevent propagation for click (activate tab).
e.stopPropagation();
appContext.tabManager.removeNoteContext(subContext.ntxId);
}}
/>}
</header>
<div className={clsx("tab-preview", `type-${subContext.note?.type ?? "empty"}`)}>
<TabPreviewContent note={subContext.note} />
</div>
</Fragment>
))}
</div>
);
}
function TabPreviewContent({ note }: {
note: FNote | null
}) {
if (!note) {
return <PreviewPlaceholder icon="bx bx-plus" />;
}
if (note.type === "book") {
return <PreviewPlaceholder icon={ICON_MAPPINGS[note.getLabelValue("viewType") ?? ""] ?? "bx bx-book"} />;
}
return (
<NoteContent
note={note}
highlightedTokens={undefined}
trim
includeArchivedNotes={false}
/>
);
}
function PreviewPlaceholder({ icon}: {
icon: string;
}) {
return (
<div className="preview-placeholder">
<Icon icon={icon} />
</div>
);
}
function getWorkspaceTabBackgroundColorHue(noteContext: NoteContext) {
if (!noteContext.hoistedNoteId) return;
const hoistedNote = froca.getNoteFromCache(noteContext.hoistedNoteId);
if (!hoistedNote) return;
const workspaceTabBackgroundColor = hoistedNote.getWorkspaceTabBackgroundColor();
if (!workspaceTabBackgroundColor) return;
try {
const parsedColor = parseColor(workspaceTabBackgroundColor);
if (!parsedColor) return;
return getHue(parsedColor);
} catch (e) {
// Colors are non-critical, simply ignore.
console.warn(e);
}
}
function useMainNoteContexts() {
const [ noteContexts, setNoteContexts ] = useState(appContext.tabManager.getMainNoteContexts());
useTriliumEvents([ "newNoteContextCreated", "noteContextRemoved" ] , () => {
setNoteContexts(appContext.tabManager.getMainNoteContexts());
});
return noteContexts;
}

View File

@ -1,84 +1,128 @@
import { useContext } from "preact/hooks";
import { createPortal, useState } from "preact/compat";
import appContext, { CommandMappings } from "../../components/app_context";
import contextMenu, { MenuItem } from "../../menus/context_menu";
import branches from "../../services/branches";
import FNote, { NotePathRecord } from "../../entities/fnote";
import { t } from "../../services/i18n";
import { getHelpUrlForNote } from "../../services/in_app_help";
import note_create from "../../services/note_create";
import tree from "../../services/tree";
import { openInAppHelpFromUrl } from "../../services/utils";
import BasicWidget from "../basic_widget";
import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions";
import ActionButton from "../react/ActionButton";
import { ParentComponent } from "../react/react_utils";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { useNoteContext } from "../react/hooks";
import Modal from "../react/Modal";
import { NoteContextMenu } from "../ribbon/NoteActions";
import NoteActionsCustom from "../ribbon/NoteActionsCustom";
import { NotePathsWidget, useSortedNotePaths } from "../ribbon/NotePathsTab";
export default function MobileDetailMenu() {
const parentComponent = useContext(ParentComponent);
const { note, noteContext, parentComponent, ntxId, viewScope, hoistedNoteId } = useNoteContext();
const subContexts = noteContext?.getMainContext().getSubContexts() ?? [];
const isMainContext = noteContext?.isMainContext();
const [ backlinksModalShown, setBacklinksModalShown ] = useState(false);
const [ notePathsModalShown, setNotePathsModalShown ] = useState(false);
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
const backlinksCount = useBacklinkCount(note, viewScope?.viewMode === "default");
function closePane() {
// Wait first for the context menu to be dismissed, otherwise the backdrop stays on.
requestAnimationFrame(() => {
parentComponent.triggerCommand("closeThisNoteSplit", { ntxId });
});
}
return (
<ActionButton
icon="bx bx-dots-vertical-rounded"
text=""
onClick={(e) => {
const ntxId = (parentComponent as BasicWidget | null)?.getClosestNtxId();
if (!ntxId) return;
<div style={{ contain: "none" }}>
{note ? (
<NoteContextMenu
note={note} noteContext={noteContext}
extraItems={<>
<div className="form-list-row">
<div className="form-list-col">
<FormListItem
icon="bx bx-link"
onClick={() => setBacklinksModalShown(true)}
disabled={backlinksCount === 0}
>{t("status_bar.backlinks", { count: backlinksCount })}</FormListItem>
</div>
<div className="form-list-col">
<FormListItem
icon="bx bx-directions"
onClick={() => setNotePathsModalShown(true)}
disabled={(sortedNotePaths?.length ?? 0) <= 1}
>{t("status_bar.note_paths", { count: sortedNotePaths?.length })}</FormListItem>
</div>
</div>
<FormDropdownDivider />
const noteContext = appContext.tabManager.getNoteContextById(ntxId);
const subContexts = noteContext.getMainContext().getSubContexts();
const isMainContext = noteContext?.isMainContext();
const note = noteContext.note;
const helpUrl = getHelpUrlForNote(note);
{noteContext && ntxId && <NoteActionsCustom note={note} noteContext={noteContext} ntxId={ntxId} />}
<FormListItem
onClick={() => noteContext?.notePath && note_create.createNote(noteContext.notePath)}
icon="bx bx-plus"
>{t("mobile_detail_menu.insert_child_note")}</FormListItem>
{subContexts.length < 2 && <>
<FormDropdownDivider />
<FormListItem
onClick={() => parentComponent.triggerCommand("openNewNoteSplit", { ntxId })}
icon="bx bx-dock-right"
>{t("create_pane_button.create_new_split")}</FormListItem>
</>}
{!isMainContext && <>
<FormDropdownDivider />
<FormListItem
icon="bx bx-x"
onClick={closePane}
>{t("close_pane_button.close_this_pane")}</FormListItem>
</>}
<FormDropdownDivider />
</>}
/>
) : (
<ActionButton
icon="bx bx-x"
onClick={closePane}
text={t("close_pane_button.close_this_pane")}
/>
)}
const items: (MenuItem<keyof CommandMappings>)[] = [
{ title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" },
{ title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" },
{ kind: "separator" },
{ title: t("mobile_detail_menu.note_revisions"), command: "showRevisions", uiIcon: "bx bx-history" },
{ kind: "separator" },
helpUrl && {
title: t("help-button.title"),
uiIcon: "bx bx-help-circle",
handler: () => openInAppHelpFromUrl(helpUrl)
},
{ kind: "separator" },
subContexts.length < 2 && { title: t("create_pane_button.create_new_split"), command: "openNewNoteSplit", uiIcon: "bx bx-dock-right" },
!isMainContext && { title: t("close_pane_button.close_this_pane"), command: "closeThisNoteSplit", uiIcon: "bx bx-x" }
].filter(i => !!i) as MenuItem<keyof CommandMappings>[];
const lastItem = items.at(-1);
if (lastItem && "kind" in lastItem && lastItem.kind === "separator") {
items.pop();
}
contextMenu.show<keyof CommandMappings>({
x: e.pageX,
y: e.pageY,
items,
selectMenuItemHandler: async ({ command }) => {
if (command === "insertChildNote") {
note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined);
} else if (command === "delete") {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) {
throw new Error("Cannot get note path to delete.");
}
const branchId = await tree.getBranchIdFromUrl(notePath);
if (!branchId) {
throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath }));
}
if (await branches.deleteNotes([branchId]) && parentComponent) {
parentComponent.triggerCommand("setActiveScreen", { screen: "tree" });
}
} else if (command && parentComponent) {
parentComponent.triggerCommand(command, { ntxId });
}
},
forcePositionOnMobile: true
});
}}
/>
{createPortal((
<>
<BacklinksModal note={note} modalShown={backlinksModalShown} setModalShown={setBacklinksModalShown} />
<NotePathsModal note={note} modalShown={notePathsModalShown} notePath={noteContext?.notePath} sortedNotePaths={sortedNotePaths} setModalShown={setNotePathsModalShown} />
</>
), document.body)}
</div>
);
}
function BacklinksModal({ note, modalShown, setModalShown }: { note: FNote | null | undefined, modalShown: boolean, setModalShown: (shown: boolean) => void }) {
return (
<Modal
className="backlinks-modal tn-backlinks-widget"
size="md"
title={t("mobile_detail_menu.backlinks")}
show={modalShown}
onHidden={() => setModalShown(false)}
>
<ul className="backlinks-items">
{note && <BacklinksList note={note} />}
</ul>
</Modal>
);
}
function NotePathsModal({ note, modalShown, notePath, sortedNotePaths, setModalShown }: { note: FNote | null | undefined, modalShown: boolean, sortedNotePaths: NotePathRecord[] | undefined, notePath: string | null | undefined, setModalShown: (shown: boolean) => void }) {
return (
<Modal
className="note-paths-modal"
size="md"
title={t("note_paths.title")}
show={modalShown}
onHidden={() => setModalShown(false)}
>
{note && (
<NotePathsWidget
sortedNotePaths={sortedNotePaths}
currentNotePath={notePath}
/>
)}
</Modal>
);
}

View File

@ -6,10 +6,7 @@ import { useContext, useRef } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime";
import FNote from "../../entities/fnote";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { openInAppHelpFromUrl } from "../../services/utils";
import { ViewTypeOptions } from "../collections/interface";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
import FormTextBox from "../react/FormTextBox";
@ -19,7 +16,7 @@ import { ParentComponent } from "../react/react_utils";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config";
import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab";
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
export const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: "bx bxs-grid",
list: "bx bx-list-ul",
calendar: "bx bx-calendar",

View File

@ -117,3 +117,35 @@ body.experimental-feature-new-layout {
}
}
}
body.mobile .modal.icon-switcher {
.modal-dialog {
left: 0;
right: 0;
margin: unset;
transform: unset;
max-width: 100%;
height: 100%;
}
.modal-body {
padding: 0;
display: flex;
flex-direction: column;
> .filter-row {
padding: 0.25em var(--bs-modal-padding) 0.5em var(--bs-modal-padding);
border-bottom: 1px solid var(--main-border-color);
}
}
.icon-list {
margin: auto;
flex-grow: 1;
height: 100%;
span {
padding: 12px;
}
}
}

View File

@ -4,18 +4,24 @@ import { IconRegistry } from "@triliumnext/commons";
import { Dropdown as BootstrapDropdown } from "bootstrap";
import clsx from "clsx";
import { t } from "i18next";
import { CSSProperties, RefObject } from "preact";
import { CSSProperties } from "preact";
import { createPortal } from "preact/compat";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type React from "react";
import { CellComponentProps, Grid } from "react-window";
import FNote from "../entities/fnote";
import attributes from "../services/attributes";
import server from "../services/server";
import { isDesktop, isMobile } from "../services/utils";
import ActionButton from "./react/ActionButton";
import Dropdown from "./react/Dropdown";
import { FormDropdownDivider, FormListItem } from "./react/FormList";
import FormTextBox from "./react/FormTextBox";
import { useNoteContext, useNoteLabel, useStaticTooltip } from "./react/hooks";
import { useNoteContext, useNoteLabel, useStaticTooltip, useWindowSize } from "./react/hooks";
import Modal from "./react/Modal";
const ICON_SIZE = isMobile() ? 56 : 48;
interface IconToCountCache {
iconClassToCountMap: Record<string, number>;
@ -36,6 +42,10 @@ export default function NoteIcon() {
setIcon(note?.getIcon());
}, [ note, iconClass, workspaceIconClass ]);
if (isMobile()) {
return <MobileNoteIconSwitcher note={note} icon={icon} />;
}
return (
<Dropdown
className="note-icon-widget"
@ -47,16 +57,47 @@ export default function NoteIcon() {
hideToggleArrow
disabled={viewScope?.viewMode !== "default"}
>
{ note && <NoteIconList note={note} dropdownRef={dropdownRef} /> }
{ note && <NoteIconList note={note} onHide={() => dropdownRef?.current?.hide()} columnCount={12} /> }
</Dropdown>
);
}
function NoteIconList({ note, dropdownRef }: {
note: FNote,
dropdownRef: RefObject<BootstrapDropdown>;
function MobileNoteIconSwitcher({ note, icon }: {
note: FNote | null | undefined;
icon: string | null | undefined;
}) {
const [ modalShown, setModalShown ] = useState(false);
const { windowWidth } = useWindowSize();
return (note &&
<div className="note-icon-widget">
<ActionButton
className="note-icon"
icon={icon ?? "bx bx-empty"}
text={t("note_icon.change_note_icon")}
onClick={() => setModalShown(true)}
/>
{createPortal((
<Modal
title={t("note_icon.change_note_icon")}
size="xl"
show={modalShown} onHidden={() => setModalShown(false)}
className="icon-switcher note-icon-widget"
scrollable
>
<NoteIconList note={note} onHide={() => setModalShown(false)} columnCount={Math.max(1, Math.floor(windowWidth / ICON_SIZE))} />
</Modal>
), document.body)}
</div>
);
}
function NoteIconList({ note, onHide, columnCount }: {
note: FNote;
onHide: () => void;
columnCount: number;
}) {
const searchBoxRef = useRef<HTMLInputElement>(null);
const iconListRef = useRef<HTMLDivElement>(null);
const [ search, setSearch ] = useState<string>();
const [ filterByPrefix, setFilterByPrefix ] = useState<string | null>(null);
@ -72,53 +113,22 @@ function NoteIconList({ note, dropdownRef }: {
return (
<>
<div class="filter-row">
<span>{t("note_icon.search")}</span>
<FormTextBox
inputRef={searchBoxRef}
type="text"
name="icon-search"
placeholder={ filterByPrefix
? t("note_icon.search_placeholder_filtered", {
number: filteredIcons.length ?? 0,
name: glob.iconRegistry.sources.find(s => s.prefix === filterByPrefix)?.name ?? ""
})
: t("note_icon.search_placeholder", { number: filteredIcons.length ?? 0, count: glob.iconRegistry.sources.length })}
currentValue={search} onChange={setSearch}
autoFocus
/>
{getIconLabels(note).length > 0 && (
<div style={{ textAlign: "center" }}>
<ActionButton
icon="bx bx-reset"
text={t("note_icon.reset-default")}
onClick={() => {
if (!note) return;
for (const label of getIconLabels(note)) {
attributes.removeAttributeById(note.noteId, label.attributeId);
}
dropdownRef?.current?.hide();
}}
/>
</div>
)}
{glob.iconRegistry.sources.length > 0 && <Dropdown
buttonClassName="bx bx-filter-alt"
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
iconAction
title={t("note_icon.filter")}
>
<IconFilterContent filterByPrefix={filterByPrefix} setFilterByPrefix={setFilterByPrefix} />
</Dropdown>}
</div>
<FilterRow
note={note}
filterByPrefix={filterByPrefix}
search={search}
setSearch={setSearch}
setFilterByPrefix={setFilterByPrefix}
filteredIcons={filteredIcons}
onHide={onHide}
/>
<div
class="icon-list"
ref={iconListRef}
style={{
width: (columnCount * ICON_SIZE + 10),
}}
onClick={(e) => {
// Make sure we are not clicking on something else than a button.
const clickedTarget = e.target as HTMLElement;
@ -129,18 +139,19 @@ function NoteIconList({ note, dropdownRef }: {
const attributeToSet = note.hasOwnedLabel("workspace") ? "workspaceIconClass" : "iconClass";
attributes.setLabel(note.noteId, attributeToSet, iconClass);
}
dropdownRef?.current?.hide();
onHide();
}}
>
{filteredIcons.length ? (
<Grid
columnCount={12}
columnWidth={48}
rowCount={Math.ceil(filteredIcons.length / 12)}
rowHeight={48}
columnCount={columnCount}
columnWidth={ICON_SIZE}
rowCount={Math.ceil(filteredIcons.length / columnCount)}
rowHeight={ICON_SIZE}
cellComponent={IconItemCell}
cellProps={{
filteredIcons
filteredIcons,
columnCount
}}
/>
) : (
@ -151,12 +162,97 @@ function NoteIconList({ note, dropdownRef }: {
);
}
function IconItemCell({ rowIndex, columnIndex, style, filteredIcons }: CellComponentProps<{
function FilterRow({ note, filterByPrefix, search, setSearch, setFilterByPrefix, filteredIcons, onHide }: {
note: FNote;
filterByPrefix: string | null;
search: string | undefined;
setSearch: (value: string | undefined) => void;
setFilterByPrefix: (value: string | null) => void;
filteredIcons: IconWithName[];
}>): React.JSX.Element {
const iconIndex = rowIndex * 12 + columnIndex;
onHide: () => void;
}) {
const searchBoxRef = useRef<HTMLInputElement>(null);
const hasCustomIcon = getIconLabels(note).length > 0;
function resetToDefaultIcon() {
if (!note) return;
for (const label of getIconLabels(note)) {
attributes.removeAttributeById(note.noteId, label.attributeId);
}
onHide();
}
return (
<div class="filter-row">
<span>{t("note_icon.search")}</span>
<FormTextBox
inputRef={searchBoxRef}
type="text"
name="icon-search"
placeholder={ filterByPrefix
? t("note_icon.search_placeholder_filtered", {
number: filteredIcons.length ?? 0,
name: glob.iconRegistry.sources.find(s => s.prefix === filterByPrefix)?.name ?? ""
})
: t("note_icon.search_placeholder", { number: filteredIcons.length ?? 0, count: glob.iconRegistry.sources.length })}
currentValue={search} onChange={setSearch}
autoFocus
/>
{isDesktop()
? <>
{hasCustomIcon && (
<div style={{ textAlign: "center" }}>
<ActionButton
icon="bx bx-reset"
text={t("note_icon.reset-default")}
onClick={resetToDefaultIcon}
/>
</div>
)}
{<Dropdown
buttonClassName="bx bx-filter-alt"
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
iconAction
title={t("note_icon.filter")}
>
<IconFilterContent filterByPrefix={filterByPrefix} setFilterByPrefix={setFilterByPrefix} />
</Dropdown>}
</> : (
<Dropdown
buttonClassName="bx bx-dots-vertical-rounded"
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
iconAction
dropdownContainerClassName="mobile-bottom-menu"
>
{hasCustomIcon && <>
<FormListItem
icon="bx bx-reset"
onClick={resetToDefaultIcon}
disabled={!hasCustomIcon}
>{t("note_icon.reset-default")}</FormListItem>
<FormDropdownDivider />
</>}
<IconFilterContent filterByPrefix={filterByPrefix} setFilterByPrefix={setFilterByPrefix} />
</Dropdown>
)}
</div>
);
}
function IconItemCell({ rowIndex, columnIndex, style, filteredIcons, columnCount }: CellComponentProps<{
filteredIcons: IconWithName[];
columnCount: number;
}>) {
const iconIndex = rowIndex * columnCount + columnIndex;
const iconData = filteredIcons[iconIndex] as IconWithName | undefined;
if (!iconData) return <></>;
if (!iconData) return <></> as React.ReactElement;
const { id, terms, iconPack } = iconData;
return (
@ -166,7 +262,7 @@ function IconItemCell({ rowIndex, columnIndex, style, filteredIcons }: CellCompo
title={t("note_icon.icon_tooltip", { name: terms?.[0] ?? id, iconPack })}
style={style as CSSProperties}
/>
);
) as React.ReactElement;
}
function IconFilterContent({ filterByPrefix, setFilterByPrefix }: {
@ -183,7 +279,7 @@ function IconFilterContent({ filterByPrefix, setFilterByPrefix }: {
checked={filterByPrefix === "bx"}
onClick={() => setFilterByPrefix("bx")}
>{t("note_icon.filter-default")}</FormListItem>
<FormDropdownDivider />
{glob.iconRegistry.sources.length > 1 && <FormDropdownDivider />}
{glob.iconRegistry.sources.map(({ prefix, name, icon }) => (
prefix !== "bx" && <FormListItem

View File

@ -109,4 +109,29 @@ body.experimental-feature-new-layout {
--input-focus-color: initial;
}
}
&.mobile .title-row {
.icon-action:not(.note-icon) {
--icon-button-size: 45px;
--icon-button-icon-ratio: 0.5;
flex-shrink: 0;
}
.note-actions {
width: auto;
}
.note-badges {
margin-inline: 0.5em;
flex-shrink: 0;
}
.note-icon-widget {
margin-inline: 0.5em;
.note-icon {
--icon-button-size: 24px;
}
}
}
}

View File

@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { CommandNames } from "../../components/app_context";
import { useStaticTooltip } from "./hooks";
import keyboard_actions from "../../services/keyboard_actions";
import { HTMLAttributes } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
export interface ActionButtonProps extends Pick<HTMLAttributes<HTMLButtonElement>, "onClick" | "onAuxClick" | "onContextMenu"> {
import { CommandNames } from "../../components/app_context";
import keyboard_actions from "../../services/keyboard_actions";
import { useStaticTooltip } from "./hooks";
export interface ActionButtonProps extends Pick<HTMLAttributes<HTMLButtonElement>, "onClick" | "onAuxClick" | "onContextMenu" | "style"> {
text: string;
titlePosition?: "top" | "right" | "bottom" | "left";
icon: string;

View File

@ -17,21 +17,34 @@
.collapsible-body {
height: 0;
overflow: hidden;
&.fully-expanded {
overflow: visible;
}
}
.collapsible-inner-body {
padding-top: 0.5em;
opacity: 0;
}
&.expanded {
.collapsible-title .arrow {
transform: rotate(90deg);
}
.collapsible-inner-body {
opacity: 1;
}
}
&.with-transition {
.collapsible-body {
transition: height 250ms ease-in;
}
.collapsible-inner-body {
transition: opacity 250ms ease-in;
}
}
}

View File

@ -27,6 +27,7 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
const { height } = useElementSize(innerRef) ?? {};
const contentId = useUniqueName();
const [ transitionEnabled, setTransitionEnabled ] = useState(false);
const [ fullyExpanded, setFullyExpanded ] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
@ -35,6 +36,21 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (expanded) {
if (transitionEnabled) {
const timeout = setTimeout(() => {
setFullyExpanded(true);
}, 250);
return () => clearTimeout(timeout);
} else {
setFullyExpanded(true);
}
} else {
setFullyExpanded(false);
}
}, [expanded, transitionEnabled])
return (
<div className={clsx("collapsible", className, {
expanded,
@ -53,7 +69,7 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
<div
id={contentId}
ref={bodyRef}
className="collapsible-body"
className={clsx("collapsible-body", {"fully-expanded": fullyExpanded})}
style={{ height: expanded ? height : "0" }}
aria-hidden={!expanded}
>

View File

@ -3,6 +3,7 @@ import { ComponentChildren, HTMLAttributes } from "preact";
import { CSSProperties, HTMLProps } from "preact/compat";
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import { isMobile } from "../../services/utils";
import { useTooltip, useUniqueName } from "./hooks";
type DataAttributes = {
@ -32,9 +33,10 @@ export interface DropdownProps extends Pick<HTMLProps<HTMLDivElement>, "id" | "c
dropdownRef?: MutableRef<BootstrapDropdown | null>;
titlePosition?: "top" | "right" | "bottom" | "left";
titleOptions?: Partial<Tooltip.Options>;
mobileBackdrop?: boolean;
}
export default function Dropdown({ id, className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, dropdownContainerRef: externalContainerRef, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, noDropdownListStyle, forceShown, onShown: externalOnShown, onHidden: externalOnHidden, dropdownOptions, buttonProps, dropdownRef, titlePosition, titleOptions }: DropdownProps) {
export default function Dropdown({ id, className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, dropdownContainerRef: externalContainerRef, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, noDropdownListStyle, forceShown, onShown: externalOnShown, onHidden: externalOnHidden, dropdownOptions, buttonProps, dropdownRef, titlePosition, titleOptions, mobileBackdrop }: DropdownProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const dropdownContainerRef = useRef<HTMLUListElement | null>(null);
@ -74,12 +76,18 @@ export default function Dropdown({ id, className, buttonClassName, isStatic, chi
setShown(true);
externalOnShown?.();
hideTooltip();
}, [ hideTooltip ]);
if (mobileBackdrop && isMobile()) {
document.getElementById("context-menu-cover")?.classList.add("show", "global-menu-cover");
}
}, [ hideTooltip, mobileBackdrop ]);
const onHidden = useCallback(() => {
setShown(false);
externalOnHidden?.();
}, []);
if (mobileBackdrop && isMobile()) {
document.getElementById("context-menu-cover")?.classList.remove("show", "global-menu-cover");
}
}, [ mobileBackdrop ]);
useEffect(() => {
if (!containerRef.current) return;

View File

@ -3,8 +3,9 @@ import { useEffect, useRef } from "preact/hooks";
import ActionButton, { ActionButtonProps } from "./ActionButton";
import Button, { ButtonProps } from "./Button";
import { FormListItem, FormListItemOpts } from "./FormList";
interface FormFileUploadProps {
export interface FormFileUploadProps {
name?: string;
onChange: (files: FileList | null) => void;
multiple?: boolean;
@ -75,3 +76,25 @@ export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<Ac
</>
);
}
/**
* Similar to {@link FormFileUploadButton}, but uses an {@link FormListItem} instead of a normal {@link Button}.
* @param param the change listener for the file upload and the properties for the button.
*/
export function FormFileUploadFormListItem({ onChange, children, ...buttonProps }: Omit<FormListItemOpts, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<FormListItem
{...buttonProps}
onClick={() => inputRef.current?.click()}
>{children}</FormListItem>
<FormFileUpload
inputRef={inputRef}
hidden
onChange={onChange}
/>
</>
);
}

View File

@ -27,3 +27,13 @@
}
}
}
.form-list-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
.form-list-col {
flex-grow: 1;
}
}

View File

@ -78,7 +78,7 @@ export interface FormListBadge {
text: string;
}
interface FormListItemOpts {
export interface FormListItemOpts {
children: ComponentChildren;
icon?: string;
value?: string;

View File

@ -1,17 +1,17 @@
import clsx from "clsx";
import { useEffect, useRef, useMemo } from "preact/hooks";
import { t } from "../../services/i18n";
import { ComponentChildren } from "preact";
import type { CSSProperties, RefObject } from "preact/compat";
import { openDialog } from "../../services/dialog";
import { Modal as BootstrapModal } from "bootstrap";
import clsx from "clsx";
import { ComponentChildren, CSSProperties, RefObject } from "preact";
import { memo } from "preact/compat";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { openDialog } from "../../services/dialog";
import { t } from "../../services/i18n";
import { useSyncedRef } from "./hooks";
interface CustomTitleBarButton {
title: string;
iconClassName: string;
onClick: () => void;
onClick: (e: MouseEvent) => void;
}
export interface ModalProps {
@ -80,7 +80,7 @@ export interface ModalProps {
noFocus?: boolean;
}
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) {
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) {
const modalRef = useSyncedRef<HTMLDivElement>(externalModalRef);
const modalInstanceRef = useRef<BootstrapModal>();
const elementToFocus = useRef<Element | null>();
@ -116,7 +116,7 @@ export default function Modal({ children, className, size, title, customTitleBar
focus: !noFocus
}).then(($widget) => {
modalInstanceRef.current = BootstrapModal.getOrCreateInstance($widget[0]);
})
});
} else {
modalInstanceRef.current?.hide();
}
@ -159,13 +159,12 @@ export default function Modal({ children, className, size, title, customTitleBar
{titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
<button type="button"
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick}>
</button>
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick} />
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")} />
</div>

View File

@ -1,6 +1,6 @@
import { ConvertToAttachmentResponse } from "@triliumnext/commons";
import { Dropdown as BootstrapDropdown } from "bootstrap";
import { RefObject } from "preact";
import { ComponentChildren, RefObject } from "preact";
import { useContext, useEffect, useRef } from "preact/hooks";
import appContext, { CommandNames } from "../../components/app_context";
@ -63,7 +63,7 @@ function RevisionsButton({ note }: { note: FNote }) {
type ItemToFocus = "basic-properties";
function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
export function NoteContextMenu({ note, noteContext, extraItems }: { note: FNote, noteContext?: NoteContext, extraItems?: ComponentChildren; }) {
const dropdownRef = useRef<BootstrapDropdown>(null);
const parentComponent = useContext(ParentComponent);
const noteType = useNoteProperty(note, "type") ?? "";
@ -99,12 +99,15 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
dropdownRef={dropdownRef}
buttonClassName={ isNewLayout ? "bx bx-dots-horizontal-rounded" : "bx bx-dots-vertical-rounded" }
className="note-actions"
dropdownContainerClassName="mobile-bottom-menu"
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
iconAction
onHidden={() => itemToFocusRef.current = null }
mobileBackdrop
>
{extraItems}
{isReadOnly && <>
<CommandItem icon="bx bx-pencil" text={t("read-only-info.edit-note")}
@ -277,7 +280,7 @@ function DevelopmentActions({ note, noteContext }: { note: FNote, noteContext?:
);
}
function CommandItem({ icon, text, title, command, disabled }: { icon: string, text: string, title?: string, command: CommandNames | (() => void), disabled?: boolean, destructive?: boolean }) {
export function CommandItem({ icon, text, title, command, disabled }: { icon: string, text: string, title?: string, command: CommandNames | (() => void), disabled?: boolean, destructive?: boolean }) {
return <FormListItem
icon={icon}
title={title}

View File

@ -0,0 +1,4 @@
body.mobile .note-actions-custom:not(:empty) {
margin-bottom: calc(var(--bs-dropdown-divider-margin-y) * 2);
border-top: 1px solid var(--bs-dropdown-divider-bg);
}

View File

@ -1,3 +1,5 @@
import "./NoteActionsCustom.css";
import { NoteType } from "@triliumnext/commons";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
@ -7,11 +9,12 @@ import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import { openInAppHelpFromUrl } from "../../services/utils";
import { isMobile, openInAppHelpFromUrl } from "../../services/utils";
import { ViewTypeOptions } from "../collections/interface";
import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions";
import ActionButton from "../react/ActionButton";
import { FormFileUploadActionButton } from "../react/FormFileUpload";
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
import { FormFileUploadActionButton, FormFileUploadFormListItem, FormFileUploadProps } from "../react/FormFileUpload";
import { FormListItem } from "../react/FormList";
import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { buildUploadNewFileRevisionListener } from "./FilePropertiesTab";
@ -32,6 +35,8 @@ interface NoteActionsCustomInnerProps extends NoteActionsCustomProps {
viewType: ViewTypeOptions | null | undefined;
}
const cachedIsMobile = isMobile();
/**
* Part of {@link NoteActions} on the new layout, but are rendered with a slight spacing
* from the rest of the note items and the buttons differ based on the note type.
@ -115,7 +120,7 @@ function UploadNewRevisionButton({ note, onChange }: NoteActionsCustomInnerProps
onChange: (files: FileList | null) => void;
}) {
return (
<FormFileUploadActionButton
<NoteActionWithFileUpload
icon="bx bx-folder-open"
text={t("image_properties.upload_new_revision")}
disabled={!note.isContentAvailable()}
@ -125,8 +130,8 @@ function UploadNewRevisionButton({ note, onChange }: NoteActionsCustomInnerProps
}
function OpenExternallyButton({ note, noteMime }: NoteActionsCustomInnerProps) {
return (
<ActionButton
return (!cachedIsMobile &&
<NoteAction
icon="bx bx-link-external"
text={t("file_properties.open")}
disabled={note.isProtected}
@ -137,7 +142,7 @@ function OpenExternallyButton({ note, noteMime }: NoteActionsCustomInnerProps) {
function DownloadFileButton({ note, parentComponent, ntxId }: NoteActionsCustomInnerProps) {
return (
<ActionButton
<NoteAction
icon="bx bx-download"
text={t("file_properties.download")}
disabled={!note.isContentAvailable()}
@ -149,7 +154,7 @@ function DownloadFileButton({ note, parentComponent, ntxId }: NoteActionsCustomI
//#region Floating buttons
function CopyReferenceToClipboardButton({ ntxId, noteType, parentComponent }: NoteActionsCustomInnerProps) {
return (["mermaid", "canvas", "mindMap", "image"].includes(noteType) &&
<ActionButton
<NoteAction
text={t("image_properties.copy_reference_to_clipboard")}
icon="bx bx-copy"
onClick={() => parentComponent?.triggerEvent("copyImageReferenceToClipboard", { ntxId })}
@ -161,7 +166,7 @@ function RefreshButton({ note, noteType, isDefaultViewMode, parentComponent, not
const isEnabled = (note.noteId === "_backendLog" || noteType === "render") && isDefaultViewMode;
return (isEnabled &&
<ActionButton
<NoteAction
text={t("backend_log.refresh")}
icon="bx bx-refresh"
onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })}
@ -170,11 +175,11 @@ function RefreshButton({ note, noteType, isDefaultViewMode, parentComponent, not
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const isShown = note.type === "mermaid" && note.isContentAvailable() && isDefaultViewMode;
const isShown = note.type === "mermaid" && !cachedIsMobile && note.isContentAvailable() && isDefaultViewMode;
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
return isShown && <ActionButton
return isShown && <NoteAction
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
@ -188,7 +193,7 @@ export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCus
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <ActionButton
return isEnabled && <NoteAction
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
onClick={() => setReadOnly(!isReadOnly)}
@ -197,7 +202,7 @@ export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCus
function RunActiveNoteButton({ noteMime }: NoteActionsCustomInnerProps) {
const isEnabled = noteMime.startsWith("application/javascript") || noteMime === "text/x-sqlite;schema=trilium";
return isEnabled && <ActionButton
return isEnabled && <NoteAction
icon="bx bx-play"
text={t("code_buttons.execute_button_title")}
triggerCommand="runActiveNote"
@ -218,7 +223,7 @@ function SaveToNoteButton({ note, noteMime }: NoteActionsCustomInnerProps) {
}
});
return isEnabled && <ActionButton
return isEnabled && <NoteAction
icon="bx bx-save"
text={t("code_buttons.save_to_note_button_title")}
onClick={buildSaveSqlToNoteHandler(note)}
@ -227,7 +232,7 @@ function SaveToNoteButton({ note, noteMime }: NoteActionsCustomInnerProps) {
function OpenTriliumApiDocsButton({ noteMime }: NoteActionsCustomInnerProps) {
const isEnabled = noteMime.startsWith("application/javascript;env=");
return isEnabled && <ActionButton
return isEnabled && <NoteAction
icon="bx bx-help-circle"
text={t("code_buttons.trilium_api_docs_button_title")}
onClick={() => openInAppHelpFromUrl(noteMime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")}
@ -239,7 +244,7 @@ function InAppHelpButton({ note }: NoteActionsCustomInnerProps) {
const isEnabled = !!helpUrl;
return isEnabled && (
<ActionButton
<NoteAction
icon="bx bx-help-circle"
text={t("help-button.title")}
onClick={() => helpUrl && openInAppHelpFromUrl(helpUrl)}
@ -249,7 +254,7 @@ function InAppHelpButton({ note }: NoteActionsCustomInnerProps) {
function AddChildButton({ parentComponent, noteType, ntxId, isReadOnly }: NoteActionsCustomInnerProps) {
if (noteType === "relationMap") {
return <ActionButton
return <NoteAction
icon="bx bx-folder-plus"
text={t("relation_map_buttons.create_child_note_title")}
onClick={() => parentComponent.triggerEvent("relationMapCreateChildNote", { ntxId })}
@ -258,3 +263,19 @@ function AddChildButton({ parentComponent, noteType, ntxId, isReadOnly }: NoteAc
}
}
//#endregion
function NoteAction({ text, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand"> & {
onClick?: ((e: MouseEvent) => void) | undefined;
}) {
return (cachedIsMobile
? <FormListItem {...props}>{text}</FormListItem>
: <ActionButton text={text} {...props} />
);
}
function NoteActionWithFileUpload({ text, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand"> & Pick<FormFileUploadProps, "onChange">) {
return (cachedIsMobile
? <FormFileUploadFormListItem {...props}>{text}</FormFileUploadFormListItem>
: <FormFileUploadActionButton text={text} {...props} />
);
}

View File

@ -0,0 +1,63 @@
body.experimental-feature-new-layout .note-paths-widget {
.note-path-intro {
color: var(--muted-text-color);
}
.note-path-list {
margin: 12px 0;
padding: 0;
list-style: none;
/* Note path card */
li {
--border-radius: 6px;
position: relative;
background: var(--card-background-color);
padding: 8px 20px 8px 25px;
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
& + li {
margin-top: 2px;
}
/* Current path arrow */
&.path-current::before {
position: absolute;
display: flex;
justify-content: flex-end;
align-items: center;
content: "\ee8f";
top: 0;
left: 0;
width: 20px;
bottom: 0;
font-family: "boxicons";
font-size: .75em;
color: var(--menu-item-icon-color);
}
}
/* Note path segment */
a {
margin-inline: 2px;
padding-inline: 2px;
color: currentColor;
font-weight: normal;
text-decoration: none;
/* The last segment of the note path */
&.basename {
color: var(--muted-text-color);
}
}
}
}

View File

@ -1,15 +1,16 @@
import "./NotePathsTab.css";
import clsx from "clsx";
import { useEffect, useMemo, useState } from "preact/hooks";
import FNote, { NotePathRecord } from "../../entities/fnote";
import { t } from "../../services/i18n";
import { NOTE_PATH_TITLE_SEPARATOR } from "../../services/tree";
import { useTriliumEvent } from "../react/hooks";
import LinkButton from "../react/LinkButton";
import NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";
import { TabContext } from "./ribbon-interface";
import LinkButton from "../react/LinkButton";
import clsx from "clsx";
export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabContext) {
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
@ -112,9 +113,9 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
<li class={classes}>
{joinElements(fullNotePaths.map((notePath, index, arr) => (
<NoteLink key={notePath}
className={clsx({"basename": (index === arr.length - 1)})}
notePath={notePath}
noPreview />
className={clsx({"basename": (index === arr.length - 1)})}
notePath={notePath}
noPreview />
)), NOTE_PATH_TITLE_SEPARATOR)}
{icons.map(({ icon, title }) => (

View File

@ -1,20 +1,22 @@
import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import { useMemo } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n";
import search from "../../../services/search";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { isElectron } from "../../../services/utils";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
import { useMemo } from "preact/hooks";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import search from "../../../services/search";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormSelect from "../../react/FormSelect";
import { isElectron } from "../../../services/utils";
import FormText from "../../react/FormText";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
export default function OtherSettings() {
return (
@ -31,7 +33,7 @@ export default function OtherSettings() {
<ShareSettings />
<NetworkSettings />
</>
)
);
}
function SearchEngineSettings() {
@ -82,7 +84,7 @@ function SearchEngineSettings() {
/>
</FormGroup>
</OptionsSection>
)
);
}
function TrayOptionsSettings() {
@ -97,7 +99,7 @@ function TrayOptionsSettings() {
onChange={trayEnabled => setDisableTray(!trayEnabled)}
/>
</OptionsSection>
)
);
}
function NoteErasureTimeout() {
@ -105,13 +107,13 @@ function NoteErasureTimeout() {
<OptionsSection title={t("note_erasure_timeout.note_erasure_timeout_title")}>
<FormText>{t("note_erasure_timeout.note_erasure_description")}</FormText>
<FormGroup name="erase-entities-after" label={t("note_erasure_timeout.erase_notes_after")}>
<TimeSelector
name="erase-entities-after"
<TimeSelector
name="erase-entities-after"
optionValueId="eraseEntitiesAfterTimeInSeconds" optionTimeScaleId="eraseEntitiesAfterTimeScale"
/>
</FormGroup>
<FormText>{t("note_erasure_timeout.manual_erasing_description")}</FormText>
<Button
text={t("note_erasure_timeout.erase_deleted_notes_now")}
onClick={() => {
@ -121,7 +123,7 @@ function NoteErasureTimeout() {
}}
/>
</OptionsSection>
)
);
}
function AttachmentErasureTimeout() {
@ -145,7 +147,7 @@ function AttachmentErasureTimeout() {
}}
/>
</OptionsSection>
)
);
}
function RevisionSnapshotInterval() {
@ -165,7 +167,7 @@ function RevisionSnapshotInterval() {
/>
</FormGroup>
</OptionsSection>
)
);
}
function RevisionSnapshotLimit() {
@ -176,7 +178,7 @@ function RevisionSnapshotLimit() {
<FormText>{t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</FormText>
<FormGroup name="revision-snapshot-number-limit">
<FormTextBoxWithUnit
<FormTextBoxWithUnit
type="number" min={-1}
currentValue={revisionSnapshotNumberLimit}
unit={t("revisions_snapshot_limit.snapshot_number_limit_unit")}
@ -197,7 +199,7 @@ function RevisionSnapshotLimit() {
}}
/>
</OptionsSection>
)
);
}
function HtmlImportTags() {
@ -236,7 +238,7 @@ function HtmlImportTags() {
onClick={() => setAllowedHtmlTags(SANITIZER_DEFAULT_ALLOWED_TAGS)}
/>
</OptionsSection>
)
);
}
function ShareSettings() {
@ -246,8 +248,8 @@ function ShareSettings() {
return (
<OptionsSection title={t("share.title")}>
<FormGroup name="redirectBareDomain" description={t("share.redirect_bare_domain_description")}>
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
currentValue={redirectBareDomain}
onChange={async value => {
if (value) {
@ -264,17 +266,17 @@ function ShareSettings() {
}
setRedirectBareDomain(value);
}}
/>
/>
</FormGroup>
<FormGroup name="showLoginInShareTheme" description={t("share.show_login_link_description")}>
<FormCheckbox
<FormCheckbox
label={t("share.show_login_link")}
currentValue={showLogInShareTheme} onChange={setShowLogInShareTheme}
/>
</FormGroup>
</OptionsSection>
)
);
}
function NetworkSettings() {
@ -288,5 +290,5 @@ function NetworkSettings() {
currentValue={checkForUpdates} onChange={setCheckForUpdates}
/>
</OptionsSection>
)
}
);
}

View File

@ -2,6 +2,7 @@ import { normalizeMimeTypeForCKEditor, type OptionNames } from "@triliumnext/com
import { Themes } from "@triliumnext/highlightjs";
import type { CSSProperties } from "preact/compat";
import { useEffect, useMemo, useState } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import { isExperimentalFeatureEnabled } from "../../../services/experimental_features";

View File

@ -17,17 +17,17 @@ test("Can drag tabs around", async ({ page, context }) => {
await app.addNewTab();
await app.addNewTab();
let tab = app.getTab(0);
let tab = await app.getTab(0);
// Drag the first tab at the end
await tab.dragTo(app.getTab(2), { targetPosition: { x: 50, y: 0 } });
await tab.dragTo(await app.getTab(2), { targetPosition: { x: 50, y: 0 } });
tab = app.getTab(2);
tab = await app.getTab(2);
await expect(tab).toContainText(NOTE_TITLE);
// Drag the tab to the left
await tab.dragTo(app.getTab(0), { targetPosition: { x: 50, y: 0 } });
await expect(app.getTab(0)).toContainText(NOTE_TITLE);
await tab.dragTo(await app.getTab(0), { targetPosition: { x: 50, y: 0 } });
await expect(await app.getTab(0)).toContainText(NOTE_TITLE);
});
test("Can drag tab to new window", async ({ page, context }) => {
@ -36,7 +36,7 @@ test("Can drag tab to new window", async ({ page, context }) => {
await app.closeAllTabs();
await app.clickNoteOnNoteTreeByTitle(NOTE_TITLE);
const tab = app.getTab(0);
const tab = await app.getTab(0);
await expect(tab).toContainText(NOTE_TITLE);
const popupPromise = page.waitForEvent("popup");
@ -75,14 +75,14 @@ test("Tabs are restored in right order", async ({ page, context }) => {
await expect(app.getActiveTab()).toContainText("Mermaid");
// Select the mid one.
await app.getTab(1).click();
await (await app.getTab(1)).click();
await expect(app.noteTreeActiveNote).toContainText("Text notes");
// Refresh the page and check the order.
await app.goto( { preserveTabs: true });
await expect(app.getTab(0)).toContainText("Code notes");
await expect(app.getTab(1)).toContainText("Text notes");
await expect(app.getTab(2)).toContainText("Mermaid");
await expect(await app.getTab(0)).toContainText("Code notes");
await expect(await app.getTab(1)).toContainText("Text notes");
await expect(await app.getTab(2)).toContainText("Mermaid");
// Check the note tree has the right active node.
await expect(app.noteTreeActiveNote).toContainText("Text notes");
@ -118,7 +118,7 @@ test("Search works when dismissing a tab", async ({ page, context }) => {
await app.addNewTab();
await app.goToNoteInNewTab("Sample mindmap");
await app.getTab(0).click();
await (await app.getTab(0)).click();
await app.openAndClickNoteActionMenu("Search in note");
await expect(app.findAndReplaceWidget.first()).toBeVisible();
});

View File

@ -26,6 +26,7 @@ export default class App {
readonly currentNoteSplitTitle: Locator;
readonly currentNoteSplitContent: Locator;
readonly sidebar: Locator;
private isMobile: boolean = false;
constructor(page: Page, context: BrowserContext) {
this.page = page;
@ -43,6 +44,8 @@ export default class App {
}
async goto({ url, isMobile, preserveTabs }: GotoOpts = {}) {
this.isMobile = !!isMobile;
await this.context.addCookies([
{
url: BASE_URL,
@ -83,7 +86,12 @@ export default class App {
await this.page.locator(".launcher-button.bx-cog").click();
}
getTab(tabIndex: number) {
async getTab(tabIndex: number) {
if (this.isMobile) {
await this.launcherBar.locator(".mobile-tab-switcher").click();
return this.page.locator(".modal.tab-bar-modal .tab-card").nth(tabIndex);
}
return this.tabBar.locator(".note-tab-wrapper").nth(tabIndex);
}
@ -97,7 +105,8 @@ export default class App {
async closeAllTabs() {
await this.triggerCommand("closeAllTabs");
// Page in Playwright is not updated somehow, need to click on the tab to make sure it's rendered
await this.getTab(0).click();
const tab = await this.getTab(0);
await tab.click();
}
/**

View File

@ -337,6 +337,155 @@ paths:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/revisions:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns all revisions for a note identified by its ID
operationId: getNoteRevisions
responses:
"200":
description: list of revisions
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/attachments:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns all attachments for a note identified by its ID
operationId: getNoteAttachments
responses:
"200":
description: list of attachments
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/Attachment"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/undelete:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
post:
description: Restore a deleted note. The note must be deleted and must have at least one undeleted parent.
operationId: undeleteNote
responses:
"200":
description: note restored successfully
content:
application/json; charset=utf-8:
schema:
type: object
properties:
success:
type: boolean
example: true
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/history:
get:
description: Returns recent changes including note creations, modifications, and deletions
operationId: getNoteHistory
parameters:
- name: ancestorNoteId
in: query
required: false
description: Limit changes to a subtree identified by this note ID. Defaults to "root" (all notes).
schema:
$ref: "#/components/schemas/EntityId"
responses:
"200":
description: list of recent changes
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/RecentChange"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns a revision identified by its ID
operationId: getRevisionById
responses:
"200":
description: revision response
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}/content:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns revision content identified by its ID
operationId: getRevisionContent
responses:
"200":
description: revision content response
content:
text/html:
schema:
type: string
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/branches:
post:
description: >
@ -1186,3 +1335,93 @@ components:
type: string
description: Human readable error, potentially with more details,
example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI
Revision:
type: object
description: Revision represents a snapshot of note's title and content at some point in the past.
properties:
revisionId:
$ref: "#/components/schemas/EntityId"
readOnly: true
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
type:
type: string
enum:
[
text,
code,
render,
file,
image,
search,
relationMap,
book,
noteMap,
mermaid,
webView,
shortcut,
doc,
contentWidget,
launcher,
]
mime:
type: string
isProtected:
type: boolean
readOnly: true
title:
type: string
blobId:
type: string
description: ID of the blob object which effectively serves as a content hash
dateLastEdited:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
dateCreated:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
utcDateLastEdited:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateCreated:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateModified:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
contentLength:
type: integer
format: int32
readOnly: true
RecentChange:
type: object
description: Represents a recent change event (creation, modification, or deletion).
properties:
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
title:
type: string
description: Title at the time of the change (may be "[protected]" for protected notes)
current_title:
type: string
description: Current title of the note (may be "[protected]" for protected notes)
current_isDeleted:
type: boolean
description: Whether the note is currently deleted
current_deleteId:
type: string
description: Delete ID if the note is deleted
current_isProtected:
type: boolean
description: Whether the note is protected
utcDate:
$ref: "#/components/schemas/UtcDateTime"
description: UTC timestamp of the change
date:
$ref: "#/components/schemas/LocalDateTime"
description: Local timestamp of the change
canBeUndeleted:
type: boolean
description: Whether the note can be undeleted (only present for deleted notes)

View File

@ -0,0 +1,82 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let createdAttachmentId: string;
describe("etapi/get-note-attachments", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create an attachment for the note
const response = await supertest(app)
.post(`/etapi/attachments`)
.auth(USER, token, { "type": "basic" })
.send({
"ownerId": createdNoteId,
"role": "file",
"mime": "text/plain",
"title": "test-attachment.txt",
"content": "test content",
"position": 10
});
createdAttachmentId = response.body.attachmentId;
expect(createdAttachmentId).toBeTruthy();
});
it("gets attachments for a note", async () => {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/attachments`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const attachment = response.body[0];
expect(attachment).toHaveProperty("attachmentId", createdAttachmentId);
expect(attachment).toHaveProperty("ownerId", createdNoteId);
expect(attachment).toHaveProperty("role", "file");
expect(attachment).toHaveProperty("mime", "text/plain");
expect(attachment).toHaveProperty("title", "test-attachment.txt");
expect(attachment).toHaveProperty("position", 10);
expect(attachment).toHaveProperty("blobId");
expect(attachment).toHaveProperty("dateModified");
expect(attachment).toHaveProperty("utcDateModified");
expect(attachment).toHaveProperty("contentLength");
});
it("returns empty array for note with no attachments", async () => {
// Create a new note without any attachments
const newNoteId = await createNote(app, token, "Note without attachments");
const response = await supertest(app)
.get(`/etapi/notes/${newNoteId}/attachments`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.get("/etapi/notes/nonexistentnote/attachments")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
});

View File

@ -0,0 +1,77 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/get-note-revisions", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create a revision by updating the note content
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content for revision")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets revisions for a note", async () => {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const revision = response.body[0];
expect(revision).toHaveProperty("revisionId");
expect(revision).toHaveProperty("noteId", createdNoteId);
expect(revision).toHaveProperty("type");
expect(revision).toHaveProperty("mime");
expect(revision).toHaveProperty("title");
expect(revision).toHaveProperty("isProtected");
expect(revision).toHaveProperty("blobId");
expect(revision).toHaveProperty("utcDateCreated");
});
it("returns empty array for note with no revisions", async () => {
// Create a new note without any revisions
const newNoteId = await createNote(app, token, "Brand new content");
const response = await supertest(app)
.get(`/etapi/notes/${newNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// New notes may or may not have revisions depending on settings
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.get("/etapi/notes/nonexistentnote/revisions")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
});

View File

@ -0,0 +1,71 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/get-revision", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial content");
// Update content to create a revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision metadata by ID", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("revisionId", revisionId);
expect(response.body).toHaveProperty("noteId", createdNoteId);
expect(response.body).toHaveProperty("type", "text");
expect(response.body).toHaveProperty("mime", "text/html");
expect(response.body).toHaveProperty("title", "Hello");
expect(response.body).toHaveProperty("isProtected", false);
expect(response.body).toHaveProperty("blobId");
expect(response.body).toHaveProperty("utcDateCreated");
expect(response.body).toHaveProperty("utcDateModified");
});
it("returns 404 for non-existent revision", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@ -0,0 +1,94 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/note-history", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
// Create a note to ensure there's some history
createdNoteId = await createNote(app, token, "History test content");
// Create a revision to ensure history has entries
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets recent changes history", async () => {
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
// Check that history entries have expected properties
const entry = response.body[0];
expect(entry).toHaveProperty("noteId");
expect(entry).toHaveProperty("title");
expect(entry).toHaveProperty("utcDate");
expect(entry).toHaveProperty("date");
expect(entry).toHaveProperty("current_isDeleted");
expect(entry).toHaveProperty("current_isProtected");
});
it("filters history by ancestor note", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=root")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// All results should be descendants of root (which is everything)
});
it("returns empty array for non-existent ancestor", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=nonexistentancestor")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// Should be empty since no notes are descendants of a non-existent note
expect(response.body.length).toBe(0);
});
it("includes canBeUndeleted for deleted notes", async () => {
// Create and delete a note
const noteToDeleteId = await createNote(app, token, "Note to delete for history test");
await supertest(app)
.delete(`/etapi/notes/${noteToDeleteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Check history - deleted note should appear with canBeUndeleted property
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
const deletedEntry = response.body.find(
(entry: any) => entry.noteId === noteToDeleteId && entry.current_isDeleted === true
);
// Deleted entries should have canBeUndeleted property
if (deletedEntry) {
expect(deletedEntry).toHaveProperty("canBeUndeleted");
}
});
});

View File

@ -0,0 +1,64 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/revision-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial revision content");
// Update content to ensure we have content in the revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Content after first update")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision content", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}/content`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toBeTruthy();
});
it("returns 404 for non-existent revision content", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision/content")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@ -0,0 +1,103 @@
import { Application } from "express";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomInt } from "crypto";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/undelete-note", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("undeletes a deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note to delete and restore",
"type": "text",
"content": "Content to restore"
})
.expect(201);
// Verify note exists
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
// Delete the note
await supertest(app)
.delete(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Verify note is deleted (should return 404)
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(404);
// Undelete the note
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("success", true);
// Verify note is restored
const restoredResponse = await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(restoredResponse.body.title).toStrictEqual("Note to delete and restore");
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.post("/etapi/notes/nonexistentnote/undelete")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
it("returns 400 when trying to undelete a non-deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note not deleted",
"type": "text",
"content": "Content"
})
.expect(201);
// Try to undelete a note that isn't deleted
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(400);
expect(response.body.code).toStrictEqual("NOTE_NOT_DELETED");
});
});

View File

@ -354,6 +354,13 @@
<td>Hides the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;for
that particular note.</td>
</tr>
<tr>
<td><code>subtreeHidden</code>
</td>
<td>Hides all child notes of this note from the tree, displaying a badge with
the count of hidden children. Children remain accessible via search or
direct links.</td>
</tr>
<tr>
<td><code>printLandscape</code>
</td>
@ -372,6 +379,11 @@
<a
class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>.</td>
</tr>
<tr>
<td><code>map:*</code>
</td>
<td>Defines specific options for the&nbsp;<a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>.</td>
</tr>
<tr>
<td><code>calendar:*</code>
</td>

View File

@ -26,10 +26,35 @@
<h2>Automatically download and install the latest nightly</h2>
<p>This is pretty useful if you are a beta tester that wants to periodically
update their version:</p>
<p>On Ubuntu:</p><pre><code class="language-text-x-trilium-auto">#!/usr/bin/env bash
<h2>On Ubuntu (Bash)</h2><pre><code class="language-text-x-sh">#!/usr/bin/env bash
name=TriliumNotes-linux-x64-nightly.deb
rm -f $name*
wget https://github.com/TriliumNext/Trilium/releases/download/nightly/$name
sudo apt-get install ./$name
rm $name</code></pre>
rm $name</code></pre>
<h2>On Windows (PowerShell)</h2><pre><code class="language-application-x-powershell">if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
$arch = "arm64";
} else {
$arch = "x64";
}
$exeUrl = "https://github.com/TriliumNext/Trilium/releases/download/nightly/TriliumNotes-main-windows-$($arch).exe";
Write-Host "Downloading $($exeUrl)"
# Generate a unique path in the temp dir
$guid = [guid]::NewGuid().ToString()
$destination = Join-Path -Path $env:TEMP -ChildPath "$guid.exe"
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $exeUrl -OutFile $destination
$process = Start-Process -FilePath $destination
} catch {
Write-Error "An error occurred: $_"
} finally {
# Clean up
if (Test-Path $destination) {
Remove-Item -Path $destination -Force
}
}</code></pre>

View File

@ -24,8 +24,7 @@
<li>click on an image or link and save it through context menu</li>
<li>save whole page from the popup or context menu</li>
<li>save screenshot (with crop tool) from either popup or context menu</li>
<li
>create short text note from popup</li>
<li>create short text note from popup</li>
</ul>
<h2>Location of clippings</h2>
<p>Trilium will save these clippings as a new child note under a "clipper
@ -40,10 +39,8 @@
<p>Keyboard shortcuts are available for most functions:</p>
<ul>
<li>Save selected text: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>S</kbd>)</li>
<li
>Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>S</kbd>)</li>
<li
>Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>E</kbd>)</li>
<li>Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>S</kbd>)</li>
<li>Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>E</kbd>)</li>
</ul>
<p>To set custom shortcuts, follow the directions for your browser.</p>
<ul>
@ -72,20 +69,19 @@
<li><a href="https://github.com/TriliumNext/Trilium/releases">GitHub Releases</a> by
looking for releases starting with <em>Web Clipper.</em>
</li>
<li>Artifacts in GitHub Actions, by looking for the <a href="https://github.com/TriliumNext/Trilium/actions/workflows/web-clipper.yml"><em>Deploy web clipper extension </em>workflow</a>.
<li>Artifacts in GitHub Actions, by looking for the <a href="https://github.com/TriliumNext/Trilium/actions/workflows/web-clipper.yml"><em>Deploy web clipper extension</em> workflow</a>.
Once a workflow run is selected, the ZIP files are available in the <em>Artifacts</em> section,
under the name <code spellcheck="false">web-clipper-extension</code>.</li>
</ul>
<h3>For Chrome</h3>
<ol>
<li>Download <code spellcheck="false">trilium-web-clipper-[x.y.z]-chrome.zip</code>.</li>
<li
>Extract the archive.</li>
<li>In Chrome, navigate to <code spellcheck="false">chrome://extensions/</code>
</li>
<li>Toggle <em>Developer Mode</em> in top-right of the page.</li>
<li>Press the <em>Load unpacked</em> button near the header.</li>
<li>Point to the extracted directory from step (2).</li>
<li>Extract the archive.</li>
<li>In Chrome, navigate to <code spellcheck="false">chrome://extensions/</code>
</li>
<li>Toggle <em>Developer Mode</em> in top-right of the page.</li>
<li>Press the <em>Load unpacked</em> button near the header.</li>
<li>Point to the extracted directory from step (2).</li>
</ol>
<h3>For Firefox</h3>
<aside class="admonition warning">
@ -100,12 +96,10 @@
<li>Navigate to <code spellcheck="false">about:addons</code>.</li>
<li>Select <em>Extensions</em> in the left-side navigation.</li>
<li>Press the <em>Gear</em> icon on the right of the <em>Manage Your Extensions</em> title.</li>
<li
>Select <em>Install Add-on From File…</em>
</li>
<li>Point it to <code spellcheck="false">trilium-web-clipper-[x.y.z]-firefox.zip</code>.</li>
<li
>Press the <em>Add</em> button to confirm.</li>
<li>Select <em>Install Add-on From File…</em>
</li>
<li>Point it to <code spellcheck="false">trilium-web-clipper-[x.y.z]-firefox.zip</code>.</li>
<li>Press the <em>Add</em> button to confirm.</li>
</ol>
<h2>Credits</h2>
<p>Some parts of the code are based on the <a href="https://github.com/laurent22/joplin/tree/master/Clipper">Joplin Notes browser extension</a>.</p>

View File

@ -259,7 +259,8 @@
"ai-llm-title": "AI/LLM",
"inbox-title": "收件箱",
"command-palette": "打开命令面板",
"zen-mode": "禅模式"
"zen-mode": "禅模式",
"tab-switcher-title": "标签切换器"
},
"notes": {
"new-note": "新建笔记",

View File

@ -257,7 +257,8 @@
"localization": "Sprache & Region",
"inbox-title": "Posteingang",
"zen-mode": "Zen-Modus",
"command-palette": "Befehlspalette öffnen"
"command-palette": "Befehlspalette öffnen",
"tab-switcher-title": "Tabauswahl"
},
"notes": {
"new-note": "Neue Notiz",

View File

@ -356,7 +356,8 @@
"visible-launchers-title": "Visible Launchers",
"user-guide": "User Guide",
"localization": "Language & Region",
"inbox-title": "Inbox"
"inbox-title": "Inbox",
"tab-switcher-title": "Tab Switcher"
},
"notes": {
"new-note": "New note",

View File

@ -259,7 +259,8 @@
"inbox-title": "Bandeja",
"jump-to-note-title": "Saltar a...",
"command-palette": "Abrir paleta de comandos",
"zen-mode": "Modo Zen"
"zen-mode": "Modo Zen",
"tab-switcher-title": "Conmutador de pestañas"
},
"notes": {
"new-note": "Nueva nota",

View File

@ -0,0 +1,443 @@
{
"keyboard_actions": {
"back-in-note-history": "Téigh go dtí an nóta roimhe seo sa stair",
"forward-in-note-history": "Téigh go dtí an chéad nóta eile sa stair",
"open-jump-to-note-dialog": "Oscail an dialóg \"Léim go dtí an nóta\"",
"open-command-palette": "Oscail pailéad orduithe",
"scroll-to-active-note": "Scrollaigh crann na nótaí go dtí an nóta gníomhach",
"quick-search": "Gníomhachtaigh an barra cuardaigh thapa",
"search-in-subtree": "Cuardaigh nótaí i bhfo-chrann an nóta ghníomhaigh",
"expand-subtree": "Leathnaigh fo-chrann an nóta reatha",
"collapse-tree": "Laghdaíonn sé an crann nótaí iomlán",
"collapse-subtree": "Laghdaíonn sé fo-chrann an nóta reatha",
"sort-child-notes": "Sórtáil nótaí leanaí",
"creating-and-moving-notes": "Nótaí a chruthú agus a bhogadh",
"create-note-after": "Cruthaigh nóta i ndiaidh nóta gníomhach",
"create-note-into": "Cruthaigh nóta mar leanbh den nóta gníomhach",
"create-note-into-inbox": "Cruthaigh nóta sa bhosca isteach (más sainithe) nó nóta lae",
"delete-note": "Scrios nóta",
"move-note-up": "Bog nóta suas",
"move-note-down": "Bog nóta síos",
"move-note-up-in-hierarchy": "Bog nóta suas san ordlathas",
"move-note-down-in-hierarchy": "Bog nóta síos san ordlathas",
"edit-note-title": "Léim ón gcrann go dtí sonraí an nóta agus cuir an teideal in eagar",
"edit-branch-prefix": "Taispeáin an dialóg \"Cuir réimír na brainse in eagar\"",
"clone-notes-to": "Clónáil nótaí roghnaithe",
"move-notes-to": "Bog nótaí roghnaithe",
"note-clipboard": "Gearrthaisce nótaí",
"copy-notes-to-clipboard": "Cóipeáil na nótaí roghnaithe chuig an ghearrthaisce",
"paste-notes-from-clipboard": "Greamaigh nótaí ón ghearrthaisce isteach sa nóta gníomhach",
"cut-notes-to-clipboard": "Gearr nótaí roghnaithe chuig an ghearrthaisce",
"select-all-notes-in-parent": "Roghnaigh na nótaí go léir ón leibhéal nóta reatha",
"add-note-above-to-the-selection": "Cuir nóta thuas leis an rogha",
"add-note-below-to-selection": "Cuir nóta leis an rogha thíos",
"duplicate-subtree": "Fo-chrann dúblach",
"tabs-and-windows": "Cluaisíní agus Fuinneoga",
"open-new-tab": "Oscail cluaisín nua",
"close-active-tab": "Dún an cluaisín gníomhach",
"reopen-last-tab": "Athoscail an cluaisín deireanach a dúnadh",
"activate-next-tab": "Gníomhachtaigh an cluaisín ar dheis",
"activate-previous-tab": "Gníomhachtaigh an cluaisín ar chlé",
"open-new-window": "Oscail fuinneog nua folamh",
"toggle-tray": "Taispeáin/folaigh an feidhmchlár ón tráidire córais",
"first-tab": "Gníomhachtaigh an chéad chluaisín sa liosta",
"second-tab": "Gníomhachtaigh an dara cluaisín sa liosta",
"third-tab": "Gníomhachtaigh an tríú cluaisín sa liosta",
"fourth-tab": "Gníomhachtaigh an ceathrú cluaisín sa liosta",
"fifth-tab": "Gníomhachtaigh an cúigiú cluaisín sa liosta",
"sixth-tab": "Gníomhachtaigh an séú cluaisín sa liosta",
"seventh-tab": "Gníomhachtaigh an seachtú cluaisín sa liosta",
"eight-tab": "Gníomhachtaigh an t-ochtú cluaisín sa liosta",
"ninth-tab": "Gníomhachtaigh an naoú cluaisín sa liosta",
"last-tab": "Gníomhachtaigh an cluaisín deireanach sa liosta",
"dialogs": "Dialóga",
"show-note-source": "Taispeáin an dialóg \"Foinse an Nóta\"",
"show-options": "Oscail an leathanach \"Roghanna\"",
"show-revisions": "Taispeáin an dialóg \"Athbhreithnithe Nóta\"",
"show-recent-changes": "Taispeáin an dialóg \"Athruithe Le Déanaí\"",
"show-sql-console": "Oscail an leathanach \"Consól SQL\"",
"show-backend-log": "Oscail an leathanach \"Log Backend\"",
"show-help": "Oscail an Treoir Úsáideora ionsuite",
"show-cheatsheet": "Taispeáin modal le hoibríochtaí coitianta méarchláir",
"text-note-operations": "Oibríochtaí nótaí téacs",
"add-link-to-text": "Oscail an dialóg chun nasc a chur leis an téacs",
"follow-link-under-cursor": "Lean an nasc ina bhfuil an caret curtha",
"insert-date-and-time-to-text": "Cuir an dáta agus an t-am reatha isteach sa téacs",
"paste-markdown-into-text": "Greamaigh Markdown ón ghearrthaisce isteach i nóta téacs",
"cut-into-note": "Gearrann sé an rogha ón nóta reatha agus cruthaíonn sé fo-nóta leis an téacs roghnaithe",
"add-include-note-to-text": "Osclaíonn an dialóg chun nóta a chur san áireamh",
"edit-readonly-note": "Cuir nóta inléite amháin in eagar",
"attributes-labels-and-relations": "Tréithe (lipéid & caidrimh)",
"add-new-label": "Cruthaigh lipéad nua",
"create-new-relation": "Cruthaigh caidreamh nua",
"ribbon-tabs": "Cluaisíní ribín",
"toggle-basic-properties": "Airíonna Bunúsacha a Athrú",
"toggle-file-properties": "Airíonna Comhaid a Athrú",
"toggle-image-properties": "Airíonna Íomhá a Athrú",
"toggle-owned-attributes": "Tréithe faoi Úinéireacht a Athrú",
"toggle-inherited-attributes": "Tréithe Oidhreachta a Athrú",
"toggle-promoted-attributes": "Tréithe Curtha Chun Cinn a Athrú",
"toggle-link-map": "Léarscáil Nasc a Athsholáthar",
"toggle-note-info": "Eolas Nóta a Athrú",
"toggle-note-paths": "Cosáin Nótaí a Athrú",
"toggle-similar-notes": "Nótaí Cosúla a Athsholáthar",
"other": "Eile",
"toggle-right-pane": "Athraigh taispeáint an phainéil dheis, lena n-áirítear Clár Ábhair agus Buaicphointí",
"print-active-note": "Priontáil nóta gníomhach",
"open-note-externally": "Oscail nóta mar chomhad leis an bhfeidhmchlár réamhshocraithe",
"render-active-note": "Rindreáil (ath-rindreáil) nóta gníomhach",
"run-active-note": "Rith nóta cóid JavaScript gníomhach (frontend/backend)",
"toggle-note-hoisting": "Scoránaigh ardú nóta an nóta ghníomhaigh",
"unhoist": "Dí-ardaigh ó áit ar bith",
"reload-frontend-app": "Athlódáil an tosaigh",
"open-dev-tools": "Uirlisí forbróra oscailte",
"find-in-text": "Painéal cuardaigh a scoránaigh",
"toggle-left-note-tree-panel": "Scoránaigh an painéal ar chlé (crann nótaí)",
"toggle-full-screen": "Scoraigh an scáileán iomlán",
"zoom-out": "Zúmáil Amach",
"zoom-in": "Zúmáil Isteach",
"note-navigation": "Nascleanúint nótaí",
"reset-zoom-level": "Athshocraigh leibhéal súmála",
"copy-without-formatting": "Cóipeáil téacs roghnaithe gan fhormáidiú",
"force-save-revision": "Cruthú/sábháil nóta nua den nóta gníomhach i bhfeidhm",
"toggle-book-properties": "Airíonna an Bhailiúcháin a Athrú",
"toggle-classic-editor-toolbar": "Athraigh an cluaisín Formáidithe don eagarthóir leis an mbarra uirlisí socraithe",
"export-as-pdf": "Easpórtáil an nóta reatha mar PDF",
"toggle-zen-mode": "Cumasaíonn/díchumasaíonn sé an mód zen (comhad úsáideora íosta le haghaidh eagarthóireacht níos dírithe)"
},
"keyboard_action_names": {
"back-in-note-history": "Ar ais i Stair na Nótaí",
"forward-in-note-history": "Ar Aghaidh i Stair na Nótaí",
"jump-to-note": "Léim go...",
"command-palette": "Pailéad Ordú",
"scroll-to-active-note": "Scrollaigh go dtí an Nóta Gníomhach",
"quick-search": "Cuardach Tapa",
"search-in-subtree": "Cuardaigh i bhFo-chrann",
"expand-subtree": "Leathnaigh an Fo-Chrann",
"collapse-tree": "Laghdaigh Crann",
"collapse-subtree": "Laghdaigh Fo-chrann",
"sort-child-notes": "Sórtáil Nótaí Leanaí",
"create-note-after": "Cruthaigh Nóta Tar éis",
"create-note-into": "Cruthaigh Nóta Isteach",
"create-note-into-inbox": "Cruthaigh Nóta sa Bhosca Isteach",
"delete-notes": "Scrios Nótaí",
"move-note-up": "Bog Nóta Suas",
"move-note-down": "Bog Nóta Síos",
"move-note-up-in-hierarchy": "Bog Nóta Suas san Ordlathas",
"move-note-down-in-hierarchy": "Bog Nóta Síos san Ordlathas",
"edit-note-title": "Cuir Teideal an Nóta in Eagar",
"edit-branch-prefix": "Cuir Réimír na Brainse in Eagar",
"clone-notes-to": "Nótaí Clónála Chuig",
"move-notes-to": "Bog Nótaí Chuig",
"copy-notes-to-clipboard": "Cóipeáil Nótaí chuig an nGearrthaisce",
"paste-notes-from-clipboard": "Greamaigh Nótaí ón nGearrthaisce",
"cut-notes-to-clipboard": "Gearr Nótaí chuig an nGearrthaisce",
"select-all-notes-in-parent": "Roghnaigh Gach Nóta sa Tuismitheoir",
"add-note-above-to-selection": "Cuir Nóta Thuas leis an Roghnú",
"add-note-below-to-selection": "Cuir Nóta Thíos leis an Roghnú",
"duplicate-subtree": "Fo-chrann Dúblach",
"open-new-tab": "Oscail Cluaisín Nua",
"close-active-tab": "Dún an Cluaisín Gníomhach",
"reopen-last-tab": "Athoscail an Cluaisín Deireanach",
"activate-next-tab": "Gníomhachtaigh an Chluaisín Eile",
"activate-previous-tab": "Gníomhachtaigh an Cluaisín Roimhe Seo",
"open-new-window": "Oscail Fuinneog Nua",
"toggle-system-tray-icon": "Deilbhín Tráidire an Chórais a Athrú",
"toggle-zen-mode": "Mód Zen a athrú",
"switch-to-first-tab": "Athraigh go dtí an Chéad Chluaisín",
"switch-to-second-tab": "Athraigh go dtí an Dara Cluaisín",
"switch-to-third-tab": "Athraigh go dtí an Tríú Cluaisín",
"switch-to-fourth-tab": "Athraigh go dtí an Ceathrú Cluaisín",
"switch-to-fifth-tab": "Athraigh go dtí an Cúigiú Cluaisín",
"switch-to-sixth-tab": "Athraigh go dtí an Séú Cluaisín",
"switch-to-seventh-tab": "Athraigh go dtí an Seachtú Cluaisín",
"switch-to-eighth-tab": "Athraigh go dtí an tOchtú Cluaisín",
"switch-to-ninth-tab": "Athraigh go dtí an Naoú Cluaisín",
"switch-to-last-tab": "Athraigh go dtí an Cluaisín Deireanach",
"show-note-source": "Taispeáin Foinse an Nóta",
"show-options": "Taispeáin Roghanna",
"show-revisions": "Taispeáin Athbhreithnithe",
"show-recent-changes": "Taispeáin Athruithe Le Déanaí",
"show-sql-console": "Taispeáin Consól SQL",
"show-backend-log": "Taispeáin Logáil an Chúil",
"show-help": "Taispeáin Cabhair",
"show-cheatsheet": "Taispeáin Bileog Leideanna",
"add-link-to-text": "Cuir Nasc leis an Téacs",
"follow-link-under-cursor": "Lean an Nasc Faoin gCúrsóir",
"insert-date-and-time-to-text": "Cuir Dáta agus Am isteach sa Téacs",
"paste-markdown-into-text": "Greamaigh Markdown isteach sa Téacs",
"cut-into-note": "Gearr isteach i Nóta",
"add-include-note-to-text": "Cuir Nóta le Téacs",
"edit-read-only-note": "Cuir Nóta Léite Amháin in Eagar",
"add-new-label": "Cuir Lipéad Nua leis",
"add-new-relation": "Cuir Gaol Nua leis",
"toggle-ribbon-tab-classic-editor": "Eagarthóir Clasaiceach Cluaisín Ribín a Athrú",
"toggle-ribbon-tab-basic-properties": "Airíonna Bunúsacha an Chluaisín Ribín a Athrú",
"toggle-ribbon-tab-book-properties": "Airíonna Leabhar an Chluaisín Ribín a Athrú",
"toggle-ribbon-tab-file-properties": "Airíonna Comhaid Tab Ribín a Athrú",
"toggle-ribbon-tab-image-properties": "Airíonna Íomhá an Chluaisín Ribín a Athrú",
"toggle-ribbon-tab-owned-attributes": "Tréithe atá faoi úinéireacht ag an gcluaisín ribín",
"toggle-ribbon-tab-inherited-attributes": "Tréithe Oidhreachta Cluaisín Ribín a Scor",
"toggle-ribbon-tab-promoted-attributes": "Tréithe Curtha Chun Cinn sa Chluaisín Ribín",
"toggle-ribbon-tab-note-map": "Léarscáil Nótaí Tab Ribín a Athrú",
"toggle-ribbon-tab-note-info": "Eolas Nóta Cluaisín Ribín a Athrú",
"toggle-ribbon-tab-note-paths": "Cosáin Nóta Cluaisín Ribín a Athrú",
"toggle-ribbon-tab-similar-notes": "Nótaí Cosúla a Athraigh an Cluaisín Ribín",
"toggle-right-pane": "Scoránaigh an Phána Ar Dheis",
"print-active-note": "Priontáil Nóta Gníomhach",
"export-active-note-as-pdf": "Easpórtáil Nóta Gníomhach mar PDF",
"open-note-externally": "Oscail Nóta go Seachtrach",
"render-active-note": "Rindreáil Nóta Gníomhach",
"run-active-note": "Rith Nóta Gníomhach",
"toggle-note-hoisting": "Ardú Nótaí a Athrú",
"unhoist-note": "Nóta Dí-Ardaithe",
"reload-frontend-app": "Athlódáil an Aip Tosaigh",
"open-developer-tools": "Oscail Uirlisí Forbróra",
"find-in-text": "Aimsigh sa Téacs",
"toggle-left-pane": "Scoránaigh an Phána Chlé",
"toggle-full-screen": "Athraigh an Scáileán Lán",
"zoom-out": "Zúmáil Amach",
"zoom-in": "Zúmáil Isteach",
"reset-zoom-level": "Athshocraigh Leibhéal Súmála",
"copy-without-formatting": "Cóipeáil Gan Formáidiú",
"force-save-revision": "Athbhreithniú Sábháilte Fórsála"
},
"login": {
"title": "Logáil Isteach",
"heading": "Logáil Isteach Trilium",
"incorrect-totp": "Tá an TOTP mícheart. Déan iarracht arís.",
"incorrect-password": "Tá an focal faire mícheart. Déan iarracht arís.",
"password": "Pasfhocal",
"remember-me": "Cuimhnigh orm",
"button": "Logáil Isteach",
"sign_in_with_sso": "Sínigh isteach le {{ ssoIssuerName }}"
},
"set_password": {
"title": "Socraigh Pasfhocal",
"heading": "Socraigh pasfhocal",
"description": "Sula dtosaíonn tú ag úsáid Trilium ón ngréasán, ní mór duit pasfhocal a shocrú ar dtús. Úsáidfidh tú an pasfhocal seo ansin chun logáil isteach.",
"password": "Pasfhocal",
"password-confirmation": "Deimhniú pasfhocail",
"button": "Socraigh pasfhocal"
},
"setup": {
"heading": "Socrú Trilium Notes",
"new-document": "Is úsáideoir nua mé, agus ba mhaith liom doiciméad Trilium nua a chruthú do mo nótaí",
"sync-from-desktop": "Tá cás deisce agam cheana féin, agus ba mhaith liom sioncrónú a shocrú leis",
"sync-from-server": "Tá sampla freastalaí agam cheana féin, agus ba mhaith liom sioncrónú a shocrú leis",
"next": "Ar Aghaidh",
"init-in-progress": "Túsú doiciméad ar siúl",
"redirecting": "Atreorófar chuig an bhfeidhmchlár thú go luath.",
"title": "Socrú"
},
"setup_sync-from-desktop": {
"heading": "Sioncrónaigh ón Deasc",
"description": "Ní mór an socrú seo a thionscnamh ón deasc:",
"step1": "Oscail sampla de Trilium Notes ar do dheasc.",
"step2": "Ón Roghchlár Trilium, cliceáil Roghanna.",
"step3": "Cliceáil ar an gcatagóir Sioncrónaigh.",
"step4": "Athraigh seoladh an fhreastalaí go: {{- host}} agus cliceáil Sábháil.",
"step5": "Cliceáil an cnaipe \"Tástáil sioncrónaithe\" chun a fhíorú go bhfuil an nasc rathúil.",
"step6": "Nuair a bheidh na céimeanna seo críochnaithe agat, cliceáil {{- link}}.",
"step6-here": "anseo"
},
"setup_sync-from-server": {
"heading": "Sioncrónaigh ón bhFreastalaí",
"instructions": "Cuir isteach seoladh agus dintiúir freastalaí Trilium thíos le do thoil. Íoslódálfaidh sé seo an doiciméad Trilium iomlán ón bhfreastalaí agus socróidh sé sioncrónú leis. Ag brath ar mhéid an doiciméid agus luas do nasc, d'fhéadfadh sé seo tamall a thógáil.",
"server-host": "Seoladh freastalaí Trilium",
"server-host-placeholder": "https://<ainm óstach>:<port>",
"proxy-server": "Freastalaí seachfhreastalaí (roghnach)",
"proxy-server-placeholder": "https://<ainm óstach>:<port>",
"note": "Nóta:",
"proxy-instruction": "Má fhágann tú an socrú seachfhreastalaí bán, úsáidfear seachfhreastalaí an chórais (baineann sé leis an bhfeidhmchlár deisce amháin)",
"password": "Pasfhocal",
"password-placeholder": "Pasfhocal",
"back": "Ar ais",
"finish-setup": "Críochnaigh an socrú"
},
"setup_sync-in-progress": {
"heading": "Sioncrónú ar siúl",
"successful": "Tá an sioncrónú socraithe i gceart. Tógfaidh sé tamall go mbeidh an sioncrónú tosaigh críochnaithe. Nuair a bheidh sé déanta, atreorófar chuig an leathanach logála isteach thú.",
"outstanding-items": "Míreanna sioncrónaithe gan réiteach:",
"outstanding-items-default": "N/B"
},
"share_404": {
"title": "Níor aimsíodh",
"heading": "Níor aimsíodh"
},
"share_page": {
"parent": "tuismitheoir:",
"clipped-from": "Gearradh an nóta seo ó {{- url}} ar dtús",
"child-notes": "Nótaí leanaí:",
"no-content": "Níl aon ábhar sa nóta seo."
},
"weekdays": {
"monday": "Dé Luain",
"tuesday": "Dé Máirt",
"wednesday": "Dé Céadaoin",
"thursday": "Déardaoin",
"friday": "Dé hAoine",
"saturday": "Dé Sathairn",
"sunday": "Dé Domhnaigh"
},
"weekdayNumber": "Seachtain {weekNumber}",
"months": {
"january": "Eanáir",
"february": "Feabhra",
"march": "Márta",
"april": "Aibreán",
"may": "Bealtaine",
"june": "Meitheamh",
"july": "Iúil",
"august": "Lúnasa",
"september": "Meán Fómhair",
"october": "Deireadh Fómhair",
"november": "Samhain",
"december": "Nollaig"
},
"quarterNumber": "Ráithe {quarterNumber}",
"special_notes": {
"search_prefix": "Cuardaigh:"
},
"test_sync": {
"not-configured": "Níl an freastalaí sioncrónaithe cumraithe. Cumraigh an sioncrónú ar dtús.",
"successful": "Tá croitheadh láimhe an fhreastalaí sioncrónaithe tar éis a bheith rathúil, tá tús curtha leis an sioncrónú."
},
"hidden-subtree": {
"root-title": "Nótaí Folaithe",
"search-history-title": "Stair Chuardaigh",
"note-map-title": "Léarscáil Nótaí",
"sql-console-history-title": "Stair Chonsól SQL",
"shared-notes-title": "Nótaí Comhroinnte",
"bulk-action-title": "Gníomh Bulc",
"backend-log-title": "Logáil Cúil",
"user-hidden-title": "Úsáideoir i bhfolach",
"launch-bar-templates-title": "Teimpléid Barra Seolta",
"base-abstract-launcher-title": "Tosaitheoir Bunúsach Teibí",
"command-launcher-title": "Tosaitheoir Ordú",
"note-launcher-title": "Tosaitheoir Nótaí",
"script-launcher-title": "Tosaitheoir Scripte",
"built-in-widget-title": "Giuirléid Tógtha isteach",
"spacer-title": "Spásaire",
"custom-widget-title": "Giuirléid Saincheaptha",
"launch-bar-title": "Barra Lainseáil",
"available-launchers-title": "Lainseálaithe atá ar Fáil",
"go-to-previous-note-title": "Téigh go dtí an Nóta Roimhe Seo",
"go-to-next-note-title": "Téigh go dtí an chéad Nóta Eile",
"new-note-title": "Nóta Nua",
"search-notes-title": "Cuardaigh Nótaí",
"jump-to-note-title": "Léim go...",
"calendar-title": "Féilire",
"recent-changes-title": "Athruithe Le Déanaí",
"bookmarks-title": "Leabharmharcanna",
"command-palette": "Oscail an Pailéad Ordaithe",
"zen-mode": "Mód Zen",
"open-today-journal-note-title": "Oscail Nóta Dialainne an Lae Inniu",
"quick-search-title": "Cuardach Tapa",
"protected-session-title": "Seisiún faoi Chosaint",
"sync-status-title": "Stádas Sioncrónaithe",
"settings-title": "Socruithe",
"llm-chat-title": "Comhrá le Nótaí",
"options-title": "Roghanna",
"appearance-title": "Dealramh",
"shortcuts-title": "Aicearraí",
"text-notes": "Nótaí Téacs",
"code-notes-title": "Nótaí Cód",
"images-title": "Íomhánna",
"spellcheck-title": "Seiceáil litrithe",
"password-title": "Pasfhocal",
"multi-factor-authentication-title": "MFA",
"etapi-title": "ETAPI",
"backup-title": "Cúltaca",
"sync-title": "Sioncrónaigh",
"ai-llm-title": "AI/LLM",
"other": "Eile",
"advanced-title": "Ardleibhéil",
"visible-launchers-title": "Lainseálaithe Infheicthe",
"user-guide": "Treoir Úsáideora",
"localization": "Teanga & Réigiún",
"inbox-title": "Bosca isteach",
"tab-switcher-title": "Athraitheoir Cluaisíní"
},
"notes": {
"new-note": "Nóta nua",
"duplicate-note-suffix": "(dúpáil)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Níl an comhad loga cúil '{{ fileName }}' ann (go fóill).",
"reading-log-failed": "Theip ar an gcomhad loga cúil '{{ fileName }}' a léamh."
},
"content_renderer": {
"note-cannot-be-displayed": "Ní féidir an cineál nóta seo a thaispeáint."
},
"pdf": {
"export_filter": "Doiciméad PDF (*.pdf)",
"unable-to-export-message": "Níorbh fhéidir an nóta reatha a easpórtáil mar PDF.",
"unable-to-export-title": "Ní féidir a onnmhairiú mar PDF",
"unable-to-save-message": "Níorbh fhéidir scríobh chuig an gcomhad roghnaithe. Déan iarracht eile nó roghnaigh ceann scríbe eile.",
"unable-to-print": "Ní féidir an nóta a phriontáil"
},
"tray": {
"close": "Scoir de Trilium",
"recents": "Nótaí le déanaí",
"bookmarks": "Leabharmharcanna",
"today": "Oscail nóta dialainne an lae inniu",
"new-note": "Nóta nua",
"show-windows": "Taispeáin fuinneoga",
"open_new_window": "Oscail fuinneog nua",
"tooltip": "Trilium Notes"
},
"migration": {
"old_version": "Ní thacaítear le haistriú díreach ó do leagan reatha. Uasghrádaigh go dtí an leagan is déanaí v0.60.4 ar dtús agus ansin go dtí an leagan seo amháin.",
"error_message": "Earráid le linn imirce go leagan {{version}}: {{stack}}",
"wrong_db_version": "Tá leagan an bhunachair shonraí ({{version}}) níos nuaí ná mar a bhfuil súil ag an bhfeidhmchlár leis ({{targetVersion}}), rud a chiallaíonn gur cruthaíodh é le leagan níos nuaí agus neamh-chomhoiriúnach de Trilium. Uasghrádaigh go dtí an leagan is déanaí de Trilium chun an fhadhb seo a réiteach."
},
"modals": {
"error_title": "Earráid"
},
"share_theme": {
"site-theme": "Téama an tSuímh",
"search_placeholder": "Cuardaigh...",
"image_alt": "Íomhá an Airteagail",
"last-updated": "Nuashonraithe go deireanach ar {{- date}}",
"subpages": "Fo-leathanaigh:",
"on-this-page": "Ar an Leathanach seo",
"expand": "Leathnaigh"
},
"hidden_subtree_templates": {
"text-snippet": "Sleachta Téacs",
"description": "Cur síos",
"list-view": "Amharc Liosta",
"grid-view": "Radharc Eangaí",
"calendar": "Féilire",
"table": "Tábla",
"geo-map": "Léarscáil Gheografach",
"start-date": "Dáta Tosaigh",
"end-date": "Dáta Deiridh",
"start-time": "Am Tosaigh",
"end-time": "Am Deiridh",
"geolocation": "Geoshuíomh",
"built-in-templates": "Teimpléid ionsuite",
"board": "Bord Kanban",
"status": "Stádas",
"board_note_first": "An chéad nóta",
"board_note_second": "An dara nóta",
"board_note_third": "An tríú nóta",
"board_status_todo": "Le Déanamh",
"board_status_progress": "Ar Siúl",
"board_status_done": "Déanta",
"presentation": "Cur i Láthair",
"presentation_slide": "Sleamhnán cur i láthair",
"presentation_slide_first": "An chéad sleamhnán",
"presentation_slide_second": "An dara sleamhnán",
"background": "Cúlra"
},
"sql_init": {
"db_not_initialized_desktop": "Níl an bunachar sonraí tosaithe, lean na treoracha ar an scáileán le do thoil.",
"db_not_initialized_server": "Níl an bunachar sonraí tosaithe, tabhair cuairt ar an leathanach socraithe - http://[your-server-host]:{{port}} le treoracha a fheiceáil maidir le conas Trilium a thosú."
},
"desktop": {
"instance_already_running": "Tá sampla ag rith cheana féin, agus tá fócas á chur ar an sampla sin ina ionad."
}
}

View File

@ -344,7 +344,8 @@
"inbox-title": "Inbox",
"base-abstract-launcher-title": "ベース アブストラクトランチャー",
"command-palette": "コマンドパレットを開く",
"zen-mode": "禅モード"
"zen-mode": "禅モード",
"tab-switcher-title": "タブ切り替え"
},
"notes": {
"new-note": "新しいノート",

View File

@ -257,7 +257,8 @@
"localization": "Limbă și regiune",
"inbox-title": "Inbox",
"command-palette": "Deschide paleta de comenzi",
"zen-mode": "Mod zen"
"zen-mode": "Mod zen",
"tab-switcher-title": "Schimbător de taburi"
},
"notes": {
"new-note": "Notiță nouă",

View File

@ -8,6 +8,12 @@ import type { AttachmentRow } from "@triliumnext/commons";
import type { ValidatorMap } from "./etapi-interface.js";
function register(router: Router) {
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const attachments = note.getAttachments();
res.json(attachments.map((attachment) => mappers.mapAttachmentToPojo(attachment)));
});
const ALLOWED_PROPERTIES_FOR_CREATE_ATTACHMENT: ValidatorMap = {
ownerId: [v.notNull, v.isNoteId],
role: [v.notNull, v.isString],

View File

@ -121,6 +121,16 @@ function getAndCheckAttribute(attributeId: string) {
}
}
function getAndCheckRevision(revisionId: string) {
const revision = becca.getRevision(revisionId);
if (revision) {
return revision;
} else {
throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`);
}
}
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
for (const key of Object.keys(source)) {
if (!(key in allowedProperties)) {
@ -152,5 +162,6 @@ export default {
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute,
getAndCheckAttachment
getAndCheckAttachment,
getAndCheckRevision
};

View File

@ -2,6 +2,7 @@ import type BAttachment from "../becca/entities/battachment.js";
import type BAttribute from "../becca/entities/battribute.js";
import type BBranch from "../becca/entities/bbranch.js";
import type BNote from "../becca/entities/bnote.js";
import type BRevision from "../becca/entities/brevision.js";
function mapNoteToPojo(note: BNote) {
return {
@ -64,9 +65,28 @@ function mapAttachmentToPojo(attachment: BAttachment) {
};
}
function mapRevisionToPojo(revision: BRevision) {
return {
revisionId: revision.revisionId,
noteId: revision.noteId,
type: revision.type,
mime: revision.mime,
isProtected: revision.isProtected,
title: revision.title,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,
utcDateLastEdited: revision.utcDateLastEdited,
utcDateCreated: revision.utcDateCreated,
utcDateModified: revision.utcDateModified,
contentLength: revision.contentLength
};
}
export default {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo,
mapAttachmentToPojo
mapAttachmentToPojo,
mapRevisionToPojo
};

View File

@ -0,0 +1,205 @@
import becca from "../becca/becca.js";
import sql from "../services/sql.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import noteService from "../services/notes.js";
import TaskContext from "../services/task_context.js";
import protectedSessionService from "../services/protected_session.js";
import utils from "../services/utils.js";
import type { Router } from "express";
import type { NoteRow, RecentChangeRow } from "@triliumnext/commons";
function register(router: Router) {
// GET /etapi/notes/history - must be registered before /etapi/notes/:noteId routes
eu.route(router, "get", "/etapi/notes/history", (req, res, next) => {
const ancestorNoteId = (req.query.ancestorNoteId as string) || "root";
let recentChanges: RecentChangeRow[];
if (ancestorNoteId === "root") {
// Optimized path: no ancestor filtering needed, fetch directly from DB
recentChanges = sql.getRows<RecentChangeRow>(`
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1
ORDER BY utcDate DESC
LIMIT 500`);
} else {
// Use recursive CTE to find all descendants, then filter at DB level
// This pushes filtering to the database for much better performance
recentChanges = sql.getRows<RecentChangeRow>(`
WITH RECURSIVE descendants(noteId) AS (
SELECT ?
UNION
SELECT branches.noteId
FROM branches
JOIN descendants ON branches.parentNoteId = descendants.noteId
)
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1 AND notes.noteId IN (SELECT noteId FROM descendants)
ORDER BY utcDate DESC
LIMIT 500`, [ancestorNoteId]);
}
for (const change of recentChanges) {
if (change.current_isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
change.title = protectedSessionService.decryptString(change.title) || "[protected]";
change.current_title = protectedSessionService.decryptString(change.current_title) || "[protected]";
} else {
change.title = change.current_title = "[protected]";
}
}
if (change.current_isDeleted) {
const deleteId = change.current_deleteId;
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(change.noteId, deleteId);
// note (and the subtree) can be undeleted if there's at least one undeleted parent (whose branch would be undeleted by this op)
change.canBeUndeleted = undeletedParentBranchIds.length > 0;
}
}
res.json(recentChanges);
});
// GET /etapi/notes/:noteId/revisions - List all revisions for a note
eu.route(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const revisions = becca.getRevisionsFromQuery(
`SELECT revisions.*, LENGTH(blobs.content) AS contentLength
FROM revisions
JOIN blobs USING (blobId)
WHERE noteId = ?
ORDER BY utcDateCreated DESC`,
[note.noteId]
);
res.json(revisions.map((revision) => mappers.mapRevisionToPojo(revision)));
});
// POST /etapi/notes/:noteId/undelete - Restore a deleted note
eu.route(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => {
const { noteId } = req.params;
const noteRow = sql.getRow<NoteRow | null>("SELECT * FROM notes WHERE noteId = ?", [noteId]);
if (!noteRow) {
throw new eu.EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
}
if (!noteRow.isDeleted || !noteRow.deleteId) {
throw new eu.EtapiError(400, "NOTE_NOT_DELETED", `Note '${noteId}' is not deleted.`);
}
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(noteId, noteRow.deleteId);
if (undeletedParentBranchIds.length === 0) {
throw new eu.EtapiError(400, "CANNOT_UNDELETE", `Cannot undelete note '${noteId}' - no undeleted parent found.`);
}
const taskContext = new TaskContext("no-progress-reporting", "undeleteNotes", null);
noteService.undeleteNote(noteId, taskContext);
res.json({ success: true });
});
// GET /etapi/revisions/:revisionId - Get revision metadata
eu.route(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and cannot be read through ETAPI.`);
}
res.json(mappers.mapRevisionToPojo(revision));
});
// GET /etapi/revisions/:revisionId/content - Get revision content
eu.route(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and content cannot be read through ETAPI.`);
}
const filename = utils.formatDownloadTitle(revision.title, revision.type, revision.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", revision.mime);
res.send(revision.getContent());
});
}
export default {
register
};

View File

@ -12,6 +12,7 @@ import etapiMetricsRoute from "../etapi/metrics.js";
import etapiNoteRoutes from "../etapi/notes.js";
import etapiSpecRoute from "../etapi/spec.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiRevisionsRoutes from "../etapi/revisions.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
@ -361,6 +362,8 @@ function register(app: express.Application) {
etapiAttachmentRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBranchRoutes.register(router);
// Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId
etapiRevisionsRoutes.register(router);
etapiNoteRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
etapiSpecRoute.register(router);

View File

@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() {
id: "_lbBackInHistory",
...sharedLaunchers.backInHistory
},
{
{
id: "_lbForwardInHistory",
...sharedLaunchers.forwardInHistory
},
@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() {
command: "commandPalette",
icon: "bx bx-chevron-right-square"
},
{
{
id: "_lbBackendLog",
title: t("hidden-subtree.backend-log-title"),
type: "launcher",
targetNoteId: "_backendLog",
icon: "bx bx-detail"
icon: "bx bx-detail"
},
{
id: "_zenMode",
@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() {
baseSize: "50",
growthFactor: "0"
},
{
{
id: "_lbBookmarks",
title: t("hidden-subtree.bookmarks-title"),
type: "launcher",
@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() {
id: "_lbToday",
...sharedLaunchers.openToday
},
{
{
id: "_lbSpacer2",
title: t("hidden-subtree.spacer-title"),
type: "launcher",
@ -179,7 +179,11 @@ export default function buildLaunchBarConfig() {
const mobileAvailableLaunchers: HiddenSubtreeItem[] = [
{ id: "_lbMobileNewNote", ...sharedLaunchers.newNote },
{ id: "_lbMobileToday", ...sharedLaunchers.openToday }
{ id: "_lbMobileToday", ...sharedLaunchers.openToday },
{
id: "_lbMobileRecentChanges",
...sharedLaunchers.recentChanges
}
];
const mobileVisibleLaunchers: HiddenSubtreeItem[] = [
@ -203,8 +207,10 @@ export default function buildLaunchBarConfig() {
...sharedLaunchers.calendar
},
{
id: "_lbMobileRecentChanges",
...sharedLaunchers.recentChanges
id: "_lbMobileTabSwitcher",
title: t("hidden-subtree.tab-switcher-title"),
type: "launcher",
builtinWidget: "mobileTabSwitcher"
}
];
@ -214,4 +220,4 @@ export default function buildLaunchBarConfig() {
mobileAvailableLaunchers,
mobileVisibleLaunchers
};
}
}

View File

@ -237,18 +237,19 @@ function getWindowExtraOpts() {
// Linux or other platforms.
extraOpts.frame = false;
}
}
// Window effects (Mica)
if (optionService.getOptionBool("backgroundEffects")) {
if (isMac) {
extraOpts.transparent = true;
extraOpts.visualEffectState = "active";
} else if (isWindows) {
extraOpts.backgroundMaterial = "auto";
} else {
// Linux or other platforms.
extraOpts.transparent = true;
// Window effects (Mica on Windows and Vibrancy on macOS)
// These only work if native title bar is not enabled.
if (optionService.getOptionBool("backgroundEffects")) {
if (isMac) {
extraOpts.transparent = true;
extraOpts.visualEffectState = "active";
} else if (isWindows) {
extraOpts.backgroundMaterial = "auto";
} else {
// Linux or other platforms.
extraOpts.transparent = true;
}
}
}

View File

@ -11,7 +11,7 @@
"dependencies": {
"i18next": "25.8.0",
"i18next-http-backend": "3.0.2",
"preact": "10.28.2",
"preact": "10.28.3",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.5",
"react-i18next": "16.5.4"

View File

@ -41,7 +41,12 @@
"search_title": "البحث القوي",
"web_clipper_title": "اداة قص الويب",
"title": "الانتاجية والسلامة",
"jump_to_title": "الاوامر والبحث السريع"
"jump_to_title": "الاوامر والبحث السريع",
"revisions_content": "تُحفظ الملاحظات دوريًا في الخلفية، ويمكن استخدام التعديلات للمراجعة أو للتراجع عن التغييرات غير المقصودة. كما يمكن إنشاء التعديلات عند الطلب.",
"sync_content": "استخدم نسخة مستضافة ذاتيًا أو نسخة سحابية لمزامنة ملاحظاتك بسهولة عبر أجهزة متعددة، وللوصول إليها من هاتفك المحمول باستخدام تطبيق ويب تقدمي (PWA).",
"protected_notes_content": "احمِ معلوماتك الشخصية الحساسة عبر تشفير الملاحظات وقفلها خلف جلسة محمية بكلمة مرور.",
"jump_to_content": "انتقل بسرعة إلى الملاحظات أو أوامر واجهة المستخدم عبر التسلسل الهرمي من خلال البحث عن عناوينها، مع ميزة المطابقة التقريبية لتجاوز الأخطاء الإملائية أو الاختلافات البسيطة.",
"search_content": "أو ابحث عن نص داخل الملاحظات وضيّق نطاق البحث عبر التصفية حسب الملاحظة الرئيسية، أو حسب مستوى التفرع."
},
"note_types": {
"canvas_title": "مساحة العمل",

View File

@ -0,0 +1,200 @@
{
"get-started": {
"title": "Tosaigh",
"desktop_title": "Íoslódáil an feidhmchlár deisce (v{{version}})",
"architecture": "Ailtireacht:",
"older_releases": "Féach ar eisiúintí níos sine",
"server_title": "Socraigh freastalaí le haghaidh rochtana ar ilghléasanna"
},
"hero_section": {
"title": "Eagraigh do chuid smaointe. Tóg do bhunachar eolais pearsanta.",
"subtitle": "Is réiteach foinse oscailte é Trilium chun nótaí a thógáil agus bunachar eolais pearsanta a eagrú. Bain úsáid as go háitiúil ar do dheasc, nó sioncrónaigh é le do fhreastalaí féinóstáilte chun do nótaí a choinneáil cibé áit a théann tú.",
"get_started": "Tosaigh",
"github": "GitHub",
"dockerhub": "Docker Hub",
"screenshot_alt": "Scáileán den fheidhmchlár deisce Trilium Notes"
},
"organization_benefits": {
"title": "Eagraíocht",
"note_structure_title": "Struchtúr nótaí",
"note_structure_description": "Is féidir nótaí a shocrú go hiarlathach. Níl aon ghá le fillteáin, ós rud é gur féidir fo-nótaí a bheith i ngach nóta. Is féidir nóta aonair a chur leis i roinnt áiteanna san ordlathas.",
"attributes_title": "Lipéid nótaí agus caidrimh",
"attributes_description": "Bain úsáid as caidrimh idir nótaí nó cuir lipéid leis le haghaidh catagóiriú éasca. Bain úsáid as tréithe ardaithe chun faisnéis struchtúrtha a iontráil ar féidir a úsáid i dtáblaí agus i gcláir.",
"hoisting_title": "Spásanna oibre agus ardaitheoir",
"hoisting_description": "Deighil do nótaí pearsanta agus oibre go héasca trí iad a ghrúpáil faoi spás oibre, rud a dhíríonn ar do chrann nótaí chun sraith nótaí ar leith amháin a thaispeáint."
},
"productivity_benefits": {
"title": "Táirgiúlacht agus sábháilteacht",
"revisions_title": "Athbhreithnithe nóta",
"revisions_content": "Sábháiltear nótaí go tréimhsiúil sa chúlra agus is féidir athbhreithnithe a úsáid le haghaidh athbhreithnithe nó chun athruithe de thaisme a chealú. Is féidir athbhreithnithe a chruthú ar éileamh freisin.",
"sync_title": "Sioncrónú",
"sync_content": "Bain úsáid as cás féinóstáilte nó scamall chun do nótaí a shioncrónú go héasca ar fud ilghléasanna, agus chun rochtain a fháil orthu ó do ghuthán póca ag baint úsáide as PWA.",
"protected_notes_title": "Nótaí faoi chosaint",
"protected_notes_content": "Cosain faisnéis phearsanta íogair trí na nótaí a chriptiú agus iad a ghlasáil taobh thiar de sheisiún atá cosanta ag pasfhocal.",
"jump_to_title": "Cuardach tapa agus orduithe",
"jump_to_content": "Léim go tapa chuig nótaí nó orduithe UI ar fud an ordlathais trí chuardach a dhéanamh ar a dteideal, le meaitseáil doiléir chun clóscríobh nó difríochtaí beaga a chur san áireamh.",
"search_title": "Cuardach cumhachtach",
"search_content": "Nó déan cuardach ar théacs laistigh de nótaí agus caolaigh an cuardach trí scagadh a dhéanamh de réir an nóta tuismitheora, nó de réir doimhneachta.",
"web_clipper_title": "Gearrthóir gréasáin",
"web_clipper_content": "Gabh leathanaigh ghréasáin (nó scáileáin) agus cuir iad go díreach i Trilium ag baint úsáide as síneadh brabhsálaí an ghearrthóra gréasáin."
},
"note_types": {
"title": "Ilbhealaí chun dfhaisnéis a léiriú",
"text_title": "Nótaí téacs",
"text_description": "Déantar na nótaí a chur in eagar ag baint úsáide as eagarthóir amhairc (WYSIWYG), a thacaíonn le táblaí, íomhánna, nathanna matamaitice, bloic chóid le haibhsiú comhréire. Formáidigh an téacs go tapa ag baint úsáide as comhréir cosúil le Markdown nó ag baint úsáide as orduithe slaise.",
"code_title": "Nótaí cóid",
"code_description": "Úsáideann samplaí móra de chód foinse nó scripteanna eagarthóir tiomnaithe, le haibhsiú comhréire do go leor teangacha ríomhchlárúcháin agus le téamaí dathanna éagsúla.",
"file_title": "Nótaí comhaid",
"file_description": "Cuir comhaid ilmheán ar nós PDFanna, íomhánna, físeáin le chéile le réamhamharc san fheidhmchlár.",
"canvas_title": "Canbhás",
"canvas_description": "Socraigh cruthanna, íomhánna agus téacs ar chanbhás gan teorainn, ag baint úsáide as an teicneolaíocht chéanna atá taobh thiar de excalidraw.com. Oiriúnach do léaráidí, sceitsí agus pleanáil amhairc.",
"mermaid_title": "Léaráidí maighdeana mara",
"mermaid_description": "Cruthaigh léaráidí ar nós cairteacha sreafa, léaráidí ranga agus seicheamhacha, cairteacha Gantt agus go leor eile, ag baint úsáide as comhréir Mermaid.",
"mindmap_title": "Léarscáil intinne",
"mindmap_description": "Eagraigh do chuid smaointe go hamhairc nó déan seisiún smaointeoireachta.",
"others_list": "agus cinn eile: <0>léarscáil nótaí</0>, <1>léarscáil gaoil</1>, <2>cuardaigh shábháilte</2>, <3>nóta rindreála</3>, agus <4>radhairc ghréasáin</4>."
},
"extensibility_benefits": {
"title": "Comhroinnt & inleathnú",
"import_export_title": "Iompórtáil/onnmhairiú",
"import_export_description": "Idirghníomhaigh go héasca le feidhmchláir eile ag baint úsáide as formáidí Markdown, ENEX, OML.",
"share_title": "Comhroinn nótaí ar an ngréasán",
"share_description": "Má tá freastalaí agat, is féidir é a úsáid chun fo-thacar de do nótaí a roinnt le daoine eile.",
"scripting_title": "Scriptiú ardleibhéil",
"scripting_description": "Tóg do chomhtháthú féin laistigh de Trilium le giuirléidí saincheaptha, nó loighic taobh an fhreastalaí.",
"api_title": "REST API",
"api_description": "Idirghníomhaigh le Trilium go ríomhchláraitheach ag baint úsáide as a REST API ionsuite."
},
"collections": {
"title": "Bailiúcháin",
"calendar_title": "Féilire",
"calendar_description": "Eagraigh dimeachtaí pearsanta nó gairmiúla ag baint úsáide as féilire, le tacaíocht dimeachtaí uile-lae agus il-lae. Féach ar dimeachtaí go tapa leis na radhairc seachtaine, míosa agus bliana. Idirghníomhaíocht éasca chun imeachtaí a chur leis nó a tharraingt.",
"table_title": "Tábla",
"table_description": "Taispeáin agus cuir in eagar faisnéis faoi nótaí i struchtúr táblach, le cineálacha éagsúla colún amhail téacs, uimhir, boscaí seiceála, dáta & am, naisc agus dathanna agus tacaíocht do chaidrimh. De rogha air sin, taispeáin na nótaí laistigh de ordlathas crainn taobh istigh den tábla.",
"board_title": "Bord Kanban",
"board_description": "Eagraigh stádas do thascanna nó do thionscadail i mbord Kanban le bealach éasca chun míreanna agus colúin nua a chruthú agus a stádas a athrú go simplí trí tharraingt trasna an chláir.",
"geomap_title": "Geo-léarscáil",
"geomap_description": "Pleanáil do laethanta saoire nó marcáil do phointí spéise go díreach ar léarscáil gheografach ag baint úsáide as marcóirí saincheaptha. Taispeáin rianta GPX taifeadta chun bealaí taistil a rianú.",
"presentation_title": "Cur i Láthair",
"presentation_description": "Eagraigh faisnéis i sleamhnáin agus cuir i láthair iad i lánscáileán le haistrithe réidhe. Is féidir na sleamhnáin a onnmhairiú go PDF freisin le go mbeidh sé éasca iad a roinnt."
},
"faq": {
"title": "Ceisteanna Coitianta",
"mobile_question": "An bhfuil feidhmchlár soghluaiste ann?",
"mobile_answer": "Faoi láthair níl aon aip shoghluaiste oifigiúil ann. Mar sin féin, má tá freastalaí agat is féidir leat rochtain a fháil air trí bhrabhsálaí gréasáin a úsáid agus fiú é a shuiteáil mar PWA. I gcás Android, tá aip neamhoifigiúil ann ar a dtugtar TriliumDroid a oibríonn as líne fiú (cosúil le cliant deisce).",
"database_question": "Cá bhfuil na sonraí stóráilte?",
"database_answer": "Stórálfar do nótaí go léir i mbunachar sonraí SQLite i bhfillteán feidhmchláir. Is é an chúis a n-úsáideann Trilium bunachar sonraí in ionad comhaid téacs simplí ná feidhmíocht agus go mbeadh roinnt gnéithe i bhfad níos deacra a chur i bhfeidhm amhail clóin (an nóta céanna in áiteanna éagsúla sa chrann). Chun an fillteán feidhmchláir a aimsiú, téigh go dtí an fhuinneog Maidir Linn.",
"server_question": "An bhfuil freastalaí ag teastáil uaim le Trilium a úsáid?",
"server_answer": "Ní hea, ceadaíonn an freastalaí rochtain trí bhrabhsálaí gréasáin agus bainistíonn sé an sioncrónú má tá ilghléasanna agat. Chun tús a chur leis, is leor an feidhmchlár deisce a íoslódáil agus tosú ag baint úsáide as.",
"scaling_question": "Cé chomh maith agus a scálaíonn an feidhmchlár le líon mór nótaí?",
"scaling_answer": "Ag brath ar úsáid, ba cheart go mbeadh an feidhmchlár in ann 100,000 nóta ar a laghad a láimhseáil gan fadhb. Tabhair faoi deara go bhféadfadh teip a bheith ar an bpróiseas sioncrónaithe uaireanta má tá go leor comhad mór á uaslódáil (1 GB in aghaidh an chomhaid) ós rud é go bhfuil Trilium beartaithe níos mó mar fheidhmchlár bonn eolais seachas stór comhad (cosúil le NextCloud, mar shampla).",
"network_share_question": "An féidir liom mo bhunachar sonraí a roinnt thar thiomántán líonra?",
"network_share_answer": "Ní hea, ní smaoineamh maith é bunachar sonraí SQLite a roinnt thar thiomántán líonra i gcoitinne. Cé go bhféadfadh sé oibriú uaireanta, tá seans ann go ndéanfar an bunachar sonraí a thruailliú mar gheall ar ghlasanna comhad neamhfhoirfe thar líonra.",
"security_question": "Conas a chosnaítear mo chuid sonraí?",
"security_answer": "De réir réamhshocraithe, ní chriptítear nótaí agus is féidir iad a léamh go díreach ón mbunachar sonraí. Nuair a mharcáiltear nóta mar chriptithe, déantar an nóta a chriptiú ag baint úsáide as AES-128-CBC."
},
"final_cta": {
"title": "Réidh le tosú le Trilium Notes?",
"description": "Tóg do bhunachar eolais pearsanta le gnéithe cumhachtacha agus príobháideacht iomlán.",
"get_started": "Tosaigh"
},
"components": {
"link_learn_more": "Foghlaim níos mó..."
},
"download_now": {
"text": "Íoslódáil anois ",
"platform_big": "v{{version}} do {{platform}}",
"platform_small": "do {{platform}}",
"linux_big": "v{{version}} do Linux",
"linux_small": "do Linux",
"more_platforms": "Tuilleadh ardán & socrú freastalaí"
},
"header": {
"get-started": "Tosaigh",
"documentation": "Doiciméadú",
"support-us": "Tacaigh linn"
},
"footer": {
"copyright_and_the": " agus an ",
"copyright_community": "pobal"
},
"social_buttons": {
"github": "GitHub",
"github_discussions": "Pléanna GitHub",
"matrix": "Maitrís",
"reddit": "Reddit"
},
"support_us": {
"title": "Tacaigh linn",
"financial_donations_title": "Síntiúis airgeadais",
"financial_donations_description": "Tógtar agus cothaítear Trilium le <Link>na céadta uair an chloig oibre</Link>. Coinníonn do thacaíocht é foinse oscailte, feabhsaíonn sé gnéithe, agus clúdaíonn sé costais amhail óstáil.",
"financial_donations_cta": "Smaoinigh ar thacaíocht a thabhairt don phríomhfhorbróir (<Link>eliandoran</Link>) den fheidhmchlár trí:",
"github_sponsors": "Urraitheoirí GitHub",
"paypal": "PayPal",
"buy_me_a_coffee": "Ceannaigh Caife Dom"
},
"contribute": {
"title": "Bealaí eile chun ranníocaíocht a dhéanamh",
"way_translate": "Aistrigh an feidhmchlár go do theanga dhúchais trí <Link>Weblate</Link>.",
"way_community": "Déan idirghníomhú leis an bpobal ar <Discussions>GitHub Discussions</Discussions> nó ar <Matrix>Matrix</Matrix>.",
"way_reports": "Tuairiscigh fabhtanna trí <Link>Fadhbanna GitHub</Link>.",
"way_document": "Feabhas a chur ar an doiciméadacht trí eolas a thabhairt dúinn faoi bhearnaí sa doiciméadacht nó trí threoracha, Ceisteanna Coitianta nó ranganna teagaisc a chur ar fáil.",
"way_market": "Scaip an scéal: Roinn Nótaí Trilium le cairde, nó ar bhlaganna agus ar na meáin shóisialta."
},
"404": {
"title": "404: Níor aimsíodh",
"description": "Níorbh fhéidir an leathanach a bhí á lorg agat a aimsiú. Bfhéidir gur scriosadh é nó go bhfuil an URL mícheart."
},
"download_helper_desktop_windows": {
"title_x64": "Windows 64-bit",
"title_arm64": "Windows ar ARM",
"description_x64": "Ag luí le gléasanna Intel nó AMD a bhfuil Windows 10 agus 11 á rith acu.",
"description_arm64": "Ag luí le gléasanna ARM (m.sh. le Qualcomm Snapdragon).",
"quick_start": "Chun a shuiteáil trí Winget:",
"download_exe": "Íoslódáil an Suiteálaí (.exe)",
"download_zip": "Iniompartha (.zip)",
"download_scoop": "Scúp"
},
"download_helper_desktop_linux": {
"title_x64": "Linux 64-bit",
"title_arm64": "Linux ar ARM",
"description_x64": "Don chuid is mó de na dáiltí Linux, comhoiriúnach le hailtireacht x86_64.",
"description_arm64": "I gcás dáiltí Linux bunaithe ar ARM, comhoiriúnach le hailtireacht aarch64.",
"quick_start": "Roghnaigh formáid phacáiste chuí, ag brath ar do dháileadh:",
"download_deb": ".deb",
"download_rpm": ".rpm",
"download_flatpak": ".flatpak",
"download_zip": "Iniompartha (.zip)",
"download_nixpkgs": "nixpkgs",
"download_aur": "AUR"
},
"download_helper_desktop_macos": {
"title_x64": "macOS do Intel",
"title_arm64": "macOS do Apple Silicon",
"description_x64": "Do Macs bunaithe ar Intel a bhfuil macOS Monterey nó níos déanaí á rith acu.",
"description_arm64": "Do ríomhairí Mac Apple Silicon ar nós iad siúd a bhfuil sceallóga M1 agus M2 acu.",
"quick_start": "Chun a shuiteáil trí Homebrew:",
"download_dmg": "Íoslódáil an Suiteálaí (.dmg)",
"download_homebrew_cask": "Homebrew Cask",
"download_zip": "Iniompartha (.zip)"
},
"download_helper_server_docker": {
"title": "Féinóstáilte ag baint úsáide as Docker",
"description": "Imscaradh go héasca ar Windows, Linux nó macOS ag baint úsáide as coimeádán Docker.",
"download_dockerhub": "Docker Hub",
"download_ghcr": "ghcr.io"
},
"download_helper_server_linux": {
"title": "Féinóstáilte ar Linux",
"description": "Imscar Trilium Notes ar do fhreastalaí nó VPS féin, atá comhoiriúnach leis an gcuid is mó de na dáileacháin.",
"download_tar_x64": "x64 (.tar.xz)",
"download_tar_arm64": "ARM (.tar.xz)",
"download_nixos": "Modúl NixOS"
},
"download_helper_server_hosted": {
"title": "Óstáil íoctha",
"description": "Nótaí Trilium atá á n-óstáil ar PikaPods, seirbhís íoctha le haghaidh rochtana agus bainistíochta éasca. Níl baint dhíreach aige le foireann Trilium.",
"download_pikapod": "Socraigh ar PikaPods",
"download_triliumcc": "Nó féach ar trilium.cc"
}
}

View File

@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/jaeLd6kJGrRa/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/y2NjgAW23yyj/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@ -24,7 +24,7 @@ As a quick heads-up of some differences when compared to `npm`:
## Installing dependencies
Run `pnpm i` at the top of the `Notes` repository to install the dependencies.
Run `pnpm i` at the top of the `Trilium` repository to install the dependencies.
> [!NOTE]
> Dependencies are kept up to date periodically in the project. Generally it's a good rule to do `pnpm i` after each `git pull` on the main branch.

10
docs/README-ar.md vendored
View File

@ -88,11 +88,11 @@ script)](./README-ZH_TW.md) | [English](../README.md) | [French](./README-fr.md)
النصية](https://docs.triliumnotes.org/user-guide/scripts) المتقدمة
* UI available in English, German, Spanish, French, Romanian, and Chinese
(simplified and traditional)
* Direct [OpenID and TOTP
integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for
more secure login
* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization)
with self-hosted sync server
* تكامل مباشر مع [أنظمة الهوية المفتوحة OpenID وكلمات المرور المؤقتة
TOTP](https://docs.triliumnotes.org/user-guide/setup/server/mfa) لتسجيل دخول
أكثر أماناً
* [المزامنة](https://docs.triliumnotes.org/user-guide/setup/synchronization) مع
خادم مزامنة مُستضاف ذاتيًا
* there are [3rd party services for hosting synchronisation
server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)

355
docs/README-ga.md vendored Normal file
View File

@ -0,0 +1,355 @@
<div align="center">
<sup>Special thanks to:</sup><br />
<a href="https://go.warp.dev/Trilium" target="_blank">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png"><br />
Warp, built for coding with multiple AI agents<br />
</a>
<sup>Available for macOS, Linux and Windows</sup>
</div>
<hr />
# Trilium Notes
![Urraitheoirí GitHub](https://img.shields.io/github/sponsors/eliandoran)
![Pátrúin LiberaPay](https://img.shields.io/liberapay/patrons/ElianDoran)\
![Tarraingtí Docker](https://img.shields.io/docker/pulls/triliumnext/trilium)
![Íoslódálacha GitHub (gach sócmhainn, gach
eisiúint)](https://img.shields.io/github/downloads/triliumnext/trilium/total)\
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[![Stádas
aistriúcháin](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
<!-- translate:off -->
<!-- LANGUAGE SWITCHER -->
[Chinese (Simplified Han script)](./README-ZH_CN.md) | [Chinese (Traditional Han
script)](./README-ZH_TW.md) | [English](../README.md) | [French](./README-fr.md)
| [German](./README-de.md) | [Greek](./README-el.md) | [Italian](./README-it.md)
| [Japanese](./README-ja.md) | [Romanian](./README-ro.md) |
[Spanish](./README-es.md)
<!-- translate:on -->
Is feidhmchlár saor in aisce agus foinse oscailte, tras-ardán, ordlathach é
Trilium Notes chun nótaí a thógáil le fócas ar bhunachair mhóra eolais
phearsanta a thógáil.
<img src="./app.png" alt="Trilium Screenshot" width="1000">
## ⏬ Íoslódáil
- [An leagan is déanaí](https://github.com/TriliumNext/Trilium/releases/latest)
leagan cobhsaí, molta do fhormhór na n-úsáideoirí.
- [Tógáil oíche](https://github.com/TriliumNext/Trilium/releases/tag/nightly)
leagan forbartha éagobhsaí, a nuashonraítear go laethúil leis na gnéithe agus
na socruithe is déanaí.
## 📚 Doiciméadú
**Tabhair cuairt ar ár ndoiciméadacht chuimsitheach ag
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Tá ár ndoiciméadacht ar fáil i bhformáidí éagsúla:
- **Doiciméadacht Ar Líne**: Brabhsáil an doiciméadacht iomlán ag
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **Cabhair san Aip**: Brúigh `F1` laistigh de Trilium chun rochtain a fháil ar
an doiciméadacht chéanna go díreach san fheidhmchlár
- **GitHub**: Nascleanúint tríd an [Treoir
Úsáideora](./User%20Guide/User%20Guide/) sa stórlann seo
### Naisc Thapa
- [Treoir Tosaithe](https://docs.triliumnotes.org/)
- [Treoracha Suiteála](https://docs.triliumnotes.org/user-guide/setup)
- [Socrú
Docker](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
- [Uasghrádú
TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
- [Coincheapa agus Gnéithe
Bunúsacha](https://docs.triliumnotes.org/user-guide/concepts/notes)
- [Patrúin de Bhunachar Eolais
Phearsanta](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
## 🎁 Gnéithe
* Is féidir nótaí a shocrú i gcrann domhain treallach. Is féidir nóta aonair a
chur in áiteanna éagsúla sa chrann (féach
[clónáil](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
* Eagarthóir nótaí WYSIWYG saibhir lena n-áirítear táblaí, íomhánna agus
[matamaitic](https://docs.triliumnotes.org/user-guide/note-types/text) le
marcáil síos
[autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
* Tacaíocht le haghaidh eagarthóireacht [nótaí le cód
foinse](https://docs.triliumnotes.org/user-guide/note-types/code), lena
n-áirítear aibhsiú comhréire
* Nascleanúint thapa agus éasca idir
nótaí(https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation),
cuardach téacs iomlán agus [ardú
nótaí](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
* Gan uaim [leaganú
nótaí](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
* Is féidir nótaí
[tréithe](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes)
a úsáid chun nótaí a eagrú, fiosrúcháin a dhéanamh agus [scriptiú]
ardleibhéil(https://docs.triliumnotes.org/user-guide/scripts)
* Tá an comhéadan úsáideora ar fáil i mBéarla, i nGearmáinis, i Spáinnis, i
bhFraincis, i Rómáinis, agus i Sínis (simplithe agus traidisiúnta)
* Díreach [Comhtháthú OpenID agus
TOTP](https://docs.triliumnotes.org/user-guide/setup/server/mfa) le haghaidh
logáil isteach níos sláine
* [Sioncrónú](https://docs.triliumnotes.org/user-guide/setup/synchronization) le
freastalaí sioncrónaithe féinóstáilte
* tá [seirbhísí tríú páirtí ann chun freastalaí sioncrónaithe a
óstáil](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* Nótaí [Ag
roinnt](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (ag
foilsiú) ar an idirlíon poiblí
* [Criptiú nótaí]
láidir(https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes)
le mionsonraí in aghaidh an nóta
* Léaráidí sceitseála, bunaithe ar [Excalidraw](https://excalidraw.com/)
(tabhair faoi deara cineál "canbhás")
* [Léarscáileanna
caidrimh](https://docs.triliumnotes.org/user-guide/note-types/relation-map)
agus [léarscáileanna
nótaí/naisc](https://docs.triliumnotes.org/user-guide/note-types/note-map)
chun nótaí agus a gcaidrimh a léirshamhlú
* Léarscáileanna intinne, bunaithe ar [Mind
Elixir](https://docs.mind-elixir.com/)
* [Léarscáileanna
geo](https://docs.triliumnotes.org/user-guide/collections/geomap) le bioráin
suímh agus rianta GPX
* [Scriptiú](https://docs.triliumnotes.org/user-guide/scripts) - féach
[Taispeántais
Ardleibhéil](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) le
haghaidh uathoibrithe
* Scálann go maith i dtéarmaí inúsáidteachta agus feidhmíochta araon os cionn
100,000 nóta
* Tadhall-optamaithe [comhéadan soghluaiste]
(https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) le haghaidh
fóin chliste agus táibléad
* Téama dorcha
ionsuite(https://docs.triliumnotes.org/user-guide/concepts/themes), tacaíocht
do théamaí úsáideora
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote)
agus [Iompórtáil & Easpórtáil
Markdown](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
* [Gearrthóir
Gréasáin](https://docs.triliumnotes.org/user-guide/setup/web-clipper) le
haghaidh sábháil éasca ar ábhar gréasáin
* Comhéadan úsáideora saincheaptha (cnaipí taobhbharra, giuirléidí sainithe ag
an úsáideoir, ...)
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics),
mar aon le Painéal Grafana.
✨ Féach ar na hacmhainní/pobail tríú páirtí seo a leanas le haghaidh tuilleadh
earraí gaolmhara le TriliumNext:
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) le haghaidh
téamaí, scripteanna, breiseáin agus tuilleadh ó thríú páirtithe.
- [TriliumRocks!](https://trilium.rocks/) le haghaidh ranganna teagaisc,
treoracha, agus i bhfad níos mó.
## ❓Cén fáth TriliumNext?
Bhronn forbróir bunaidh Trilium ([Zadam](https://github.com/zadam)) stórlann
Trilium go fial ar an tionscadal pobail atá le fáil ag
https://github.com/TriliumNext
### ⬆Ag dul ar imirce ó Zadam/Trilium?
Níl aon chéimeanna imirce speisialta ann chun imirce ó shampla zadam/Trilium go
sampla TriliumNext/Trilium. Níl le déanamh ach [TriliumNext/Trilium a
shuiteáil](#-installation) mar is gnách agus úsáidfidh sé do bhunachar sonraí
atá ann cheana féin.
Tá leaganacha suas go dtí agus lena n-áirítear
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4)
comhoiriúnach leis an leagan is déanaí de zadam/trilium de
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Méadaítear
leaganacha sioncrónaithe aon leaganacha níos déanaí de TriliumNext/Trilium rud a
chuireann cosc ar aistriú díreach.
## 💬 Pléigh linn
Ná bíodh drogall ort páirt a ghlacadh inár gcomhráite oifigiúla. Ba bhreá linn
cloisteáil faoi na gnéithe, na moltaí nó na fadhbanna a d'fhéadfadh a bheith
agat!
- [Maitrís](https://matrix.to/#/#triliumnext:matrix.org) (Le haghaidh plé
sioncrónach.)
- Tá droichead idir seomra an Mhaitrís `Ginearálta` agus
[XMPP](xmpp:discuss@trilium.thisgreat.party?join) freisin
- [Plé Github](https://github.com/TriliumNext/Trilium/discussions) (Le haghaidh
plé neamhshioncrónach.)
- [Fadhbanna Github](https://github.com/TriliumNext/Trilium/issues) (Le haghaidh
tuairiscí fabhtanna agus iarratais ar ghnéithe.)
## 🏗 Suiteáil
### Windows / MacOS
Íoslódáil an scaoileadh dénártha do d'ardán ón [leathanach scaoileadh is
déanaí](https://github.com/TriliumNext/Trilium/releases/latest), dízipeáil an
pacáiste agus rith an comhad inrite `trilium`.
### Linux
Más liostaithe sa tábla thíos atá do dháileadh, bain úsáid as pacáiste do
dháilte.
[![Stádas
pacáistithe](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
Féadfaidh tú an scaoileadh dénártha do d'ardán a íoslódáil ón [leathanach
scaoileadh is déanaí](https://github.com/TriliumNext/Trilium/releases/latest)
freisin, an pacáiste a dhízipeáil agus an comhad inrite `trilium` a rith.
Cuirtear TriliumNext ar fáil mar Flatpak freisin, ach níl sé foilsithe ar
FlatHub go fóill.
### Brabhsálaí (aon chóras oibriúcháin)
Má úsáideann tú suiteáil freastalaí (féach thíos), is féidir leat rochtain
dhíreach a fháil ar an gcomhéadan gréasáin (atá beagnach mar an gcéanna leis an
aip deisce).
Faoi láthair ní thacaítear (agus déantar tástáil ar) ach leis na leaganacha is
déanaí de Chrome agus Firefox.
### Soghluaiste
Chun TriliumNext a úsáid ar ghléas soghluaiste, is féidir leat brabhsálaí
gréasáin soghluaiste a úsáid chun rochtain a fháil ar chomhéadan soghluaiste
suiteála freastalaí (féach thíos).
Féach ar an eagrán https://github.com/TriliumNext/Trilium/issues/4962 le
haghaidh tuilleadh eolais faoi thacaíocht daipeanna soghluaiste.
Más fearr leat aip dhúchasach Android, is féidir leat
[TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)
a úsáid. Tuairiscigh fabhtanna agus gnéithe atá ar iarraidh ag [a
stór](https://github.com/FliegendeWurst/TriliumDroid). Tabhair faoi deara: Is
fearr nuashonruithe uathoibríocha a dhíchumasú ar do shuiteáil freastalaí (féach
thíos) agus TriliumDroid in úsáid agat ós rud é go gcaithfidh an leagan
sioncrónaithe a bheith mar an gcéanna idir Trilium agus TriliumDroid.
### Freastalaí
Chun TriliumNext a shuiteáil ar do fhreastalaí féin (lena n-áirítear trí Docker
ó [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) lean [na doiciméid
suiteála freastalaí](https://docs.triliumnotes.org/user-guide/setup/server).
## 💻 Cuir leis
### Aistriúcháin
Más cainteoir dúchais thú, cabhraigh linn Trilium a aistriú trí dhul chuig ár
[leathanach Weblate](https://hosted.weblate.org/engage/trilium/).
Seo an clúdach teanga atá againn go dtí seo:
[![Stádas
aistriúcháin](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Cód
Íoslódáil an stórlann, suiteáil spleáchais ag baint úsáide as `pnpm` agus ansin
rith an freastalaí (ar fáil ag http://localhost:8080):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
### Doiciméadú
Íoslódáil an stórlann, suiteáil spleáchais ag baint úsáide as `pnpm` agus ansin
rith an timpeallacht atá riachtanach chun an doiciméadú a chur in eagar:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm edit-docs:edit-docs
```
### Ag Tógáil an Inrite
Íoslódáil an stórlann, suiteáil spleáchais ag baint úsáide as `pnpm` agus ansin
tóg an aip deisce do Windows:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
```
Le haghaidh tuilleadh sonraí, féach ar na [doiciméid
forbartha](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
### Doiciméadacht Forbróra
Féach ar an [treoir
dhoiciméadúcháin](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
le haghaidh tuilleadh sonraí. Má tá tuilleadh ceisteanna agat, bíodh leisce ort
teagmháil a dhéanamh linn trí na naisc a bhfuil cur síos orthu sa chuid "Pléigh
Linn" thuas.
## 👏 Glaonna amach
* [zadam](https://github.com/zadam) as an gcoincheap bunaidh agus cur i bhfeidhm
an fheidhmchláir.
* [Sarah Hussein](https://github.com/Sarah-Hussein) as dearadh dheilbhín an
fheidhmchláir.
* [nriver](https://github.com/nriver) as a chuid oibre ar an idirnáisiúnú.
* [Thomas Frei](https://github.com/thfrei) as a shaothar bunaidh ar an Chanbhás.
* [antoniotejada](https://github.com/nriver) don ghiuirléid aibhsithe comhréire
bunaidh.
* [Dosu](https://dosu.dev/) as na freagraí uathoibrithe a sholáthar dúinn ar
shaincheisteanna agus ar phlé GitHub.
* [Deilbhíní Tábla](https://tabler.io/icons) do na deilbhíní sa tráidire córais.
Ní bheadh Trilium indéanta gan na teicneolaíochtaí atá taobh thiar de:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - an t-eagarthóir amhairc
atá taobh thiar de nótaí téacs. Táimid buíoch as sraith de na gnéithe préimhe
a bheith curtha ar fáil dúinn.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - eagarthóir cóid le
tacaíocht do líon ollmhór teangacha.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - an clár bán gan
teorainn a úsáidtear i nótaí Canvas.
* [Intinn Elixir](https://github.com/SSShooter/mind-elixir-core) - ag soláthar
feidhmiúlacht léarscáil intinne.
* [Bileog](https://github.com/Leaflet/Leaflet) - le haghaidh léarscáileanna
geografacha a léiriú.
* [Tábla](https://github.com/olifolkerd/tabulator) - don tábla idirghníomhach a
úsáidtear i mbailiúcháin.
* [FancyTree](https://github.com/mar10/fancytree) - leabharlann crann lán
gnéithe gan iomaíocht cheart.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - leabharlann nascachta amhairc.
Úsáidte i [léarscáileanna
caidrimh](https://docs.triliumnotes.org/user-guide/note-types/relation-map)
agus [léarscáileanna
nasc](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
## 🤝 Tacaíocht
Tógtar agus cothaítear Trilium le [na céadta uair an chloig
oibre](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Coinníonn
do thacaíocht é foinse oscailte, feabhsaíonn sé gnéithe, agus clúdaíonn sé
costais amhail óstáil.
Smaoinigh ar thacaíocht a thabhairt don phríomhfhorbróir
([eliantoran](https://github.com/eliandoran)) den fheidhmchlár trí:
- [Urraitheoirí GitHub](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Ceannaigh Caife Dom](https://buymeacoffee.com/eliandoran)
## 🔑 Ceadúnas
Cóipcheart 2017-2025 zadam, Elian Doran, agus rannpháirtithe eile
Is bogearraí saor in aisce an clár seo: is féidir leat é a athdháileadh agus/nó
a mhodhnú faoi théarmaí Cheadúnas Poiblí Ginearálta GNU Affero mar atá foilsithe
ag an bhFondúireacht Bogearraí Saor in Aisce, cibé acu leagan 3 den Cheadúnas,
nó (de réir do rogha féin) aon leagan níos déanaí.

File diff suppressed because one or more lines are too long

View File

@ -21,9 +21,9 @@ Depending on your use case, you can either test the portable version or even use
This is pretty useful if you are a beta tester that wants to periodically update their version:
On Ubuntu:
## On Ubuntu (Bash)
```
```sh
#!/usr/bin/env bash
name=TriliumNotes-linux-x64-nightly.deb
@ -31,4 +31,34 @@ rm -f $name*
wget https://github.com/TriliumNext/Trilium/releases/download/nightly/$name
sudo apt-get install ./$name
rm $name
```
## On Windows (PowerShell)
```powershell
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
$arch = "arm64";
} else {
$arch = "x64";
}
$exeUrl = "https://github.com/TriliumNext/Trilium/releases/download/nightly/TriliumNotes-main-windows-$($arch).exe";
Write-Host "Downloading $($exeUrl)"
# Generate a unique path in the temp dir
$guid = [guid]::NewGuid().ToString()
$destination = Join-Path -Path $env:TEMP -ChildPath "$guid.exe"
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $exeUrl -OutFile $destination
$process = Start-Process -FilePath $destination
} catch {
Write-Error "An error occurred: $_"
} finally {
# Clean up
if (Test-Path $destination) {
Remove-Item -Path $destination -Force
}
}
```

View File

@ -63,7 +63,7 @@
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.5.1",
"eslint-plugin-simple-import-sort": "12.1.1",
"happy-dom": "20.4.0",
"happy-dom": "20.5.0",
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
@ -101,7 +101,7 @@
},
"overrides": {
"mermaid": "11.12.2",
"preact": "10.28.2",
"preact": "10.28.3",
"roughjs": "4.6.6",
"@types/express-serve-static-core": "5.1.0",
"flat@<5.0.1": ">=5.0.1",

View File

@ -39,7 +39,7 @@
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"webdriverio": "9.23.2"
"webdriverio": "9.23.3"
},
"peerDependencies": {
"ckeditor5": "47.4.0"

View File

@ -40,7 +40,7 @@
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"webdriverio": "9.23.2"
"webdriverio": "9.23.3"
},
"peerDependencies": {
"ckeditor5": "47.4.0"

View File

@ -42,7 +42,7 @@
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"webdriverio": "9.23.2"
"webdriverio": "9.23.3"
},
"peerDependencies": {
"ckeditor5": "47.4.0"

View File

@ -42,7 +42,7 @@
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"webdriverio": "9.23.2"
"webdriverio": "9.23.3"
},
"peerDependencies": {
"ckeditor5": "47.4.0"

View File

@ -42,7 +42,7 @@
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"webdriverio": "9.23.2"
"webdriverio": "9.23.3"
},
"peerDependencies": {
"ckeditor5": "47.4.0"

View File

@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.4.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.29",
"@smithy/middleware-retry": "4.4.30",
"@types/jquery": "3.5.33",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",

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