Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/about-dialog-overhaul

This commit is contained in:
Adorian Doran
2026-04-18 11:58:15 +03:00
134 changed files with 4100 additions and 1192 deletions

View File

@@ -93,7 +93,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.6.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -134,7 +134,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.6.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -150,7 +150,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.6.2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@@ -58,7 +58,7 @@ jobs:
compression-level: 0
- name: Release web clipper extension
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.6.2
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
draft: false

View File

@@ -84,7 +84,7 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.9",
"happy-dom": "20.9.0",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.1"

View File

@@ -25,6 +25,15 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void;
export type SaveState = "saved" | "saving" | "unsaved" | "error";
const READ_ONLY_CAPABLE_TYPES: string[] = [
"text",
"code",
"mermaid",
"canvas",
"mindMap",
"spreadsheet"
];
export interface NoteContextDataMap {
toc: HeadingContext;
pdfPages: {
@@ -303,8 +312,12 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
// "readOnly" is a state valid only for text/code notes
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
if (!this.note) {
return false;
}
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
return false;
}
@@ -320,6 +333,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
// Auto read-only based on content size is only configurable for text/code.
if (this.note.type !== "text" && this.note.type !== "code") {
return false;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {

View File

@@ -1069,6 +1069,10 @@ export default class FNote {
return this.mime === "text/x-sqlite;schema=trilium";
}
isMarkdown() {
return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm");
}
isTriliumScript() {
return this.mime.startsWith("application/javascript");
}

View File

@@ -39,6 +39,7 @@ export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
mime?: string;
/**
* The icon to display in the menu item.
*

View File

@@ -0,0 +1,101 @@
import type { ToggleInParentResponse } from "@triliumnext/commons";
import type FNote from "../entities/fnote.js";
import branchService from "../services/branches.js";
import { t } from "../services/i18n.js";
import server from "../services/server.js";
import toast from "../services/toast.js";
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
const VISIBLE_LAUNCHER_PARENTS = ["_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"];
function getVisibleLauncherBranch(launcherNote: FNote) {
return launcherNote.getParentBranches().find((b) => VISIBLE_LAUNCHER_PARENTS.includes(b.parentNoteId));
}
function getBookmarkBranch(launcherNote: FNote) {
return launcherNote.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
}
async function removeFromLaunchBar(launcherNote: FNote) {
const bookmarkBranch = getBookmarkBranch(launcherNote);
if (bookmarkBranch) {
// Individual bookmarks are represented via a branch under `_lbBookmarks`; removing them
// from the launch bar is the same as unbookmarking the note.
const resp = await server.put<ToggleInParentResponse>(
`notes/${launcherNote.noteId}/toggle-in-parent/_lbBookmarks/false`
);
if (!resp.success && resp.message) {
toast.showError(resp.message);
}
return;
}
const launcherBranch = getVisibleLauncherBranch(launcherNote);
if (!launcherBranch) return;
const isMobileLauncher = launcherBranch.parentNoteId === "_lbMobileVisibleLaunchers";
// Branch IDs in the hidden subtree follow the `${parentNoteId}_${noteId}` convention,
// so the branch linking `_lb(Mobile)?Root` to the "available" launchers root is predictable.
const targetBranchId = isMobileLauncher
? "_lbMobileRoot__lbMobileAvailableLaunchers"
: "_lbRoot__lbAvailableLaunchers";
await branchService.moveToParentNote([launcherBranch.branchId], targetBranchId);
}
export function canRemoveFromLaunchBar(launcherNote: FNote | null | undefined) {
if (!launcherNote) return false;
return !!(getVisibleLauncherBranch(launcherNote) || getBookmarkBranch(launcherNote));
}
export interface ShowLauncherContextMenuOptions<T extends string> {
/** Menu items specific to this launcher (e.g. "Open in new tab" for note-based launchers). They appear above the "Remove from launch bar" item. */
extraItems?: MenuItem<T>[];
/** Handler for the {@link extraItems}. The "Remove from launch bar" item is handled internally and will not be forwarded. */
onCommand?: (command: T | undefined) => void;
}
const REMOVE_COMMAND = "__removeFromLaunchBar__";
/**
* Displays the launch bar icon context menu. When the launcher can be removed (i.e. it is a direct
* child of the visible launchers root or of `_lbBookmarks`), a "Remove from launch bar" entry is
* appended. Extra items can be supplied to preserve launcher-specific actions (e.g. "Open in new tab").
*/
export async function showLauncherContextMenu<T extends string>(
launcherNote: FNote | null | undefined,
e: ContextMenuEvent,
options: ShowLauncherContextMenuOptions<T> = {}
) {
e.preventDefault();
const items = [...(options.extraItems ?? [])] as MenuItem<string>[];
if (canRemoveFromLaunchBar(launcherNote)) {
if (items.length > 0) {
items.push({ kind: "separator" });
}
items.push({
title: t("launcher_button_context_menu.remove_from_launch_bar"),
command: REMOVE_COMMAND,
uiIcon: "bx bx-x-circle"
});
}
if (items.length === 0) return;
contextMenu.show<string>({
x: e.pageX ?? 0,
y: e.pageY ?? 0,
items,
selectMenuItemHandler: ({ command }) => {
if (command === REMOVE_COMMAND) {
if (launcherNote) {
void removeFromLaunchBar(launcherNote);
}
return;
}
options.onCommand?.(command as T | undefined);
}
});
}

View File

@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
}
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
const notePath = treeService.getNotePath(this.node);
if (utils.isMobile()) {
@@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
target: "after",
targetBranchId: this.node.data.branchId,
type,
mime,
isProtected,
templateNoteId
});
@@ -313,6 +314,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
noteCreateService.createNote(parentNotePath, {
type,
mime,
isProtected: this.node.data.isProtected,
templateNoteId
});

View File

@@ -1,6 +1,7 @@
import "./content_renderer.css";
import { normalizeMimeTypeForCKEditor, type TextRepresentationResponse } from "@triliumnext/commons";
import { normalizeMimeTypeForCKEditor, renderToHtml, type TextRepresentationResponse } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { h, render } from "preact";
import WheelZoom from 'vanilla-js-wheel-zoom';
@@ -8,7 +9,7 @@ import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import renderText from "./content_renderer_text.js";
import renderText, { postProcessRichContent, renderChildrenList } from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import openService from "./open.js";
@@ -54,6 +55,8 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
if (type === "text" || type === "book") {
await renderText(entity, $renderedContent, options);
} else if (type === "markdown") {
await renderMarkdown(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
@@ -119,6 +122,31 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
};
}
/**
* Renders a markdown note by converting its source to CKEditor-compatible HTML,
* then running the same post-render pipeline as text notes (included notes,
* math, reference links, Mermaid, code highlight) so the preview matches what
* the user sees in the Markdown note type's preview pane.
*/
async function renderMarkdown(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions) {
const blob = await note.getBlob();
const source = blob?.content ?? "";
if (!source.trim()) {
if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
return;
}
const html = renderToHtml(source, note.title, {
sanitize: (dirty) => DOMPurify.sanitize(dirty),
wikiLink: { formatHref: (id) => `#root/${id}` }
});
$renderedContent.append($('<div class="ck-content">').html(html));
await postProcessRichContent(note, $renderedContent, options);
}
/**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/
@@ -330,6 +358,8 @@ function getRenderingType(entity: FNote | FAttachment) {
if (type === "file" && mime === "application/pdf") {
type = "pdf";
} else if (type === "code" && entity instanceof FNote && entity.isMarkdown()) {
type = "markdown";
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
type = "code";
} else if (type === "file" && mime && mime.startsWith("audio/")) {

View File

@@ -15,37 +15,47 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.href);
el.replaceChildren(innerSpan);
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
await postProcessRichContent(note, $renderedContent, options);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
}
/**
* Apply the post-render passes that make CKEditor-compatible HTML fully
* interactive: expand `<section class="include-note">`, render inline math and
* Mermaid diagrams, rewrite reference-link titles, and highlight code blocks.
* Assumes the caller has already appended the HTML inside a `.ck-content` child
* of `$renderedContent`.
*/
export async function postProcessRichContent(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.href);
el.replaceChildren(innerSpan);
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
}
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
// TODO: Consider duplicating with server's share/content_renderer.ts.
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
@@ -101,19 +111,107 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen
}
}
/**
* Per-container cache of rendered mermaid SVG keyed by diagram source text.
* Populated after each successful render; reused on subsequent renders to
* avoid flicker when the preview HTML is regenerated (e.g. live markdown
* editing). Entries for diagrams no longer present in the container are
* evicted on each run so the cache can't grow unbounded.
*/
const mermaidSvgCache = new WeakMap<HTMLElement, Map<string, string>>();
/**
* Per-container, ordered snapshot of the most recently rendered SVGs. Used as
* a positional placeholder so edits to a diagram's source keep the previous
* SVG visible while the new one renders offscreen.
*/
const mermaidLastRenderedByPosition = new WeakMap<HTMLElement, string[]>();
export async function applyInlineMermaid(container: HTMLDivElement) {
// Initialize mermaid
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
if (!nodes.length) {
mermaidLastRenderedByPosition.delete(container);
return;
}
let cache = mermaidSvgCache.get(container);
if (!cache) {
cache = new Map();
mermaidSvgCache.set(container, cache);
}
const lastRendered = mermaidLastRenderedByPosition.get(container) ?? [];
// Decide per node: exact cache hit → paint final SVG; source changed →
// paint the previous SVG (by position) as a placeholder and queue an
// offscreen re-render. This way the user keeps seeing the old diagram
// until mermaid has finished producing the new one.
const pending: Array<{ visible: HTMLElement; source: string }> = [];
const seenSources = new Set<string>();
for (const [ index, node ] of nodes.entries()) {
const source = (node.textContent ?? "").trim();
seenSources.add(source);
const cached = cache.get(source);
if (cached) {
node.innerHTML = cached;
node.setAttribute("data-processed", "true");
continue;
}
pending.push({ visible: node, source });
const placeholder = lastRendered[index];
if (placeholder) {
node.innerHTML = placeholder;
}
}
// Evict cache entries whose source is no longer present.
for (const key of [ ...cache.keys() ]) {
if (!seenSources.has(key)) cache.delete(key);
}
if (!pending.length) {
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
return;
}
const mermaid = (await import("mermaid")).default;
mermaid.initialize(getMermaidConfig());
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
// Render clones offscreen so the visible nodes keep showing the placeholder
// until the new SVG is ready. Keeps mermaid away from our placeholder SVG
// (which would otherwise confuse its text-based parser).
const offscreen = document.createElement("div");
offscreen.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;visibility:hidden;";
document.body.appendChild(offscreen);
const pairs = pending.map(({ visible, source }) => {
const clone = document.createElement("div");
clone.className = "mermaid-diagram";
clone.textContent = source;
offscreen.appendChild(clone);
return { visible, clone, source };
});
try {
await mermaid.run({ nodes });
await mermaid.run({ nodes: pairs.map((p) => p.clone) });
for (const { visible, clone, source } of pairs) {
if (clone.getAttribute("data-processed") !== "true") continue;
const svg = clone.innerHTML;
visible.innerHTML = svg;
visible.setAttribute("data-processed", "true");
cache.set(source, svg);
}
} catch (e) {
console.log(e);
console.error(e);
} finally {
offscreen.remove();
}
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
}
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
export async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {

View File

@@ -27,7 +27,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true, isNew: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
@@ -49,6 +49,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
{ type: "code", mime: "text/x-markdown", title: t("note_types.markdown"), icon: "bxl-markdown", isNew: true },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
@@ -100,6 +101,7 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
title: nt.title,
command,
type: nt.type,
mime: nt.mime,
uiIcon: `bx ${nt.icon}`,
badges: []
};

View File

@@ -131,11 +131,16 @@ button.tn-low-profile:hover {
color: var(--icon-button-color);
}
:root .btn-group .icon-action:last-child {
:root .btn-group .icon-action:not(:first-child) {
border-top-left-radius: unset !important;
border-bottom-left-radius: unset !important;
}
:root .btn-group .icon-action:not(:last-child) {
border-top-right-radius: unset !important;
border-bottom-right-radius: unset !important;
}
.btn-group .tn-tool-button + .tn-tool-button {
margin-inline-start: 4px !important;
}

View File

@@ -635,6 +635,7 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
inset-inline-end: 0.35em;
}
.ck-content h1,
.ck-content h2,
.ck-content h3,
.ck-content h4,
@@ -694,7 +695,7 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
outline: none;
box-shadow: 0 0 0 2px var(--link-selection-outline-color);
background: var(--link-hover-background);
}
}
/* Reference link */

View File

@@ -90,15 +90,20 @@
"delete_notes": {
"close": "关闭",
"delete_all_clones_description": "同时删除所有克隆(可以在最近修改中撤消)",
"erase_notes_description": "通常(软)删除仅标记笔记为已删除,可以在一段时间内通过最近修改对话框撤消。选中此选项将立即擦除笔记,不可撤销。",
"erase_notes_description": "立即删除笔记,而不是软删除。此操作无法撤销,并将强制应用程序重新加载。",
"erase_notes_warning": "永久擦除笔记(无法撤销),包括所有克隆。这将强制应用程序重载。",
"notes_to_be_deleted": "删除以下笔记 ({{notesCount}})",
"notes_to_be_deleted": "删除笔记({{notesCount}}",
"no_note_to_delete": "没有笔记将被删除(仅克隆)。",
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{ relationCount}})",
"broken_relations_to_be_deleted": "断开的关联({{relationCount}}",
"cancel": "取消",
"title": "删除笔记",
"delete": "删除",
"clones_label": "克隆"
"clones_label": "克隆",
"delete_clones_description_other": "同时删除 {{count}} 个其他克隆。此操作可在最近的更改中撤销。",
"erase_notes_label": "永久擦除",
"table_note_with_relation": "关联笔记",
"table_relation": "关联",
"table_points_to": "指向(已删除)"
},
"export": {
"export_note_title": "导出笔记",
@@ -209,7 +214,8 @@
"box_size_small": "小型 (显示大约10行)",
"box_size_medium": "中型 (显示大约30行)",
"box_size_full": "完整显示(完整文本框)",
"button_include": "包含笔记"
"button_include": "包含笔记",
"box_size_expandable": "可展开(默认折叠)"
},
"info": {
"modalTitle": "信息消息",
@@ -420,7 +426,9 @@
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "颜色",
"textarea": "多行文本"
"textarea": "多行文本",
"print_scale": "导出为 PDF 时,更改渲染内容的比例。取值范围从 0.1 (10%) 到 2 (200%),默认值为 1 (100%)。",
"print_margins": "导出为 PDF 时,设置页面边距。可以使用 <code>default</code>、<code>none</code>、<code>minimum</code> 或以毫米为单位的自定义值,例如 <code>top,right,bottom,left</code>。"
},
"attribute_editor": {
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
@@ -683,7 +691,12 @@
"export_as_image": "导出为图像",
"export_as_image_png": "PNG栅格",
"export_as_image_svg": "SVG矢量图",
"view_ocr_text": "查看 OCR 文本"
"view_ocr_text": "查看 OCR 文本",
"word_wrap": "自动换行",
"word_wrap_auto": "自动",
"word_wrap_auto_description": "遵循全局设置",
"word_wrap_on": "开启",
"word_wrap_off": "关闭"
},
"onclick_button": {
"no_click_handler": "按钮组件'{{componentId}}'没有定义点击处理程序"
@@ -1050,15 +1063,17 @@
"title": "检查一致性",
"find_and_fix_button": "查找并修复一致性问题",
"finding_and_fixing_message": "正在查找并修复一致性问题...",
"issues_fixed_message": "一致性问题应该已被修复。"
"issues_fixed_message": "一致性问题应该已被修复。",
"find_and_fix_label": "查找并修复一致性问题",
"find_and_fix_description": "扫描并自动修复数据库中的任何数据一致性问题。"
},
"database_anonymization": {
"title": "数据库匿名化",
"full_anonymization": "完全匿名化",
"full_anonymization_description": "此操作将创建一个新的数据库副本并进行匿名化处理(删除所有笔记内容,仅保留结构和一些非敏感元数据),用来分享到网上做调试而不用担心泄漏您的个人资料。",
"full_anonymization_description": "创建数据库副本,移除所有笔记内容,仅保留数据库结构和非敏感元数据。在调试问题时,可安全地在线共享。",
"save_fully_anonymized_database": "保存完全匿名化的数据库",
"light_anonymization": "轻度匿名化",
"light_anonymization_description": "此操作将创建一个新的数据库副本,并对其进行轻度匿名化处理——仅删除所有笔记内容,但保留标题属性。此外,自定义 JS 前端/后端脚本笔记和自定义小部件将保留。这提供更多上下文以调试问题。",
"light_anonymization_description": "创建一个副本,其中移除笔记内容,但保留标题属性和自定义脚本/小部件。这有助于提供更多调试信息。",
"choose_anonymization": "您可以自行决定是提供完全匿名化还是轻度匿名化的数据库。即使是完全匿名化的数据库也非常有用,但在某些情况下,轻度匿名化的数据库可以加快错误识别和修复的过程。",
"save_lightly_anonymized_database": "保存轻度匿名化的数据库",
"existing_anonymized_databases": "现有的匿名化数据库",
@@ -1067,14 +1082,17 @@
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
"no_anonymized_database_yet": "尚无匿名化数据库。"
"no_anonymized_database_yet": "尚无匿名化数据库。",
"description": "创建数据库的匿名副本,以便在调试问题时与开发人员共享,而不会泄露个人数据。"
},
"database_integrity_check": {
"title": "数据库完整性检查",
"check_button": "检查数据库完整性",
"checking_integrity": "正在检查数据库完整性...",
"integrity_check_succeeded": "完整性检查成功 - 未发现问题。",
"integrity_check_failed": "完整性检查失败: {{results}}"
"integrity_check_failed": "完整性检查失败: {{results}}",
"check_integrity_label": "检查数据库完整性",
"check_integrity_description": "验证 SQLite 数据库是否已损坏。"
},
"sync": {
"title": "同步",
@@ -1084,19 +1102,25 @@
"filling_entity_changes": "正在填充实体变更行...",
"sync_rows_filled_successfully": "同步行填充成功",
"finished-successfully": "同步已完成。",
"failed": "同步失败:{{message}}"
"failed": "同步失败:{{message}}",
"force_full_sync_label": "强制全量同步",
"force_full_sync_description": "触发与同步服务器的全量同步,重新上传所有更改。",
"fill_entity_changes_description": "重建实体变更记录。如果同步过程中缺少某些变更,请使用此功能。",
"fill_entity_changes_label": "填充实体变更"
},
"vacuum_database": {
"title": "数据库清理",
"description": "这会重建数据库,通常会减少占用空间,不会删除数据。",
"button_text": "清理数据库",
"vacuuming_database": "正在清理数据库...",
"database_vacuumed": "数据库已清理"
"database_vacuumed": "数据库已清理",
"vacuum_label": "数据库清理",
"vacuum_description": "重建数据库以减小文件大小。数据不会发生任何变化。"
},
"fonts": {
"theme_defined": "跟随主题",
"fonts": "字体",
"main_font": "字体",
"main_font": "界面字体",
"font_family": "字体系列",
"size": "大小",
"note_tree_font": "笔记树字体",
@@ -1111,7 +1135,9 @@
"serif": "衬线",
"sans-serif": "无衬线",
"monospace": "等宽",
"system-default": "系统默认"
"system-default": "系统默认",
"custom_fonts": "使用自定义字体",
"preview": "预览"
},
"max_content_width": {
"title": "内容宽度",
@@ -2292,5 +2318,35 @@
"no_providers_configured": "尚未配置任何供应商。",
"provider_name": "名称",
"provider_type": "供应商"
},
"revisions": {
"note_revisions": "笔记修订",
"delete_all_revisions": "删除此笔记的所有修订版本",
"delete_all_button": "删除所有修订版本",
"help_title": "关于笔记修订的帮助",
"confirm_delete_all": "是否要删除此笔记的所有修订版本?",
"no_revisions": "这篇笔记目前还没有修改……",
"restore_button": "恢复",
"diff_on": "显示差异",
"diff_off": "显示内容",
"diff_on_hint": "点击显示笔记来源差异",
"diff_off_hint": "点击显示笔记内容",
"diff_not_available": "差异数据不可用。",
"confirm_restore": "是否恢复此版本?这将用此版本覆盖笔记的当前标题和内容。",
"delete_button": "删除",
"confirm_delete": "您要删除此修订吗?",
"revisions_deleted": "笔记修订已被删除。",
"revision_restored": "笔记修订已恢复。",
"revision_deleted": "笔记修订已删除。",
"snapshot_interval": "笔记修订快照间隔:{{seconds}}秒。",
"maximum_revisions": "笔记修订快照限制:{{number}}。",
"settings": "笔记修订设置",
"download_button": "下载",
"mime": "MIME 类型: ",
"file_size": "文件大小:",
"preview_not_available": "无法预览此类型的笔记。"
},
"database": {
"title": "数据库"
}
}

View File

@@ -1681,6 +1681,7 @@
"note_types": {
"text": "Text",
"code": "Code",
"markdown": "Markdown",
"saved-search": "Saved Search",
"relation-map": "Relation Map",
"note-map": "Note Map",
@@ -1938,6 +1939,9 @@
"move-to-available-launchers": "Move to available launchers",
"duplicate-launcher": "Duplicate launcher <kbd data-command=\"duplicateSubtree\">"
},
"launcher_button_context_menu": {
"remove_from_launch_bar": "Remove from launch bar"
},
"highlighting": {
"title": "Code Blocks",
"description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.",
@@ -2082,6 +2086,11 @@
"unlock-editing": "Unlock editing",
"lock-editing": "Lock editing"
},
"display_mode": {
"source": "Source view",
"split": "Split view",
"preview": "Preview"
},
"png_export_button": {
"button_title": "Export diagram as PNG"
},

View File

@@ -2446,5 +2446,38 @@
"destination_pdf": "PDF として保存",
"destination_printers": "プリンター",
"destination_default": "デフォルト"
},
"revisions": {
"note_revisions": "ノートの変更履歴",
"delete_all_revisions": "このノートのすべての変更履歴を削除",
"delete_all_button": "すべての変更履歴を削除",
"help_title": "ノートの変更履歴に関するヘルプ",
"confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
"no_revisions": "このノートにはまだ変更履歴がありません...",
"restore_button": "復元",
"diff_on": "差分を表示",
"diff_off": "内容を表示",
"diff_on_hint": "クリックしてノートのソース差分を表示",
"diff_off_hint": "クリックしてノートの内容を表示",
"diff_not_available": "差分は利用できません。",
"confirm_restore": "この変更履歴を復元しますか? 復元すると、ノートの現在のタイトルと内容がこの変更履歴で上書きされます。",
"delete_button": "削除",
"confirm_delete": "この変更履歴を削除しますか?",
"revisions_deleted": "ノートの変更履歴が削除されました。",
"revision_restored": "ノートの変更履歴が復元されました。",
"revision_deleted": "ノートの変更履歴が削除されました。",
"snapshot_interval": "ノートの変更履歴スナップショット間隔: {{seconds}} 秒。",
"maximum_revisions": "ノートの変更履歴スナップショット制限: {{number}}。",
"settings": "ノートの変更履歴設定",
"download_button": "ダウンロード",
"mime": "MIME: ",
"file_size": "ファイルサイズ:",
"preview_not_available": "このノートタイプではプレビューは利用できません。"
},
"revisions_snapshot": {
"title": "ノートの変更履歴"
},
"launcher_button_context_menu": {
"remove_from_launch_bar": "ランチャーバーから削除"
}
}

View File

@@ -20,7 +20,8 @@ import tree from "../services/tree";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import { ViewTypeOptions } from "./collections/interface";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { ButtonGroup } from "./react/Button";
import { useIsNoteReadOnly, useNoteLabel, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml";
@@ -47,8 +48,9 @@ export type FloatingButtonsList = ((context: FloatingButtonContext) => false | V
export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
RefreshBackendLogButton,
SwitchSplitOrientationButton,
ToggleReadOnlyButton,
SwitchSplitOrientationButton,
DisplayModeSwitcher,
EditButton,
ShowTocWidgetButton,
ShowHighlightsListWidgetButton,
@@ -80,9 +82,13 @@ function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefault
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const [ displayMode ] = useNoteLabel(note, "displayMode");
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: isReadOnly ? "preview" : "split";
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && effectiveMode === "split" && isDefaultViewMode;
return isEnabled && <FloatingButton
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
@@ -94,7 +100,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
function ToggleReadOnlyButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
const isEnabled = ([ "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton
@@ -104,6 +110,33 @@ function ToggleReadOnlyButton({ note, isDefaultViewMode }: FloatingButtonContext
/>;
}
function DisplayModeSwitcher({ note, isDefaultViewMode }: FloatingButtonContext) {
const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode");
const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode;
if (!isEnabled) return false;
const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split";
const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [
{ value: "source", icon: "bx bx-code", text: t("display_mode.source") },
{ value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") },
{ value: "preview", icon: "bx bx-show", text: t("display_mode.preview") }
];
return (
<ButtonGroup size="sm">
{buttons.map(({ value, icon, text }) => (
<FloatingButton
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</ButtonGroup>
);
}
function EditButton({ note, noteContext }: FloatingButtonContext) {
const [animationClass, setAnimationClass] = useState("");
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);

View File

@@ -352,7 +352,9 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note
resultingType = "readOnlyText";
} else if (note.isTriliumSqlite()) {
resultingType = "sqlConsole";
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
} else if (note.isMarkdown()) {
resultingType = "markdown";
} else if (type === "code" && (await noteContext?.isReadOnly())) {
resultingType = "readOnlyCode";
} else if (type === "text") {
resultingType = "editableText";

View File

@@ -63,7 +63,7 @@ export default class FindInText {
const findResultElement = editorEl?.querySelectorAll(".ck-find-result");
const scrollingContainer = editorEl?.closest('.scrolling-container');
const containerTop = scrollingContainer?.getBoundingClientRect().top ?? 0;
const closestIndex = Array.from(findResultElement ?? []).findIndex((el) => el.getBoundingClientRect().top >= containerTop);
const closestIndex = Array.from(findResultElement ?? []).findIndex((el: Element) => el.getBoundingClientRect().top >= containerTop);
currentFound = closestIndex >= 0 ? closestIndex : 0;
}
}

View File

@@ -10,11 +10,11 @@ import { useChildNotes, useNote, useNoteIcon, useNoteLabelBoolean } from "../rea
import NoteLink from "../react/NoteLink";
import ResponsiveContainer from "../react/ResponsiveContainer";
import { CustomNoteLauncher, launchCustomNoteLauncher } from "./GenericButtons";
import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
import { LaunchBarContext, LaunchBarDropdownButton, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
const PARENT_NOTE_ID = "_lbBookmarks";
export default function BookmarkButtons() {
export default function BookmarkButtons({ launcherNote }: LauncherNoteProps) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
const style = useMemo<CSSProperties>(() => ({
display: "flex",
@@ -22,20 +22,27 @@ export default function BookmarkButtons() {
contain: "none"
}), [ isHorizontalLayout ]);
const childNotes = useChildNotes(PARENT_NOTE_ID);
const bookmarks = childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />);
const showContextMenu = launcherContextMenuHandler(launcherNote);
return (
<ResponsiveContainer
desktop={
<div style={style}>
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
<div
style={style}
// Only trigger on empty container area; individual bookmark buttons handle their own context menu.
onContextMenu={(e) => e.target === e.currentTarget && showContextMenu?.(e)}
>
{bookmarks}
</div>
}
mobile={
<LaunchBarDropdownButton
launcherNote={launcherNote}
icon="bx bx-bookmark"
title={t("bookmark_buttons.bookmarks")}
>
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
{bookmarks}
</LaunchBarDropdownButton>
}
/>
@@ -90,6 +97,7 @@ function BookmarkFolder({ note }: { note: FNote }) {
return (
<LaunchBarDropdownButton
launcherNote={note}
icon={icon}
title={title}
>

View File

@@ -58,6 +58,7 @@ export default function CalendarWidget({ launcherNote }: LauncherNoteProps) {
return (
<LaunchBarDropdownButton
launcherNote={launcherNote}
icon={icon} title={title}
onShown={async () => {
const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote");

View File

@@ -1,7 +1,8 @@
import { useCallback } from "preact/hooks";
import appContext from "../../components/app_context";
import appContext, { CommandNames } from "../../components/app_context";
import FNote from "../../entities/fnote";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import link_context_menu from "../../menus/link_context_menu";
import { isCtrlKey } from "../../services/utils";
import { useGlobalShortcut, useNoteLabel } from "../react/hooks";
@@ -13,7 +14,7 @@ export function CustomNoteLauncher(props: {
getHoistedNoteId?: (launcherNote: FNote) => string | null;
keyboardShortcut?: string;
}) {
const { launcherNote, getTargetNoteId } = props;
const { launcherNote, getTargetNoteId, getHoistedNoteId } = props;
const { icon, title } = useLauncherIconAndTitle(launcherNote);
const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => {
@@ -31,11 +32,20 @@ export function CustomNoteLauncher(props: {
onClick={launch}
onAuxClick={launch}
onContextMenu={async evt => {
// Must preventDefault synchronously — awaiting getTargetNoteId first would let the
// native browser context menu open before showLauncherContextMenu gets a chance to.
evt.preventDefault();
const targetNoteId = await getTargetNoteId(launcherNote);
if (targetNoteId) {
link_context_menu.openContextMenu(targetNoteId, evt);
}
const hoistedNoteId = getHoistedNoteId?.(launcherNote) ?? null;
const linkItems = targetNoteId ? link_context_menu.getItems(evt) : [];
await showLauncherContextMenu<CommandNames>(launcherNote, evt, {
extraItems: linkItems,
onCommand: (command) => {
if (command && targetNoteId) {
link_context_menu.handleLinkContextMenuItem(command, evt, targetNoteId, {}, hoistedNoteId);
}
}
});
}}
/>
);

View File

@@ -3,6 +3,7 @@ import { useMemo } from "preact/hooks";
import FNote from "../../entities/fnote";
import contextMenu, { MenuCommandItem } from "../../menus/context_menu";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import froca from "../../services/froca";
import link from "../../services/link";
import tree from "../../services/tree";
@@ -25,46 +26,63 @@ export default function HistoryNavigationButton({ launcherNote, command }: Histo
icon={icon}
text={title}
triggerCommand={command}
onContextMenu={webContents ? handleHistoryContextMenu(webContents) : undefined}
onContextMenu={async (e) => {
// Prevent the native menu synchronously before awaiting history items.
e.preventDefault();
const items = webContents ? await getHistoryItems(webContents) : [];
showLauncherContextMenu<string>(launcherNote, e, {
extraItems: items,
onCommand: (cmd) => {
if (cmd && webContents) {
webContents.navigationHistory.goToIndex(parseInt(cmd, 10));
}
}
});
}}
/>
);
}
async function getHistoryItems(webContents: WebContents): Promise<MenuCommandItem<string>[]> {
if (webContents.navigationHistory.length() < 2) return [];
let items: MenuCommandItem<string>[] = [];
const history = webContents.navigationHistory.getAllEntries();
const activeIndex = webContents.navigationHistory.getActiveIndex();
for (const idx in history) {
const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url);
if (!noteId || !notePath) continue;
const title = await tree.getNotePathTitle(notePath);
const index = parseInt(idx, 10);
const note = froca.getNoteFromCache(noteId);
items.push({
title,
command: idx,
checked: index === activeIndex,
enabled: index !== activeIndex,
uiIcon: note?.getIcon()
});
}
items.reverse();
if (items.length > HISTORY_LIMIT) {
items = items.slice(0, HISTORY_LIMIT);
}
return items;
}
export function handleHistoryContextMenu(webContents: WebContents) {
return async (e: MouseEvent) => {
e.preventDefault();
if (!webContents || webContents.navigationHistory.length() < 2) {
return;
}
let items: MenuCommandItem<string>[] = [];
const history = webContents.navigationHistory.getAllEntries();
const activeIndex = webContents.navigationHistory.getActiveIndex();
for (const idx in history) {
const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url);
if (!noteId || !notePath) continue;
const title = await tree.getNotePathTitle(notePath);
const index = parseInt(idx, 10);
const note = froca.getNoteFromCache(noteId);
items.push({
title,
command: idx,
checked: index === activeIndex,
enabled: index !== activeIndex,
uiIcon: note?.getIcon()
});
}
items.reverse();
if (items.length > HISTORY_LIMIT) {
items = items.slice(0, HISTORY_LIMIT);
}
const items = await getHistoryItems(webContents);
if (items.length === 0) return;
contextMenu.show({
x: e.pageX,

View File

@@ -83,13 +83,13 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100");
return <SpacerWidget baseSize={baseSize} growthFactor={growthFactor} />;
return <SpacerWidget launcherNote={note} baseSize={baseSize} growthFactor={growthFactor} />;
case "bookmarks":
return <BookmarkButtons />;
return <BookmarkButtons launcherNote={note} />;
case "protectedSession":
return <ProtectedSessionStatusWidget />;
return <ProtectedSessionStatusWidget launcherNote={note} />;
case "syncStatus":
return <SyncStatus />;
return <SyncStatus launcherNote={note} />;
case "backInHistoryButton":
return <HistoryNavigationButton launcherNote={note} command="backInNoteHistory" />;
case "forwardInHistoryButton":
@@ -97,11 +97,11 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
case "todayInJournal":
return <TodayLauncher launcherNote={note} />;
case "quickSearch":
return <QuickSearchLauncherWidget />;
return <QuickSearchLauncherWidget launcherNote={note} />;
case "mobileTabSwitcher":
return <TabSwitcher />;
return <TabSwitcher launcherNote={note} />;
case "sidebarChat":
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton launcherNote={note} /> : undefined;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -14,7 +14,7 @@ import QuickSearchWidget from "../quick_search";
import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { CustomNoteLauncher } from "./GenericButtons";
import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
import { LaunchBarActionButton, LaunchBarContext, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
export function CommandButton({ launcherNote }: LauncherNoteProps) {
const { icon, title } = useLauncherIconAndTitle(launcherNote);
@@ -22,6 +22,7 @@ export function CommandButton({ launcherNote }: LauncherNoteProps) {
return command && (
<LaunchBarActionButton
launcherNote={launcherNote}
icon={icon}
text={title}
triggerCommand={command as CommandNames}
@@ -74,6 +75,7 @@ export function ScriptLauncher({ launcherNote }: LauncherNoteProps) {
return (
<LaunchBarActionButton
launcherNote={launcherNote}
icon={icon}
text={title}
onClick={launch}
@@ -93,7 +95,7 @@ export function TodayLauncher({ launcherNote }: LauncherNoteProps) {
);
}
export function QuickSearchLauncherWidget() {
export function QuickSearchLauncherWidget({ launcherNote }: LauncherNoteProps) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
const widget = useMemo(() => new QuickSearchWidget(), []);
const parentComponent = useContext(ParentComponent) as BasicWidget | null;
@@ -101,7 +103,7 @@ export function QuickSearchLauncherWidget() {
parentComponent?.contentSized();
return (
<div>
<div onContextMenu={launcherContextMenuHandler(launcherNote)}>
{isEnabled && <LegacyWidgetRenderer widget={widget} />}
</div>
);
@@ -136,7 +138,7 @@ export function CustomWidget({ launcherNote }: LauncherNoteProps) {
}, [ widgetNote ]);
return (
<div>
<div onContextMenu={launcherContextMenuHandler(launcherNote)}>
{widget && (
("type" in widget && widget.type === "preact-launcher-widget")
? <ReactWidgetRenderer widget={widget as LauncherWidgetDefinitionWithType} />

View File

@@ -1,21 +1,23 @@
import { useState } from "preact/hooks";
import protected_session_holder from "../../services/protected_session_holder";
import { LaunchBarActionButton } from "./launch_bar_widgets";
import { LaunchBarActionButton, LauncherNoteProps } from "./launch_bar_widgets";
import { useTriliumEvent } from "../react/hooks";
import { t } from "../../services/i18n";
export default function ProtectedSessionStatusWidget() {
export default function ProtectedSessionStatusWidget({ launcherNote }: LauncherNoteProps) {
const protectedSessionAvailable = useProtectedSessionAvailable();
return (
protectedSessionAvailable ? (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-check-shield"
text={t("protected_session_status.active")}
triggerCommand="leaveProtectedSession"
/>
) : (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-shield-quarter"
text={t("protected_session_status.inactive")}
triggerCommand="enterProtectedSession"

View File

@@ -2,13 +2,13 @@ import { useCallback } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import { LaunchBarActionButton } from "./launch_bar_widgets";
import { LaunchBarActionButton, LauncherNoteProps } from "./launch_bar_widgets";
/**
* Launcher button to open the sidebar (which contains the chat).
* The chat widget is always visible in the sidebar for non-chat notes.
*/
export default function SidebarChatButton() {
export default function SidebarChatButton({ launcherNote }: LauncherNoteProps) {
const handleClick = useCallback(() => {
// Open right pane if hidden, or toggle it if visible
appContext.triggerEvent("toggleRightPane", {});
@@ -16,6 +16,7 @@ export default function SidebarChatButton() {
return (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-message-square-dots"
text={t("sidebar_chat.launcher_title")}
onClick={handleClick}

View File

@@ -1,14 +1,16 @@
import appContext, { CommandNames } from "../../components/app_context";
import contextMenu from "../../menus/context_menu";
import FNote from "../../entities/fnote";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import { t } from "../../services/i18n";
import { isMobile } from "../../services/utils";
interface SpacerWidgetProps {
launcherNote?: FNote;
baseSize?: number;
growthFactor?: number;
}
export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetProps) {
export default function SpacerWidget({ launcherNote, baseSize, growthFactor }: SpacerWidgetProps) {
return (
<div
className="spacer"
@@ -17,19 +19,16 @@ export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetPro
flexGrow: growthFactor ?? 1000,
flexShrink: 1000
}}
onContextMenu={(e) => {
e.preventDefault();
contextMenu.show<CommandNames>({
x: e.pageX,
y: e.pageY,
items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") }],
selectMenuItemHandler: ({ command }) => {
if (command) {
appContext.triggerCommand(command);
}
}
});
}}
onContextMenu={launcherNote ? (e) => showLauncherContextMenu<CommandNames>(launcherNote, e, {
extraItems: [{
title: t("spacer.configure_launchbar"),
command: "showLaunchBarSubtree",
uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar")
}],
onCommand: (command) => {
if (command) appContext.triggerCommand(command);
}
}) : undefined}
/>
)
}

View File

@@ -9,6 +9,7 @@ import sync from "../../services/sync";
import { escapeQuotes } from "../../services/utils";
import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws";
import { useStaticTooltip, useTriliumOption } from "../react/hooks";
import { launcherContextMenuHandler, LauncherNoteProps } from "./launch_bar_widgets";
type SyncState = "unknown" | "in-progress"
| "connected-with-changes" | "connected-no-changes"
@@ -49,7 +50,7 @@ const STATE_MAPPINGS: Record<SyncState, StateMapping> = {
}
};
export default function SyncStatus() {
export default function SyncStatus({ launcherNote }: LauncherNoteProps) {
const syncState = useSyncStatus();
const { title, icon, hasChanges } = STATE_MAPPINGS[syncState];
const spanRef = useRef<HTMLSpanElement>(null);
@@ -60,7 +61,10 @@ export default function SyncStatus() {
});
return (syncServerHost &&
<div class="sync-status-widget launcher-button">
<div
class="sync-status-widget launcher-button"
onContextMenu={launcherContextMenuHandler(launcherNote)}
>
<div class="sync-status">
<span
key={syncState} // Force re-render when state changes to update tooltip content.

View File

@@ -3,6 +3,7 @@ import { createContext } from "preact";
import { useContext } from "preact/hooks";
import FNote from "../../entities/fnote";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import utils from "../../services/utils";
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
import Dropdown, { DropdownProps } from "../react/Dropdown";
@@ -22,7 +23,13 @@ export interface LauncherNoteProps {
launcherNote: FNote;
}
export function LaunchBarActionButton({ className, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition">) {
/** Builds the default right-click handler that shows the launch-bar icon context menu (with the "Remove from launch bar" entry). Used by widgets that render a raw element rather than going through {@link LaunchBarActionButton} / {@link LaunchBarDropdownButton}. */
export function launcherContextMenuHandler(launcherNote: FNote | null | undefined) {
if (!launcherNote) return undefined;
return (e: MouseEvent) => showLauncherContextMenu(launcherNote, e);
}
export function LaunchBarActionButton({ className, launcherNote, onContextMenu, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition"> & { launcherNote?: FNote }) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
return (
@@ -30,15 +37,20 @@ export function LaunchBarActionButton({ className, ...props }: Omit<ActionButton
className={clsx("button-widget launcher-button", className)}
noIconActionClass
titlePosition={getTitlePosition(isHorizontalLayout)}
onContextMenu={onContextMenu ?? launcherContextMenuHandler(launcherNote)}
{...props}
/>
);
}
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef"> & { icon: string }) {
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, launcherNote, buttonProps, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef" | "buttonProps"> & { icon: string, launcherNote?: FNote }) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
const titlePosition = getTitlePosition(isHorizontalLayout);
const resolvedButtonProps = launcherNote && !buttonProps?.onContextMenu
? { ...buttonProps, onContextMenu: launcherContextMenuHandler(launcherNote) }
: buttonProps;
return (
<Dropdown
className="right-dropdown-widget"
@@ -54,6 +66,7 @@ export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...pr
}
}}
mobileBackdrop
buttonProps={resolvedButtonProps}
{...props}
>{children}</Dropdown>
);

View File

@@ -75,6 +75,7 @@ function shouldShow(note: FNote | null | undefined, type: NoteType | undefined,
if (viewScope?.viewMode !== "default") return false;
if (note?.noteId?.startsWith("_options")) return true;
if (note?.isTriliumSqlite()) return false;
if (note?.isMarkdown()) return false;
return type && supportedNoteTypes.has(type);
}

View File

@@ -41,7 +41,7 @@ export default function NoteTypeSwitcher() {
const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]);
const { builtinTemplates, collectionTemplates } = useBuiltinTemplates();
return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() &&
return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() && !note?.isMarkdown() &&
<div
className="note-type-switcher"
onWheel={onWheelHorizontalScroll}

View File

@@ -14,7 +14,7 @@ import froca from "../../services/froca";
import { t } from "../../services/i18n";
import type { ViewMode, ViewScope } from "../../services/link";
import { NoteContent } from "../collections/legacy/ListOrGridView";
import { LaunchBarActionButton } from "../launch_bar/launch_bar_widgets";
import { LaunchBarActionButton, LauncherNoteProps } 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";
@@ -30,13 +30,14 @@ const VIEW_MODE_ICON_MAPPINGS: Record<Exclude<ViewMode, "default">, string> = {
ocr: "bx bx-text"
};
export default function TabSwitcher() {
export default function TabSwitcher({ launcherNote }: LauncherNoteProps) {
const [ shown, setShown ] = useState(false);
const mainNoteContexts = useMainNoteContexts();
return (
<>
<LaunchBarActionButton
launcherNote={launcherNote}
className="mobile-tab-switcher"
icon="bx bx-rectangle"
text="Tabs"

View File

@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "markdown" | "llmChat";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,6 +147,12 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
className: "sql-console-widget-container",
isFullHeight: true
},
markdown: {
view: () => import("./type_widgets/code/Markdown"),
className: "note-detail-markdown",
printable: true,
isFullHeight: true
},
spreadsheet: {
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
className: "note-detail-spreadsheet",

View File

@@ -84,9 +84,9 @@ function Button({ name, buttonRef, className, text, onClick, keyboardShortcut, i
);
}
export function ButtonGroup({ children }: { children: ComponentChildren }) {
export function ButtonGroup({ size, className, children }: { size?: "sm" | "lg"; className?: string; children: ComponentChildren }) {
return (
<div className="btn-group" role="group">
<div className={`btn-group ${size ? `btn-group-${size}` : ""} ${className ?? ""}`} role="group">
{children}
</div>
);

View File

@@ -2,7 +2,7 @@ import { CKTextEditor } from "@triliumnext/ckeditor5";
import { FilterLabelsByType, KeyboardActionNames, NoteType, OptionNames, RelationNames } from "@triliumnext/commons";
import { Tooltip } from "bootstrap";
import Mark from "mark.js";
import { RefObject, VNode } from "preact";
import { Ref, RefObject, VNode } from "preact";
import { CSSProperties, useSyncExternalStore } from "preact/compat";
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
@@ -964,11 +964,13 @@ export function useLegacyImperativeHandlers(handlers: Record<string, Function>)
}, [ handlers ]);
}
export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | null = null): RefObject<T> {
export function useSyncedRef<T>(externalRef?: Ref<T>, initialValue: T | null = null): RefObject<T> {
const ref = useRef<T>(initialValue);
useEffect(() => {
if (externalRef) {
if (typeof externalRef === "function") {
externalRef(ref.current);
} else if (externalRef) {
externalRef.current = ref.current;
}
}, [ ref, externalRef ]);
@@ -1140,6 +1142,29 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
return { isReadOnly, enableEditing, temporarilyEditable };
}
/**
* Synchronous effective read-only state for widgets that honor the `#readOnly` label
* (mermaid, canvas, mind map, spreadsheet). Combines the label with the temporary
* "enable editing" toggle (driven by `readOnlyTemporarilyDisabled`) so clicking the
* read-only badge unlocks the widget.
*/
export function useEffectiveReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) {
const [ readOnlyLabel ] = useNoteLabelBoolean(note, "readOnly");
const [ tempDisabled, setTempDisabled ] = useState<boolean>(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
useEffect(() => {
setTempDisabled(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
}, [ note, noteContext, noteContext?.viewScope ]);
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContext?.ntxId === eventNoteContext?.ntxId) {
setTempDisabled(!!eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled);
}
});
return readOnlyLabel && !tempDisabled;
}
async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {

View File

@@ -75,7 +75,8 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
const noteType = useNoteProperty(note, "type") ?? "";
const [viewType] = useNoteLabel(note, "viewType");
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
const isSourceView = noteContext?.viewScope?.viewMode === "source";
const isSearchable = isSourceView || ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
const isContentAvailable = note.isContentAvailable();

View File

@@ -1,3 +1,31 @@
body.mobile .note-actions-custom:not(:empty) {
margin-bottom: calc(var(--bs-dropdown-divider-margin-y) * 2);
}
body.mobile .note-actions-custom-display-mode {
display: grid;
grid-template-columns: repeat(3, 1fr);
& > .dropdown-item {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.25em;
padding-inline: 0.25em;
font-size: 0.85em;
border-radius: 0 !important;
border: 0 !important;
}
& > .dropdown-item:first-child {
border-start-start-radius: var(--bs-border-radius) !important;
border-end-start-radius: var(--bs-border-radius) !important;
}
& > .dropdown-item:last-child {
border-start-end-radius: var(--bs-border-radius) !important;
border-end-end-radius: var(--bs-border-radius) !important;
}
}

View File

@@ -14,6 +14,7 @@ import { createImageSrcUrl, isMobile, openInAppHelpFromUrl } from "../../service
import { ViewTypeOptions } from "../collections/interface";
import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions";
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
import { ButtonGroup } from "../react/Button";
import { FormFileUploadActionButton, FormFileUploadFormListItem, FormFileUploadProps } from "../react/FormFileUpload";
import { FormListItem } from "../react/FormList";
import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
@@ -72,7 +73,7 @@ export default function NoteActionsCustom(props: NoteActionsCustomProps) {
<AddChildButton {...innerProps} />
<RunActiveNoteButton {...innerProps } />
<SwitchSplitOrientationButton {...innerProps} />
<ToggleReadOnlyButton {...innerProps} />
<DisplayModeSwitcher {...innerProps} />
<SaveToNoteButton {...innerProps} />
<RefreshButton {...innerProps} />
<CopyReferenceToClipboardButton {...innerProps} />
@@ -189,28 +190,66 @@ function RefreshButton({ note, noteType, isDefaultViewMode, parentComponent, not
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const isShown = note.type === "mermaid" && !cachedIsMobile && note.isContentAvailable() && isDefaultViewMode;
const [ displayMode ] = useNoteLabel(note, "displayMode");
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: isReadOnly ? "preview" : "split";
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)}
disabled={isReadOnly}
disabled={effectiveMode !== "split"}
/>;
}
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
function DisplayModeSwitcher({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode");
const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode;
if (!isEnabled) return null;
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)}
/>;
const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split";
const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [
{ value: "source", icon: "bx bx-code", text: t("display_mode.source") },
{ value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") },
{ value: "preview", icon: "bx bx-show", text: t("display_mode.preview") }
];
if (cachedIsMobile) {
return (
<div className="note-actions-custom-display-mode">
{buttons.map(({ value, icon, text }) => (
<NoteAction
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</div>
);
}
return (
<>
<div className="note-actions-custom-spacer" />
<ButtonGroup size="sm">
{buttons.map(({ value, icon, text }) => (
<NoteAction
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</ButtonGroup>
<div className="note-actions-custom-spacer" />
</>
);
}
function RunActiveNoteButton({ noteMime }: NoteActionsCustomInnerProps) {
@@ -268,12 +307,12 @@ function AddChildButton({ parentComponent, noteType, ntxId, isReadOnly }: NoteAc
}
//#endregion
function NoteAction({ text, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand"> & {
function NoteAction({ text, active, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand" | "active"> & {
onClick?: ((e: MouseEvent) => void) | undefined;
}) {
return (cachedIsMobile
? <FormListItem {...props}>{text}</FormListItem>
: <ActionButton text={text} {...props} />
: <ActionButton text={text} active={active} {...props} />
);
}

View File

@@ -98,6 +98,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
const [ state, setState ] = useState<"normal" | "showHelpTooltip" | "showAttributeDetail">();
const [ error, setError ] = useState<unknown>();
const [ needsSaving, setNeedsSaving ] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const suppressNextOnHide = useRef(false);
const lastSavedContent = useRef<string>();
const currentValueRef = useRef(currentValue);
@@ -383,6 +385,12 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
onClick={(e) => {
// Prevent automatic hiding of the context menu due to the button being clicked.
e.stopPropagation();
if (isMenuOpen) {
// If we re-show the menu, ContextMenu.show() will call hide()
// and immediately trigger onHide. Suppress that transient hide.
suppressNextOnHide.current = true;
}
setIsMenuOpen(true);
contextMenu.show<AttributeCommandNames>({
x: e.pageX,
@@ -395,7 +403,14 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
{ title: t("attribute_editor.add_new_label_definition"), command: "addNewLabelDefinition", uiIcon: "bx bx-empty" },
{ title: t("attribute_editor.add_new_relation_definition"), command: "addNewRelationDefinition", uiIcon: "bx bx-empty" }
],
selectMenuItemHandler: (item) => handleAddNewAttributeCommand(item.command)
selectMenuItemHandler: (item) => handleAddNewAttributeCommand(item.command),
onHide: () => {
if (suppressNextOnHide.current) {
suppressNextOnHide.current = false;
return;
}
setIsMenuOpen(false);
},
});
}}
/>
@@ -438,4 +453,4 @@ function getClickIndex(pos: ModelPosition) {
}
return clickIndex;
}
}

View File

@@ -9,7 +9,8 @@ export default function ScrollPadding() {
const isEnabled = ["text", "code"].includes(note?.type ?? "")
&& viewScope?.viewMode === "default"
&& note?.isContentAvailable()
&& !note?.isTriliumSqlite();
&& !note?.isTriliumSqlite()
&& !note?.isMarkdown();
const refreshHeight = () => {
if (!ref.current) return;

View File

@@ -1,6 +1,6 @@
import "./TableOfContents.css";
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement, type ModelNode } from "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
@@ -230,7 +230,7 @@ function extractTocFromTextEditor(editor: CKTextEditor) {
// Fallback to plain text if DOM conversion fails
if (!text) {
text = Array.from( item.getChildren() )
.map( c => c.is( '$text' ) ? c.data : '' )
.map( (c: ModelNode) => c.is( '$text' ) ? c.data : '' )
.join( '' );
}

View File

@@ -30,6 +30,7 @@ import Icon from "../react/Icon";
import Modal from "../react/Modal";
import NoteLink from "../react/NoteLink";
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
import { TextPreview } from "./File";
import { TextRepresentation } from "./ReadOnlyTextRepresentation";
import { TypeWidgetProps } from "./type_widget";
@@ -144,6 +145,7 @@ export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
const contentWrapper = useRef<HTMLDivElement>(null);
const [ ocrModalShown, setOcrModalShown ] = useState(false);
const [ textContent, setTextContent ] = useState<string | null>(null);
const supportsOcr = attachment.role === "image" || attachment.role === "file";
function refresh() {
@@ -151,6 +153,10 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
.then(({ $renderedContent }) => {
contentWrapper.current?.replaceChildren(...$renderedContent);
});
if (attachment.role === "file") {
attachment.getBlob().then(blob => setTextContent(blob?.content ?? null));
}
}
useEffect(refresh, [ attachment ]);
@@ -213,6 +219,7 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
</div>
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
{textContent && <TextPreview content={textContent} />}
<div ref={contentWrapper} className="attachment-content-wrapper" />
</div>

View File

@@ -26,7 +26,7 @@ export default function FileTypeWidget({ note, parentComponent, noteContext }: T
}
function TextPreview({ content }: { content: string }) {
export function TextPreview({ content }: { content: string }) {
const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS);
const isTooLarge = trimmedContent.length !== content.length;

View File

@@ -12,7 +12,7 @@ import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import utils from "../../services/utils";
import { useColorScheme, useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { useColorScheme, useEditorSpacedUpdate, useEffectiveReadOnly, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { refToJQuerySelector } from "../react/react_utils";
import { TypeWidgetProps } from "./type_widget";
@@ -46,7 +46,7 @@ function buildMindElixirLangPack(): LangPack {
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const spacedUpdate = useEditorSpacedUpdate({

View File

@@ -1,7 +1,7 @@
import { Excalidraw } from "@excalidraw/excalidraw";
import { TypeWidgetProps } from "../type_widget";
import "@excalidraw/excalidraw/index.css";
import { useColorScheme, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumOption } from "../../react/hooks";
import { useCallback, useMemo, useRef } from "preact/hooks";
import { type ExcalidrawImperativeAPI, type AppState } from "@excalidraw/excalidraw/types";
import options from "../../../services/options";
@@ -18,7 +18,7 @@ window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excali
export default function Canvas({ note, noteContext }: TypeWidgetProps) {
const apiRef = useRef<ExcalidrawImperativeAPI>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const colorScheme = useColorScheme();
const [ locale ] = useTriliumOption("locale");
const persistence = useCanvasPersistence(note, noteContext, apiRef, colorScheme, isReadOnly);

View File

@@ -2,6 +2,7 @@ import "./code.css";
import { default as VanillaCodeMirror, getThemeById } from "@triliumnext/codemirror";
import { NoteType } from "@triliumnext/commons";
import { Ref } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import appContext, { CommandListenerData } from "../../../components/app_context";
@@ -31,9 +32,11 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
dataSaved?: () => void;
placeholder?: string;
/** Optional external ref to the underlying CodeMirror `EditorView`. Populated once the editor has initialized. */
editorRef?: Ref<VanillaCodeMirror>;
}
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent, editorRef }: TypeWidgetProps & { editorRef?: Ref<VanillaCodeMirror> }) {
const [ content, setContent ] = useState("");
const blob = useNoteBlob(note);
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
@@ -54,6 +57,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi
return (
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
editorRef={editorRef}
className="note-detail-readonly-code-content"
content={content}
mime={note.mime}
@@ -81,9 +85,14 @@ function formatViewSource(note: FNote, content: string) {
return content;
}
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, ...editorProps }: EditableCodeProps) {
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, editorRef: externalEditorRef, ...editorProps }: EditableCodeProps) {
const editorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useRef<HTMLPreElement>(null);
const combinedEditorRef = (view: VanillaCodeMirror | null) => {
editorRef.current = view;
if (typeof externalEditorRef === "function") externalEditorRef(view);
else if (externalEditorRef) externalEditorRef.current = view;
};
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
@@ -122,7 +131,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
<>
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
editorRef={editorRef} containerRef={containerRef}
editorRef={combinedEditorRef} containerRef={containerRef}
mime={mime ?? "text/plain"}
className="note-detail-code-editor"
placeholder={placeholder ?? t("editable_code.placeholder")}
@@ -217,11 +226,13 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)}
useTabs={editorProps.useTabs ?? codeNoteIndentWithTabs}
onInitialized={() => {
if (externalContainerRef && containerRef.current) {
externalContainerRef.current = containerRef.current;
if (containerRef.current) {
if (typeof externalContainerRef === "function") externalContainerRef(containerRef.current);
else if (externalContainerRef) externalContainerRef.current = containerRef.current;
}
if (externalEditorRef && codeEditorRef.current) {
externalEditorRef.current = codeEditorRef.current;
if (codeEditorRef.current) {
if (typeof externalEditorRef === "function") externalEditorRef(codeEditorRef.current);
else if (externalEditorRef) externalEditorRef.current = codeEditorRef.current;
}
initialized.current.resolve();
onInitialized?.();

View File

@@ -1,14 +1,14 @@
import { useEffect, useRef } from "preact/hooks";
import { EditorConfig, default as VanillaCodeMirror } from "@triliumnext/codemirror";
import { useSyncedRef } from "../../react/hooks";
import { RefObject } from "preact";
import { Ref } from "preact";
export interface CodeMirrorProps extends Omit<EditorConfig, "parent"> {
content?: string;
mime: string;
className?: string;
editorRef?: RefObject<VanillaCodeMirror>;
containerRef?: RefObject<HTMLPreElement>;
editorRef?: Ref<VanillaCodeMirror>;
containerRef?: Ref<HTMLPreElement>;
onInitialized?: () => void;
}
@@ -25,9 +25,8 @@ export default function CodeMirror({ className, content, mime, editorRef: extern
...extraOpts
});
codeEditorRef.current = codeEditor;
if (externalEditorRef) {
externalEditorRef.current = codeEditor;
}
if (typeof externalEditorRef === "function") externalEditorRef(codeEditor);
else if (externalEditorRef) externalEditorRef.current = codeEditor;
onInitialized?.();
return () => codeEditor.destroy();

View File

@@ -0,0 +1,34 @@
.note-detail-markdown {
.note-detail-code-editor {
margin-top: 0 !important;
.cm-editor .cm-scroller {
padding-block: 0.25em;
}
}
.markdown-preview {
box-sizing: border-box;
height: 100%;
overflow: auto;
padding: 0.5em 1em;
user-select: text;
.mermaid-diagram,
.mermaid-diagram svg {
max-width: 100%;
}
}
.markdown-preview [data-source-line] {
border-left: 2px solid transparent;
padding-left: 0.5em;
margin-left: -0.5em;
}
[data-source-line].markdown-preview-active {
border-left-color: var(--main-text-color);
}
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { renderWithSourceLines } from "./Markdown.js";
describe("renderWithSourceLines", () => {
function extractLines(html: string): number[] {
return [ ...html.matchAll(/data-source-line="(\d+)"/g) ].map((m) => parseInt(m[1], 10));
}
it("returns empty string for empty input", () => {
expect(renderWithSourceLines("")).toBe("");
});
it("tags a single block as line 1", () => {
const html = renderWithSourceLines("hello");
expect(extractLines(html)).toEqual([ 1 ]);
expect(html).toContain("hello");
});
it("assigns correct source lines to consecutive blocks separated by blank lines", () => {
const src = [
"# Heading", // line 1
"", // line 2
"A paragraph.", // line 3
"", // line 4
"Another one." // line 5
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 3, 5 ]);
});
it("counts multi-line blocks so subsequent blocks get the right line", () => {
const src = [
"```", // 1
"code", // 2
"more code", // 3
"```", // 4
"", // 5
"after" // 6
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 6 ]);
});
it("renders standard markdown constructs inside the wrappers", () => {
const html = renderWithSourceLines("## Heading\n\n- item\n");
expect(html).toContain("<h2>Heading</h2>");
expect(html).toContain("<ul>");
expect(html).toContain("<li>item</li>");
});
it("keeps H1 as H1 in the preview (no title-row context to avoid)", () => {
const html = renderWithSourceLines("# Top level");
expect(html).toContain("<h1>Top level</h1>");
});
it("preserves reference-style links across per-block parsing", () => {
const src = [
"[trilium][t]", // 1
"", // 2
"[t]: https://example.com"
].join("\n");
const html = renderWithSourceLines(src);
expect(html).toContain('href="https://example.com"');
});
it("normalizes fenced code languages to CKEditor MIME identifiers for syntax highlighting", () => {
const html = renderWithSourceLines("```javascript\nconst x = 1;\n```");
expect(html).toMatch(/class="language-application-javascript-env-(backend|frontend)"/);
});
it("produces CKEditor admonition markup for GFM callouts", () => {
const html = renderWithSourceLines("> [!NOTE]\n> heads up");
expect(html).toContain('<aside class="admonition note">');
});
it("preserves the `mermaid` fence language so the mermaid rewrite can match it", () => {
const html = renderWithSourceLines("```mermaid\ngraph TD;\nA-->B;\n```");
expect(html).toContain('class="language-mermaid"');
});
it("produces math-tex spans for inline math", () => {
const html = renderWithSourceLines("Energy: $e=mc^2$.");
expect(html).toContain('<span class="math-tex">');
});
it("renders [[wikilinks]] with hash-router hrefs so the preview navigates correctly", () => {
const html = renderWithSourceLines("See [[abc123]] for details.");
expect(html).toContain('class="reference-link"');
expect(html).toContain('href="#root/abc123"');
});
});

View File

@@ -0,0 +1,193 @@
import "./Markdown.css";
import VanillaCodeMirror from "@triliumnext/codemirror";
import { renderToHtml } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { Marked } from "marked";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import SplitEditor from "../helpers/SplitEditor";
import { ReadOnlyTextContent } from "../text/ReadOnlyText";
import { TypeWidgetProps } from "../type_widget";
const marked = new Marked({ breaks: true, gfm: true });
interface MarkdownContextValue {
html: string;
setEditorView: (view: VanillaCodeMirror | null) => void;
setPreviewEl: (el: HTMLDivElement | null) => void;
}
const MarkdownContext = createContext<MarkdownContextValue | null>(null);
function useMarkdownContext() {
const ctx = useContext(MarkdownContext);
if (!ctx) throw new Error("useMarkdownContext must be used within a Markdown component");
return ctx;
}
export default function Markdown(props: TypeWidgetProps) {
const [ content, setContent ] = useState("");
const [ editorView, setEditorView ] = useState<VanillaCodeMirror | null>(null);
const [ previewEl, setPreviewEl ] = useState<HTMLDivElement | null>(null);
const html = useMemo(() => renderWithSourceLines(content), [ content ]);
useSyncedScrolling(editorView, previewEl);
useSyncedHighlight(editorView, previewEl, html);
const ctx = useMemo<MarkdownContextValue>(
() => ({ html, setEditorView, setPreviewEl }),
[ html ]
);
return (
<MarkdownContext.Provider value={ctx}>
<SplitEditor
noteType="code"
{...props}
editorRef={setEditorView}
onContentChanged={setContent}
previewContent={<MarkdownPreview ntxId={props.ntxId} />}
forceOrientation="horizontal"
/>
</MarkdownContext.Provider>
);
}
function MarkdownPreview({ ntxId }: { ntxId: TypeWidgetProps["ntxId"] }) {
const { html, setPreviewEl } = useMarkdownContext();
return (
<ReadOnlyTextContent
html={html}
ntxId={ntxId}
className="markdown-preview"
contentRef={setPreviewEl}
/>
);
}
//#region Synced scrolling
/**
* One-directional (editor → preview) scroll sync. On editor scroll, finds the
* top visible source line via the CodeMirror `EditorView`, then scrolls the
* preview so the block tagged with that line is at the top — interpolating to
* the next block for smoothness.
*/
function useSyncedScrolling(view: VanillaCodeMirror | null, preview: HTMLDivElement | null) {
useEffect(() => {
if (!view || !preview) return;
const scroller = view.scrollDOM;
function onScroll() {
if (!view || !preview) return;
const topLine = view.state.doc.lineAt(view.lineBlockAtHeight(scroller.scrollTop).from).number;
const blocks = preview.querySelectorAll<HTMLElement>("[data-source-line]");
if (!blocks.length) return;
let before: HTMLElement | null = null;
let after: HTMLElement | null = null;
for (const el of blocks) {
const l = parseInt(el.dataset.sourceLine!, 10);
if (l <= topLine) before = el;
else { after = el; break; }
}
if (!before) { preview.scrollTop = 0; return; }
const previewTop = preview.getBoundingClientRect().top - preview.scrollTop;
const beforeOffset = before.getBoundingClientRect().top - previewTop;
const beforeLine = parseInt(before.dataset.sourceLine!, 10);
if (!after) { preview.scrollTop = beforeOffset; return; }
const afterOffset = after.getBoundingClientRect().top - previewTop;
const afterLine = parseInt(after.dataset.sourceLine!, 10);
const ratio = (topLine - beforeLine) / (afterLine - beforeLine);
preview.scrollTop = beforeOffset + (afterOffset - beforeOffset) * ratio;
}
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => scroller.removeEventListener("scroll", onScroll);
}, [ view, preview ]);
}
/**
* Highlights the preview block that corresponds to the editor's active line,
* matching the built-in `cm-activeLine` behavior. Re-runs when the rendered
* HTML changes so newly inserted blocks pick up the current cursor position.
*/
function useSyncedHighlight(view: VanillaCodeMirror | null, preview: HTMLDivElement | null, html: string) {
useEffect(() => {
if (!view || !preview) return;
let current: HTMLElement | null = null;
function update() {
if (!view || !preview) return;
const activeLine = view.state.doc.lineAt(view.state.selection.main.head).number;
const blocks = preview.querySelectorAll<HTMLElement>("[data-source-line]");
let match: HTMLElement | null = null;
for (const el of blocks) {
if (parseInt(el.dataset.sourceLine!, 10) <= activeLine) match = el;
else break;
}
if (match === current) return;
current?.classList.remove("markdown-preview-active");
match?.classList.add("markdown-preview-active");
current = match;
}
update();
const unsubscribe = view.addUpdateListener((v) => {
if (v.selectionSet || v.docChanged) update();
});
return unsubscribe;
}, [ view, preview, html ]);
}
/** Token types the parser emits but which don't produce top-level block HTML. */
const NON_RENDERED_TOKENS = new Set([ "space", "def" ]);
/**
* Render markdown and tag each top-level block with its 1-indexed source line,
* so the preview can be scrolled to match the editor. Uses the shared
* `renderToHtml` pipeline (admonitions, math, tables, etc.) with DOMPurify for
* sanitization, then walks the rendered DOM and pairs each top-level child
* with the matching lexer token's start line. Marked does not emit source
* positions (markedjs/marked#1267) so we count newlines in `raw` ourselves.
*/
export function renderWithSourceLines(src: string): string {
// Compute the start line of each renderable top-level token in source order.
const tokens = marked.lexer(src);
const lines: number[] = [];
let line = 1;
for (const token of tokens) {
const startLine = line;
line += (token.raw.match(/\n/g) ?? []).length;
if (!NON_RENDERED_TOKENS.has(token.type)) lines.push(startLine);
}
const html = renderToHtml(src, "", {
sanitize: (h) => DOMPurify.sanitize(h),
wikiLink: { formatHref: (id) => `#root/${id}` },
demoteH1: false
});
if (!html) return "";
const container = document.createElement("div");
container.innerHTML = html;
const parts: string[] = [];
const children = Array.from(container.children);
for (let i = 0; i < children.length; i++) {
const sourceLine = lines[i] ?? lines[lines.length - 1] ?? 1;
parts.push(`<div data-source-line="${sourceLine}">${children[i].outerHTML}</div>`);
}
return parts.join("");
}
//#endregion

View File

@@ -17,6 +17,10 @@
flex-grow: 1;
min-width: 0;
min-height: 0;
>* {
height: 100%;
}
}
.note-detail-split .note-detail-split-editor .note-detail-code {
@@ -91,11 +95,26 @@ body.desktop .note-detail-split .note-detail-code-editor {
/* Read-only view */
.note-detail-split.split-read-only .note-detail-split-editor-col {
display: none;
}
.note-detail-split.split-read-only .note-detail-split-preview-col {
width: 100%;
height: 100%;
}
/* Source-only view */
.note-detail-split.split-source-only .note-detail-split-preview-col {
display: none;
}
.note-detail-split.split-source-only .note-detail-split-editor-col {
width: 100%;
height: 100%;
}
/* #region SVG */
.note-detail-split.svg-editor .render-container {
height: 100%;

View File

@@ -8,8 +8,8 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import utils, { isMobile } from "../../../services/utils";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
import Admonition from "../../react/Admonition";
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps } from "../code/Code";
import { useEffectiveReadOnly, useNoteBlob, useNoteLabel, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps, ReadOnlyCode } from "../code/Code";
export interface SplitEditorProps extends EditableCodeProps {
className?: string;
@@ -25,38 +25,60 @@ export interface SplitEditorProps extends EditableCodeProps {
/**
* Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen.
*
* The active view is driven by the `#displayMode` label (`source`, `split`, `preview`); when unset
* it falls back to the `#readOnly` label (truthy → preview, falsy → split). `#displayMode` always
* wins so an explicit choice is never overridden by `#readOnly`. The editor and preview panes are
* always mounted; switching modes only toggles a CSS class so CodeMirror state, scroll position and
* pending edits survive the change.
*
* Features:
*
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default function SplitEditor(props: SplitEditorProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
if (readOnly) {
return <ReadOnlyView {...props} />;
}
return <EditorWithSplit {...props} />;
}
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
export default function SplitEditor({ note, noteContext, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const splitEditorOrientation = useSplitOrientation(forceOrientation);
const [ displayMode ] = useNoteLabel(note, "displayMode");
const readOnly = useEffectiveReadOnly(note, noteContext);
const mode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: readOnly ? "preview" : "split";
const editor = (
// Lazy-mount each pane on first need, then keep it mounted so subsequent switches stay instant.
const editorMounted = useRef(mode !== "preview");
const previewMounted = useRef(mode !== "source");
if (mode !== "preview") editorMounted.current = true;
if (mode !== "source") previewMounted.current = true;
// The editor only feeds content to the preview when it's an `EditableCode`. `ReadOnlyCode`
// doesn't expose `onContentChanged`, and in preview-only mode the editor isn't mounted at all —
// in both cases we read the blob directly so the preview stays populated.
const editorPropagatesContent = editorMounted.current && !readOnly;
const fallbackBlob = useNoteBlob(editorPropagatesContent ? null : note);
const onContentChangedRef = useRef(editorProps.onContentChanged);
useEffect(() => { onContentChangedRef.current = editorProps.onContentChanged; });
useEffect(() => {
if (!editorPropagatesContent && fallbackBlob) {
onContentChangedRef.current?.(fallbackBlob.content ?? "");
}
}, [ fallbackBlob, editorPropagatesContent ]);
const editor = editorMounted.current && (
<div className="note-detail-split-editor-col">
{editorBefore}
<div className="note-detail-split-editor">
<EditableCode
note={note}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>
{readOnly
? <ReadOnlyCode note={note} noteContext={noteContext} {...editorProps} />
: <EditableCode
note={note}
noteContext={noteContext}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>}
</div>
{error && (
<Admonition type="caution" className="note-detail-error-container">
@@ -67,15 +89,17 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
</div>
);
const preview = <PreviewContainer
const preview = previewMounted.current && <PreviewContainer
error={error}
previewContent={previewContent}
previewButtons={previewButtons}
/>;
useEffect(() => {
if (!utils.isDesktop() || !containerRef.current) return;
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
if (mode !== "split" || !utils.isDesktop() || !containerRef.current) return;
// Only the visible (non-display:none) panes participate in the split.
const elements = (Array.from(containerRef.current.children) as HTMLElement[])
.filter(el => el.offsetParent !== null);
const splitInstance = Split(elements, {
rtl: glob.isRtl,
sizes: [ 50, 50 ],
@@ -85,10 +109,14 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
});
return () => splitInstance.destroy();
}, [ splitEditorOrientation ]);
}, [ splitEditorOrientation, mode ]);
const layoutClass = mode === "source" ? "split-source-only"
: mode === "preview" ? "split-read-only"
: `split-${splitEditorOrientation}`;
return (
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
<div ref={containerRef} className={`note-detail-split note-detail-printable ${layoutClass} ${className ?? ""}`}>
{splitEditorOrientation === "horizontal"
? <>{editor}{preview}</>
: <>{preview}{editor}</>}
@@ -96,26 +124,6 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
);
}
function ReadOnlyView({ ...props }: SplitEditorProps) {
const { note, onContentChanged } = props;
const content = useNoteBlob(note);
const onContentChangedRef = useRef(onContentChanged);
useEffect(() => {
onContentChangedRef.current = onContentChanged;
});
useEffect(() => {
onContentChangedRef.current?.(content?.content ?? "");
}, [ content ]);
return (
<div className={`note-detail-split note-detail-printable ${props.className} split-read-only`}>
<PreviewContainer {...props} />
</div>
);
}
function PreviewContainer({ error, previewContent, previewButtons }: {
error?: string | null;
previewContent: ComponentChildren;

View File

@@ -24,12 +24,12 @@ import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
export default function Spreadsheet(props: TypeWidgetProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
const readOnly = useEffectiveReadOnly(props.note, props.noteContext);
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;

View File

@@ -1,24 +1,30 @@
/* h1 should not be used at all since semantically that's a note title */
.note-detail-readonly-text h1 { font-size: 1.8em; }
.note-detail-readonly-text h2 { font-size: 1.6em; }
.note-detail-readonly-text h3 { font-size: 1.4em; }
.note-detail-readonly-text h4 { font-size: 1.2em; }
.note-detail-readonly-text h5 { font-size: 1.1em; }
.note-detail-readonly-text h6 { font-size: 1.0em; }
.note-detail-readonly-text-content {
h1 { font-size: 1.8em; }
h2 { font-size: 1.6em; }
h3 { font-size: 1.4em; }
h4 { font-size: 1.2em; }
h5 { font-size: 1.1em; }
h6 { font-size: 1.0em; }
}
body.heading-style-markdown .note-detail-readonly-text h1::before { content: "#\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h2::before { content: "##\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h3::before { content: "###\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h4:not(.include-note-title)::before { content: "####\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h5::before { content: "#####\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h6::before { content: "######\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text-content {
h1::before { content: "#\2004"; color: var(--muted-text-color); }
h2::before { content: "##\2004"; color: var(--muted-text-color); }
h3::before { content: "###\2004"; color: var(--muted-text-color); }
h4:not(.include-note-title)::before { content: "####\2004"; color: var(--muted-text-color); }
h5::before { content: "#####\2004"; color: var(--muted-text-color); }
h6::before { content: "######\2004"; color: var(--muted-text-color); }
}
body.heading-style-underline .note-detail-readonly-text h1 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h2 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h3 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h4:not(.include-note-title) { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h5 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h6 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text-content {
h1 { border-bottom: 1px solid var(--main-border-color); }
h2 { border-bottom: 1px solid var(--main-border-color); }
h3 { border-bottom: 1px solid var(--main-border-color); }
h4:not(.include-note-title) { border-bottom: 1px solid var(--main-border-color); }
h5 { border-bottom: 1px solid var(--main-border-color); }
h6 { border-bottom: 1px solid var(--main-border-color); }
}
.note-detail-readonly-text {
padding-inline-start: 24px;
@@ -65,4 +71,4 @@ body.mobile .note-detail-readonly-text {
.note-detail-readonly-text-content code.copyable-inline-code:hover {
background-color: var(--accented-background-color);
}
}

View File

@@ -5,7 +5,8 @@ import "./ReadOnlyText.css";
import "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { Ref } from "preact";
import { useEffect, useLayoutEffect, useMemo } from "preact/hooks";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
@@ -13,7 +14,7 @@ import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../
import { getLocaleById } from "../../../services/i18n";
import { renderMathInElement } from "../../../services/math";
import { formatCodeBlocks } from "../../../services/syntax_highlight";
import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { useNoteBlob, useNoteLabel, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { RawHtmlBlock } from "../../react/RawHtml";
import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar";
import { TypeWidgetProps } from "../type_widget";
@@ -22,51 +23,14 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetProps) {
const blob = useNoteBlob(note);
const contentRef = useRef<HTMLDivElement>(null);
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
const { isRtl } = useNoteLanguage(note);
useEffect(() => {
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
}, [codeBlockTabWidth]);
// Apply necessary transforms.
useEffect(() => {
const container = contentRef.current;
if (!container) return;
appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: container });
rewriteMermaidDiagramsInContainer(container);
applyInlineMermaid(container);
applyIncludedNotes(container);
applyMath(container);
applyReferenceLinks(container);
formatCodeBlocks($(container));
setupImageOpening(container, true);
}, [ blob ]);
// React to included note changes.
useTriliumEvent("refreshIncludedNote", ({ noteId }) => {
if (!contentRef.current) return;
refreshIncludedNote(contentRef.current, noteId);
});
// Search integration.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId || !contentRef.current) return;
resolve($(contentRef.current));
});
return (
<>
<RawHtmlBlock
containerRef={contentRef}
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap")}
tabindex={100}
<ReadOnlyTextContent
html={blob?.content ?? ""}
ntxId={ntxId}
dir={isRtl ? "rtl" : "ltr"}
html={blob?.content}
/>
<TouchBar>
@@ -85,6 +49,77 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
);
}
interface ReadOnlyTextContentProps {
/** CKEditor-compatible HTML to render. */
html: string;
/** Note context id — enables `contentElRefreshed` / `executeWithContentElement` integrations when provided. */
ntxId?: string | null;
dir?: "ltr" | "rtl";
/** Extra classes appended to the content div. */
className?: string;
/** Optional external ref to the rendered content div (e.g. to drive scroll sync). */
contentRef?: Ref<HTMLDivElement>;
}
/**
* Renders arbitrary CKEditor-style HTML with the same pipeline as {@link ReadOnlyText}:
* mermaid rewriting, inline mermaid, included-note expansion, KaTeX math, reference-link
* titles, code-block syntax highlighting, and image click handling. Transforms re-run
* whenever `html` changes.
*/
export function ReadOnlyTextContent({ html, ntxId, dir, className, contentRef: externalContentRef }: ReadOnlyTextContentProps) {
const contentRef = useSyncedRef(externalContentRef);
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
useEffect(() => {
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
}, [codeBlockTabWidth]);
// Apply necessary transforms. Runs in a layout effect so the synchronous
// DOM mutations (mermaid rewrite + cached-SVG repaint, math, etc.) happen
// before the browser paints — prevents a flash of raw `<pre>` content
// during live preview re-renders.
useLayoutEffect(() => {
const container = contentRef.current;
if (!container) return;
if (ntxId) {
appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: container });
}
rewriteMermaidDiagramsInContainer(container);
applyInlineMermaid(container);
applyIncludedNotes(container);
applyMath(container);
applyReferenceLinks(container);
formatCodeBlocks($(container));
setupImageOpening(container, true);
}, [ html, ntxId, contentRef ]);
// React to included note changes.
useTriliumEvent("refreshIncludedNote", ({ noteId }) => {
if (!contentRef.current) return;
refreshIncludedNote(contentRef.current, noteId);
});
// Search integration.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (!ntxId || eventNtxId !== ntxId || !contentRef.current) return;
resolve($(contentRef.current));
});
return (
<RawHtmlBlock
containerRef={contentRef}
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap", className)}
tabindex={100}
dir={dir}
html={html}
/>
);
}
function useNoteLanguage(note: FNote) {
const [ language ] = useNoteLabel(note, "language");
const isRtl = useMemo(() => {

View File

@@ -5,7 +5,7 @@
"description": "Tool to compare content of Trilium databases. Useful for debugging sync problems.",
"dependencies": {
"colors": "1.4.0",
"diff": "8.0.4",
"diff": "9.0.0",
"sqlite": "5.1.1",
"sqlite3": "6.0.1"
},

View File

@@ -24,7 +24,7 @@
},
"dependencies": {
"@electron/remote": "2.1.3",
"better-sqlite3": "12.8.0",
"better-sqlite3": "12.9.0",
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1"

View File

@@ -4,7 +4,7 @@
"description": "Standalone tool to dump contents of Trilium document.db file into a directory tree of notes",
"private": true,
"dependencies": {
"better-sqlite3": "12.8.0",
"better-sqlite3": "12.9.0",
"mime-types": "3.0.2",
"sanitize-filename": "1.6.4",
"tsx": "4.21.0",

View File

@@ -5,7 +5,7 @@
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
"dependencies": {
"archiver": "7.0.1",
"better-sqlite3": "12.8.0"
"better-sqlite3": "12.9.0"
},
"devDependencies": {
"@triliumnext/client": "workspace:*",

View File

@@ -6,6 +6,6 @@
"e2e": "playwright test"
},
"devDependencies": {
"dotenv": "17.4.1"
"dotenv": "17.4.2"
}
}

View File

@@ -1,5 +1,5 @@
{
"dependencies": {
"better-sqlite3": "12.8.0"
"better-sqlite3": "12.9.0"
}
}

View File

@@ -31,16 +31,16 @@
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.69",
"@ai-sdk/google": "3.0.62",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/openai": "3.0.52",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.158",
"better-sqlite3": "12.8.0",
"ai": "6.0.159",
"better-sqlite3": "12.9.0",
"html-to-text": "9.0.5",
"js-yaml": "4.1.1",
"node-html-parser": "7.1.0",
"sucrase": "3.35.1",
"unpdf": "1.4.0"
"unpdf": "1.6.0"
},
"devDependencies": {
"@braintree/sanitize-url": "7.1.2",
@@ -118,7 +118,7 @@
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.4",
"sanitize-html": "2.17.2",
"sanitize-html": "2.17.3",
"sax": "1.6.0",
"serve-favicon": "2.5.1",
"stream-throttle": "0.1.3",

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,8 @@
</li>
<li><a href="https://github.com/perfectra1n/triliumnext-mcp">perfectra1n/triliumnext-mcp</a>
</li>
<li><a href="https://github.com/eliassoares/trilium-fastmcp">eliassoares/trilium-fastmcp</a>
</li>
</ul>
<aside class="admonition important">
<p>These solutions are third-party and thus not endorsed or supported directly

View File

@@ -18,7 +18,6 @@
<img style="aspect-ratio:1124/571;" src="2_SQL Console_image.png"
width="1124" height="571">
</figure>
<h3>Interacting with the table</h3>
<p>After executing a query, a table with the results will be displayed:</p>
<ul>

View File

@@ -6,8 +6,7 @@ class="image">
<img style="aspect-ratio:1144/660;" src="Sharing_image.png"
width="1144" height="660">
</figure>
<h2>Features, interaction and limitations</h2>
<h2>Features, interaction and limitations</h2>
<ul>
<li>Searching by note title.</li>
<li>Automatic dark/light mode based on the user's browser settings.</li>
@@ -419,8 +418,7 @@ for (const attr of parentNote.attributes) {
</tr>
</tbody>
</table>
<h3>Customizing logo</h3>
<h3>Customizing logo</h3>
<p>It's possible to adjust the logo which is displayed on the top-left of
the left pane.</p>
<table>

View File

@@ -2,7 +2,6 @@
<img style="aspect-ratio:991/403;" src="1_Jump to_image.png"
width="991" height="403">
</figure>
<h2>Jump to Note</h2>
<p>The <em>Jump to Note</em> function allows easy navigation between notes
by searching for their title. In addition to that, it can also trigger

View File

@@ -4,7 +4,6 @@
<figcaption>Screenshot of the note contextual menu indicating the “Export as PDF”
option.</figcaption>
</figure>
<h2>Printing</h2>
<p>This feature allows printing of notes. It works on both the desktop client,
but also on the web.</p>

View File

@@ -15,8 +15,7 @@ class="image">
<img style="aspect-ratio:1150/27;" src="5_New Layout_image.png"
width="1150" height="27">
</figure>
<h3>Inline title</h3>
<h3>Inline title</h3>
<p>In previous versions of Trilium, the title bar was fixed at all times.
In the new layout, there is both a fixed title bar and one that scrolls
with the text. The newly introduced title is called the <em>Inline title</em> and
@@ -42,8 +41,7 @@ class="image">
width="910" height="104">
<figcaption>The fixed title bar. The title only appears after scrolling past the <em>Inline title</em>.</figcaption>
</figure>
<h3>New note type switcher</h3>
<h3>New note type switcher</h3>
<p>When a new&nbsp;<a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>&nbsp;or&nbsp;
<a
class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;note is created, a note type switcher will appear below
@@ -52,8 +50,7 @@ class="image">
<p>The switcher will disappear as soon as a text is entered.</p>
<img src="6_New Layout_image.png"
width="735" height="143">
<h3>Note badges</h3>
<h3>Note badges</h3>
<p>Note badges appear near the fixed note title and indicate important information
about the note such as whether it is read-only. Some of the badges are
also interactive.</p>

View File

@@ -0,0 +1,31 @@
<p>Split view is a feature of&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;and&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6RM1Q7ppFVoj">Markdown</a>&nbsp;notes which displays both the source code on one side
and the preview of the content on the other.</p>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;also
allow changing between a horizontal or a vertical split, to accommodate
for the various sizes of diagrams.</p>
<h2>Display modes and interaction</h2>
<p>The split comes with three different display modes:</p>
<ul>
<li><em>Split view</em>, in which both the source code is available on one
side and can be edited, and the preview is available on the other side.
<ul>
<li>In this mode, the size of either the source pane or the preview pane can
be adjusted by dragging the small border between them.</li>
</ul>
</li>
<li><em>Source view</em> which shows the source code on the entire screen for
a more focused editing experience.</li>
<li><em>Preview</em> which displays only the rendering of the diagram or text
in full screen, especially useful for read-only notes.</li>
</ul>
<p>These buttons can be found near the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;section
on the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>,
or in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;on
the old layout.</p>
<p>The display node is stored at note level.</p>
<h2>Relation to read-only notes</h2>
<p>If a note is marked as <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/BFs8mudNFgCS/_help_CoFPLs3dRlXc">read-only</a>,
the source view will not be editable. While in preview mode, marking a
note as read-only has no effect since the preview itself is not editable.</p>

View File

@@ -2,7 +2,6 @@
<img style="aspect-ratio:988/572;" src="1_Recent Changes_image.png"
width="988" height="572">
</figure>
<h2>Accessing the recent changes</h2>
<ul>
<li>For an overview of the changes across all documents, press the

View File

@@ -4,8 +4,7 @@ class="image image-style-align-center">
<img style="aspect-ratio:1398/1015;" src="Split View_2_Split View_im.png"
width="1398" height="1015">
</figure>
<h2><strong>Interactions</strong></h2>
<h2><strong>Interactions</strong></h2>
<ul>
<li>Press the
<img src="Split View_Split View_imag.png">button to the right of a note's title to open a new split to the right

View File

@@ -356,7 +356,6 @@
</ul>
<img src="6_Calendar_image.png" width="1217"
height="724">
<h3>Using a different attribute as event title</h3>
<p>By default, events are displayed on the calendar by their note title.
However, it is possible to configure a different attribute to be displayed
@@ -389,7 +388,6 @@ height="724">
</tr>
</tbody>
</table>
<h3>Using a relation attribute as event title</h3>
<p>Similarly to using an attribute, use <code spellcheck="false">#calendar:title</code> and
set it to <code spellcheck="false">name</code> where <code spellcheck="false">name</code> is

View File

@@ -397,7 +397,6 @@ width="1288" height="278">
<img style="aspect-ratio:678/499;" src="12_Geo Map_image.png"
width="678" height="499">
</figure>
<h3>Grid-like artifacts on the map</h3>
<p>This occurs if the application is not at 100% zoom which causes the pixels
of the map to not render correctly due to fractional scaling. The only

View File

@@ -19,7 +19,7 @@
also some backup capabilities by its nature of distributing the data to
other computers.</p>
<h2>Downloading backup</h2>
<p>You can download a existing backup by going to Settings &gt; Backup &gt;
<p>You can download an existing backup by going to Settings &gt; Backup &gt;
Existing backups &gt; Download</p>
<h2>Restoring backup</h2>
<p>Let's assume you want to restore the weekly backup, here's how to do it:</p>

View File

@@ -0,0 +1,517 @@
<p>GNU AFFERO GENERAL PUBLIC LICENSE
<br>Version 3, 19 November 2007</p>
<p>Copyright (C) 2007 Free Software Foundation, Inc. <a href="https://fsf.org/">https://fsf.org/</a>
</p>
<p>Everyone is permitted to copy and distribute verbatim copies of this license
document, but changing it is not allowed.</p>
<h2>Preamble</h2>
<p>The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure cooperation
with the community in the case of network server software.</p>
<p>The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, our
General Public Licenses are intended to guarantee your freedom to share
and change all versions of a program--to make sure it remains free software
for all its users.</p>
<p>When we speak of free software, we are referring to freedom, not price.
Our General Public Licenses are designed to make sure that you have the
freedom to distribute copies of free software (and charge for them if you
wish), that you receive source code or can get it if you want it, that
you can change the software or use pieces of it in new free programs, and
that you know you can do these things.</p>
<p>Developers that use our General Public Licenses protect your rights with
two steps: (1) assert copyright on the software, and (2) offer you this
License which gives you legal permission to copy, distribute and/or modify
the software.</p>
<p>A secondary benefit of defending all users' freedom is that improvements
made in alternate versions of the program, if they receive widespread use,
become available for other developers to incorporate. Many developers of
free software are heartened and encouraged by the resulting cooperation.
However, in the case of software used on network servers, this result may
fail to come about. The GNU General Public License permits making a modified
version and letting the public access it on a server without ever releasing
its source code to the public.</p>
<p>The GNU Affero General Public License is designed specifically to ensure
that, in such cases, the modified source code becomes available to the
community. It requires the operator of a network server to provide the
source code of the modified version running there to the users of that
server. Therefore, public use of a modified version, on a publicly accessible
server, gives the public access to the source code of the modified version.</p>
<p>An older license, called the Affero General Public License and published
by Affero, was designed to accomplish similar goals. This is a different
license, not a version of the Affero GPL, but Affero has released a new
version of the Affero GPL which permits relicensing under this license.</p>
<p>The precise terms and conditions for copying, distribution and modification
follow.</p>
<h2>TERMS AND CONDITIONS</h2>
<h3>0. Definitions.</h3>
<p>"This License" refers to version 3 of the GNU Affero General Public License.</p>
<p>"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.</p>
<p>"The Program" refers to any copyrightable work licensed under this License.
Each licensee is addressed as "you". "Licensees" and "recipients" may be
individuals or organizations.</p>
<p>To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the earlier
work or a work "based on" the earlier work.</p>
<p>A "covered work" means either the unmodified Program or a work based on
the Program.</p>
<p>To "propagate" a work means to do anything with it that, without permission,
would make you directly or secondarily liable for infringement under applicable
copyright law, except executing it on a computer or modifying a private
copy. Propagation includes copying, distribution (with or without modification),
making available to the public, and in some countries other activities
as well.</p>
<p>To "convey" a work means any kind of propagation that enables other parties
to make or receive copies. Mere interaction with a user through a computer
network, with no transfer of a copy, is not conveying.</p>
<p>An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible feature
that (1) displays an appropriate copyright notice, and (2) tells the user
that there is no warranty for the work (except to the extent that warranties
are provided), that licensees may convey the work under this License, and
how to view a copy of this License. If the interface presents a list of
user commands or options, such as a menu, a prominent item in the list
meets this criterion.</p>
<h3>1. Source Code.</h3>
<p>The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.</p>
<p>A "Standard Interface" means an interface that either is an official standard
defined by a recognized standards body, or, in the case of interfaces specified
for a particular programming language, one that is widely used among developers
working in that language.</p>
<p>The "System Libraries" of an executable work include anything, other than
the work as a whole, that (a) is included in the normal form of packaging
a Major Component, but which is not part of that Major Component, and (b)
serves only to enable use of the work with that Major Component, or to
implement a Standard Interface for which an implementation is available
to the public in source code form. A "Major Component", in this context,
means a major essential component (kernel, window system, and so on) of
the specific operating system (if any) on which the executable work runs,
or a compiler used to produce the work, or an object code interpreter used
to run it.</p>
<p>The "Corresponding Source" for a work in object code form means all the
source code needed to generate, install, and (for an executable work) run
the object code and to modify the work, including scripts to control those
activities. However, it does not include the work's System Libraries, or
general-purpose tools or generally available free programs which are used
unmodified in performing those activities but which are not part of the
work. For example, Corresponding Source includes interface definition files
associated with source files for the work, and the source code for shared
libraries and dynamically linked subprograms that the work is specifically
designed to require, such as by intimate data communication or control
flow between those subprograms and other parts of the work.</p>
<p>The Corresponding Source need not include anything that users can regenerate
automatically from other parts of the Corresponding Source.</p>
<p>The Corresponding Source for a work in source code form is that same work.</p>
<h3>2. Basic Permissions.</h3>
<p>All rights granted under this License are granted for the term of copyright
on the Program, and are irrevocable provided the stated conditions are
met. This License explicitly affirms your unlimited permission to run the
unmodified Program. The output from running a covered work is covered by
this License only if the output, given its content, constitutes a covered
work. This License acknowledges your rights of fair use or other equivalent,
as provided by copyright law.</p>
<p>You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having them
make modifications exclusively for you, or provide you with facilities
for running those works, provided that you comply with the terms of this
License in conveying all material for which you do not control copyright.
Those thus making or running the covered works for you must do so exclusively
on your behalf, under your direction and control, on terms that prohibit
them from making any copies of your copyrighted material outside their
relationship with you.</p>
<p>Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.</p>
<h3>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h3>
<p>No covered work shall be deemed part of an effective technological measure
under any applicable law fulfilling obligations under article 11 of the
WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting
or restricting circumvention of such measures.</p>
<p>When you convey a covered work, you waive any legal power to forbid circumvention
of technological measures to the extent such circumvention is effected
by exercising rights under this License with respect to the covered work,
and you disclaim any intention to limit operation or modification of the
work as a means of enforcing, against the work's users, your or third parties'
legal rights to forbid circumvention of technological measures.</p>
<h3>4. Conveying Verbatim Copies.</h3>
<p>You may convey verbatim copies of the Program's source code as you receive
it, in any medium, provided that you conspicuously and appropriately publish
on each copy an appropriate copyright notice; keep intact all notices stating
that this License and any non-permissive terms added in accord with section
7 apply to the code; keep intact all notices of the absence of any warranty;
and give all recipients a copy of this License along with the Program.</p>
<p>You may charge any price or no price for each copy that you convey, and
you may offer support or warranty protection for a fee.</p>
<h3>5. Conveying Modified Source Versions.</h3>
<p>You may convey a work based on the Program, or the modifications to produce
it from the Program, in the form of source code under the terms of section
4, provided that you also meet all of these conditions:</p>
<ul>
<li>a) The work must carry prominent notices stating that you modified it,
and giving a relevant date.</li>
<li>b) The work must carry prominent notices stating that it is released under
this License and any conditions added under section 7. This requirement
modifies the requirement in section 4 to "keep intact all notices".</li>
<li>c) You must license the entire work, as a whole, under this License to
anyone who comes into possession of a copy. This License will therefore
apply, along with any applicable section 7 additional terms, to the whole
of the work, and all its parts, regardless of how they are packaged. This
License gives no permission to license the work in any other way, but it
does not invalidate such permission if you have separately received it.</li>
<li>d) If the work has interactive user interfaces, each must display Appropriate
Legal Notices; however, if the Program has interactive interfaces that
do not display Appropriate Legal Notices, your work need not make them
do so.</li>
</ul>
<p>A compilation of a covered work with other separate and independent works,
which are not by their nature extensions of the covered work, and which
are not combined with it such as to form a larger program, in or on a volume
of a storage or distribution medium, is called an "aggregate" if the compilation
and its resulting copyright are not used to limit the access or legal rights
of the compilation's users beyond what the individual works permit. Inclusion
of a covered work in an aggregate does not cause this License to apply
to the other parts of the aggregate.</p>
<h3>6. Conveying Non-Source Forms.</h3>
<p>You may convey a covered work in object code form under the terms of sections
4 and 5, provided that you also convey the machine-readable Corresponding
Source under the terms of this License, in one of these ways:</p>
<ul>
<li>a) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by the Corresponding Source
fixed on a durable physical medium customarily used for software interchange.</li>
<li>b) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by a written offer, valid
for at least three years and valid for as long as you offer spare parts
or customer support for that product model, to give anyone who possesses
the object code either (1) a copy of the Corresponding Source for all the
software in the product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no more than
your reasonable cost of physically performing this conveying of source,
or (2) access to copy the Corresponding Source from a network server at
no charge.</li>
<li>c) Convey individual copies of the object code with a copy of the written
offer to provide the Corresponding Source. This alternative is allowed
only occasionally and noncommercially, and only if you received the object
code with such an offer, in accord with subsection 6b.</li>
<li>d) Convey the object code by offering access from a designated place (gratis
or for a charge), and offer equivalent access to the Corresponding Source
in the same way through the same place at no further charge. You need not
require recipients to copy the Corresponding Source along with the object
code. If the place to copy the object code is a network server, the Corresponding
Source may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain clear
directions next to the object code saying where to find the Corresponding
Source. Regardless of what server hosts the Corresponding Source, you remain
obligated to ensure that it is available for as long as needed to satisfy
these requirements.</li>
<li>e) Convey the object code using peer-to-peer transmission, provided you
inform other peers where the object code and Corresponding Source of the
work are being offered to the general public at no charge under subsection
6d.</li>
</ul>
<p>A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be included
in conveying the object code work.</p>
<p>A "User Product" is either (1) a "consumer product", which means any tangible
personal property which is normally used for personal, family, or household
purposes, or (2) anything designed or sold for incorporation into a dwelling.
In determining whether a product is a consumer product, doubtful cases
shall be resolved in favor of coverage. For a particular product received
by a particular user, "normally used" refers to a typical or common use
of that class of product, regardless of the status of the particular user
or of the way in which the particular user actually uses, or expects or
is expected to use, the product. A product is a consumer product regardless
of whether the product has substantial commercial, industrial or non-consumer
uses, unless such uses represent the only significant mode of use of the
product.</p>
<p>"Installation Information" for a User Product means any methods, procedures,
authorization keys, or other information required to install and execute
modified versions of a covered work in that User Product from a modified
version of its Corresponding Source. The information must suffice to ensure
that the continued functioning of the modified object code is in no case
prevented or interfered with solely because modification has been made.</p>
<p>If you convey an object code work under this section in, or with, or specifically
for use in, a User Product, and the conveying occurs as part of a transaction
in which the right of possession and use of the User Product is transferred
to the recipient in perpetuity or for a fixed term (regardless of how the
transaction is characterized), the Corresponding Source conveyed under
this section must be accompanied by the Installation Information. But this
requirement does not apply if neither you nor any third party retains the
ability to install modified object code on the User Product (for example,
the work has been installed in ROM).</p>
<p>The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to
a network may be denied when the modification itself materially and adversely
affects the operation of the network or violates the rules and protocols
for communication across the network.</p>
<p>Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly documented
(and with an implementation available to the public in source code form),
and must require no special password or key for unpacking, reading or copying.</p>
<h3>7. Additional Terms.</h3>
<p>"Additional permissions" are terms that supplement the terms of this License
by making exceptions from one or more of its conditions. Additional permissions
that are applicable to the entire Program shall be treated as though they
were included in this License, to the extent that they are valid under
applicable law. If additional permissions apply only to part of the Program,
that part may be used separately under those permissions, but the entire
Program remains governed by this License without regard to the additional
permissions.</p>
<p>When you convey a copy of a covered work, you may at your option remove
any additional permissions from that copy, or from any part of it. (Additional
permissions may be written to require their own removal in certain cases
when you modify the work.) You may place additional permissions on material,
added by you to a covered work, for which you have or can give appropriate
copyright permission.</p>
<p>Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:</p>
<ul>
<li>a) Disclaiming warranty or limiting liability differently from the terms
of sections 15 and 16 of this License; or</li>
<li>b) Requiring preservation of specified reasonable legal notices or author
attributions in that material or in the Appropriate Legal Notices displayed
by works containing it; or</li>
<li>c) Prohibiting misrepresentation of the origin of that material, or requiring
that modified versions of such material be marked in reasonable ways as
different from the original version; or</li>
<li>d) Limiting the use for publicity purposes of names of licensors or authors
of the material; or</li>
<li>e) Declining to grant rights under trademark law for use of some trade
names, trademarks, or service marks; or</li>
<li>f) Requiring indemnification of licensors and authors of that material
by anyone who conveys the material (or modified versions of it) with contractual
assumptions of liability to the recipient, for any liability that these
contractual assumptions directly impose on those licensors and authors.</li>
</ul>
<p>All other non-permissive additional terms are considered "further restrictions"
within the meaning of section 10. If the Program as you received it, or
any part of it, contains a notice stating that it is governed by this License
along with a term that is a further restriction, you may remove that term.
If a license document contains a further restriction but permits relicensing
or conveying under this License, you may add to a covered work material
governed by the terms of that license document, provided that the further
restriction does not survive such relicensing or conveying.</p>
<p>If you add terms to a covered work in accord with this section, you must
place, in the relevant source files, a statement of the additional terms
that apply to those files, or a notice indicating where to find the applicable
terms.</p>
<p>Additional terms, permissive or non-permissive, may be stated in the form
of a separately written license, or stated as exceptions; the above requirements
apply either way.</p>
<h3>8. Termination.</h3>
<p>You may not propagate or modify a covered work except as expressly provided
under this License. Any attempt otherwise to propagate or modify it is
void, and will automatically terminate your rights under this License (including
any patent licenses granted under the third paragraph of section 11).</p>
<p>However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally, unless
and until the copyright holder explicitly and finally terminates your license,
and (b) permanently, if the copyright holder fails to notify you of the
violation by some reasonable means prior to 60 days after the cessation.</p>
<p>Moreover, your license from a particular copyright holder is reinstated
permanently if the copyright holder notifies you of the violation by some
reasonable means, this is the first time you have received notice of violation
of this License (for any work) from that copyright holder, and you cure
the violation prior to 30 days after your receipt of the notice.</p>
<p>Termination of your rights under this section does not terminate the licenses
of parties who have received copies or rights from you under this License.
If your rights have been terminated and not permanently reinstated, you
do not qualify to receive new licenses for the same material under section
10.</p>
<h3>9. Acceptance Not Required for Having Copies.</h3>
<p>You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work occurring
solely as a consequence of using peer-to-peer transmission to receive a
copy likewise does not require acceptance. However, nothing other than
this License grants you permission to propagate or modify any covered work.
These actions infringe copyright if you do not accept this License. Therefore,
by modifying or propagating a covered work, you indicate your acceptance
of this License to do so.</p>
<h3>10. Automatic Licensing of Downstream Recipients.</h3>
<p>Each time you convey a covered work, the recipient automatically receives
a license from the original licensors, to run, modify and propagate that
work, subject to this License. You are not responsible for enforcing compliance
by third parties with this License.</p>
<p>An "entity transaction" is a transaction transferring control of an organization,
or substantially all assets of one, or subdividing an organization, or
merging organizations. If propagation of a covered work results from an
entity transaction, each party to that transaction who receives a copy
of the work also receives whatever licenses to the work the party's predecessor
in interest had or could give under the previous paragraph, plus a right
to possession of the Corresponding Source of the work from the predecessor
in interest, if the predecessor has it or can get it with reasonable efforts.</p>
<p>You may not impose any further restrictions on the exercise of the rights
granted or affirmed under this License. For example, you may not impose
a license fee, royalty, or other charge for exercise of rights granted
under this License, and you may not initiate litigation (including a cross-claim
or counterclaim in a lawsuit) alleging that any patent claim is infringed
by making, using, selling, offering for sale, or importing the Program
or any portion of it.</p>
<h3>11. Patents.</h3>
<p>A "contributor" is a copyright holder who authorizes use under this License
of the Program or a work on which the Program is based. The work thus licensed
is called the contributor's "contributor version".</p>
<p>A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or hereafter
acquired, that would be infringed by some manner, permitted by this License,
of making, using, or selling its contributor version, but do not include
claims that would be infringed only as a consequence of further modification
of the contributor version. For purposes of this definition, "control"
includes the right to grant patent sublicenses in a manner consistent with
the requirements of this License.</p>
<p>Each contributor grants you a non-exclusive, worldwide, royalty-free patent
license under the contributor's essential patent claims, to make, use,
sell, offer for sale, import and otherwise run, modify and propagate the
contents of its contributor version.</p>
<p>In the following three paragraphs, a "patent license" is any express agreement
or commitment, however denominated, not to enforce a patent (such as an
express permission to practice a patent or covenant not to sue for patent
infringement). To "grant" such a patent license to a party means to make
such an agreement or commitment not to enforce a patent against the party.</p>
<p>If you convey a covered work, knowingly relying on a patent license, and
the Corresponding Source of the work is not available for anyone to copy,
free of charge and under the terms of this License, through a publicly
available network server or other readily accessible means, then you must
either (1) cause the Corresponding Source to be so available, or (2) arrange
to deprive yourself of the benefit of the patent license for this particular
work, or (3) arrange, in a manner consistent with the requirements of this
License, to extend the patent license to downstream recipients. "Knowingly
relying" means you have actual knowledge that, but for the patent license,
your conveying the covered work in a country, or your recipient's use of
the covered work in a country, would infringe one or more identifiable
patents in that country that you have reason to believe are valid.</p>
<p>If, pursuant to or in connection with a single transaction or arrangement,
you convey, or propagate by procuring conveyance of, a covered work, and
grant a patent license to some of the parties receiving the covered work
authorizing them to use, propagate, modify or convey a specific copy of
the covered work, then the patent license you grant is automatically extended
to all recipients of the covered work and works based on it.</p>
<p>A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically granted
under this License. You may not convey a covered work if you are a party
to an arrangement with a third party that is in the business of distributing
software, under which you make payment to the third party based on the
extent of your activity of conveying the work, and under which the third
party grants, to any of the parties who would receive the covered work
from you, a discriminatory patent license (a) in connection with copies
of the covered work conveyed by you (or copies made from those copies),
or (b) primarily for and in connection with specific products or compilations
that contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.</p>
<p>Nothing in this License shall be construed as excluding or limiting any
implied license or other defenses to infringement that may otherwise be
available to you under applicable patent law.</p>
<h3>12. No Surrender of Others' Freedom.</h3>
<p>If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not convey it at all. For example, if you agree to terms that obligate
you to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this License
would be to refrain entirely from conveying the Program.</p>
<h3>13. Remote Network Interaction; Use with the GNU General Public License.</h3>
<p>Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users interacting
with it remotely through a computer network (if your version supports such
interaction) an opportunity to receive the Corresponding Source of your
version by providing access to the Corresponding Source from a network
server at no charge, through some standard or customary means of facilitating
copying of software. This Corresponding Source shall include the Corresponding
Source for any work covered by version 3 of the GNU General Public License
that is incorporated pursuant to the following paragraph.</p>
<p>Notwithstanding any other provision of this License, you have permission
to link or combine any covered work with a work licensed under version
3 of the GNU General Public License into a single combined work, and to
convey the resulting work. The terms of this License will continue to apply
to the part which is the covered work, but the work with which it is combined
will remain governed by version 3 of the GNU General Public License.</p>
<h3>14. Revised Versions of this License.</h3>
<p>The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail
to address new problems or concerns.</p>
<p>Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU Affero General Public
License "or any later version" applies to it, you have the option of following
the terms and conditions either of that numbered version or of any later
version published by the Free Software Foundation. If the Program does
not specify a version number of the GNU Affero General Public License,
you may choose any version ever published by the Free Software Foundation.</p>
<p>If the Program specifies that a proxy can decide which future versions
of the GNU Affero General Public License can be used, that proxy's public
statement of acceptance of a version permanently authorizes you to choose
that version for the Program.</p>
<p>Later license versions may give you additional or different permissions.
However, no additional obligations are imposed on any author or copyright
holder as a result of your choosing to follow a later version.</p>
<h3>15. Disclaimer of Warranty.</h3>
<p>THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND,
EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE
ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.
SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY
SERVICING, REPAIR OR CORRECTION.</p>
<h3>16. Limitation of Liability.</h3>
<p>IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS
OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY
OF SUCH DAMAGES.</p>
<h3>17. Interpretation of Sections 15 and 16.</h3>
<p>If the disclaimer of warranty and limitation of liability provided above
cannot be given local legal effect according to their terms, reviewing
courts shall apply local law that most closely approximates an absolute
waiver of all civil liability in connection with the Program, unless a
warranty or assumption of liability accompanies a copy of the Program in
return for a fee.</p>
<p>END OF TERMS AND CONDITIONS</p>
<h2>How to Apply These Terms to Your New Programs</h2>
<p>If you develop a new program, and you want it to be of the greatest possible
use to the public, the best way to achieve this is to make it free software
which everyone can redistribute and change under these terms.</p>
<p>To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the "copyright"
line and a pointer to where the full notice is found.</p><pre><code class="language-text-x-trilium-auto"> &lt;one line to give the program's name and a brief idea of what it does.&gt;
Copyright (C) &lt;year&gt; &lt;name of author&gt;
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.</code></pre>
<p>Also add information on how to contact you by electronic and paper mail.</p>
<p>If your software can interact with users remotely through a computer network,
you should also make sure that it provides a way for users to get its source.
For example, if your program is a web application, its interface could
display a "Source" link that leads users to an archive of the code. There
are many ways you could offer source, and different solutions will be better
for different programs; see section 13 for the specific requirements.</p>
<p>You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL,
see <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.</p>

View File

@@ -9,7 +9,8 @@
note where to place the new one and select:</p>
<ul>
<li><em>Insert note after</em>, to put the new note underneath the one selected.</li>
<li><em>Insert child note</em>, to insert the note as a child of the selected
<li
><em>Insert child note</em>, to insert the note as a child of the selected
note.</li>
</ul>
<p>
@@ -20,7 +21,8 @@
<li>When adding a <a href="#root/_help_QEAPj01N5f7w">link</a> in a&nbsp;<a class="reference-link"
href="#root/_help_iPIMuisry3hd">Text</a>&nbsp;note, type the desired title of
the new note and press Enter. Afterwards the type of the note will be asked.</li>
<li>Similarly, when creating a new tab, type the desired title and press Enter.</li>
<li
>Similarly, when creating a new tab, type the desired title and press Enter.</li>
</ul>
<h2>Changing the type of a note</h2>
<p>It is possible to change the type of a note after it has been created
@@ -30,94 +32,96 @@
edit the <a href="#root/_help_4FahAwuGTAwC">source of a note</a>.</p>
<h2>Supported note types</h2>
<p>The following note types are supported by Trilium:</p>
<table>
<thead>
<tr>
<th>Note Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
</td>
<td>The default note type, which allows for rich text formatting, images,
admonitions and right-to-left support.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
</td>
<td>Uses a mono-space font and can be used to store larger chunks of code
or plain text than a text note, and has better syntax highlighting.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
</td>
<td>Stores the information about a search (the search text, criteria, etc.)
for later use. Can be used for quick filtering of a large amount of notes,
for example. The search can easily be triggered.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
</td>
<td>Allows easy creation of notes and relations between them. Can be used
for mainly relational data such as a family tree.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
</td>
<td>Displays the relationships between the notes, whether via relations or
their hierarchical structure.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
</td>
<td>Used in&nbsp;<a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
it displays the HTML content of another note. This allows displaying any
kind of content, provided there is a script behind it to generate it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
</td>
<td>Displays the children of the note either as a grid, a list, or for a more
specialized case: a calendar.&nbsp;
<br>
<br>Generally useful for easy reading of short notes.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
</td>
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
Requires a bit of technical knowledge since the diagrams are written in
a specialized format.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
</td>
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
</td>
<td>Displays the content of an external web page, similar to a browser.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
</td>
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
</td>
<td>Displays the children of the note as a geographical map, one use-case
would be to plan vacations. It even has basic support for tracks. Notes
can also be created from it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
</td>
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
</tr>
</tbody>
</table>
<figure class="table">
<table>
<thead>
<tr>
<th>Note Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
</td>
<td>The default note type, which allows for rich text formatting, images,
admonitions and right-to-left support.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
</td>
<td>Uses a mono-space font and can be used to store larger chunks of code
or plain text than a text note, and has better syntax highlighting.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
</td>
<td>Stores the information about a search (the search text, criteria, etc.)
for later use. Can be used for quick filtering of a large amount of notes,
for example. The search can easily be triggered.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
</td>
<td>Allows easy creation of notes and relations between them. Can be used
for mainly relational data such as a family tree.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
</td>
<td>Displays the relationships between the notes, whether via relations or
their hierarchical structure.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
</td>
<td>Used in&nbsp;<a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
it displays the HTML content of another note. This allows displaying any
kind of content, provided there is a script behind it to generate it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
</td>
<td>Displays the children of the note either as a grid, a list, or for a more
specialized case: a calendar.&nbsp;&nbsp;
<br>
<br>Generally useful for easy reading of short notes.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
</td>
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
Requires a bit of technical knowledge since the diagrams are written in
a specialized format.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
</td>
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
</td>
<td>Displays the content of an external web page, similar to a browser.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
</td>
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
</td>
<td>Displays the children of the note as a geographical map, one use-case
would be to plan vacations. It even has basic support for tracks. Notes
can also be created from it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
</td>
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
</tr>
</tbody>
</table>
</figure>

View File

@@ -5,7 +5,8 @@
create a <em>File</em> note type directly:</p>
<ul>
<li>Drag a file into the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</li>
<li>Right click a note and select <em>Import into note</em> and point it to
<li
>Right click a note and select <em>Import into note</em> and point it to
one of the supported files.</li>
</ul>
<h2>Supported file types</h2>
@@ -82,28 +83,30 @@
href="#root/_help_BlN9DFI679QC">Ribbon</a>.
<ul>
<li><em>Download</em>, which will download the file for local use.</li>
<li><em>Open</em>, will will open the file with the system-default application.</li>
<li>Upload new revision to replace the file with a new one.</li>
<li
><em>Open</em>, will will open the file with the system-default application.</li>
<li
>Upload new revision to replace the file with a new one.</li>
</ul>
</li>
<li>It is <strong>not</strong> possible to change the note type of a <em>File</em> note.</li>
<li>Convert into an <a href="#root/_help_0vhv7lsOLy82">attachment</a> from the <a href="#root/_help_8YBEPzcpUgxw">note menu</a>.</li>
</li>
<li>It is <strong>not</strong> possible to change the note type of a <em>File</em> note.</li>
<li
>Convert into an <a href="#root/_help_0vhv7lsOLy82">attachment</a> from the <a href="#root/_help_8YBEPzcpUgxw">note menu</a>.</li>
</ul>
<h2>Relation with other notes</h2>
<ul>
<li>
<p>Files are also displayed in the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;based
on their type:</p>
<img class="image_resized" style="aspect-ratio:853/315;width:50%;"
src="4_File_image.png" width="853" height="315">
</li>
<li>
<p>Non-image files can be embedded into text notes as read-only widgets via
the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;functionality.</p>
</li>
<li>
<p>Image files can be embedded into text notes like normal images via&nbsp;
<a
class="reference-link" href="#root/_help_0Ofbk1aSuVRu">Image references</a>.</p>
<p>
<img class="image_resized" style="aspect-ratio:853/315;width:50%;" src="4_File_image.png"
width="853" height="315">
</p>
</li>
<li>Non-image files can be embedded into text notes as read-only widgets via
the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;functionality.</li>
<li
>Image files can be embedded into text notes like normal images via&nbsp;
<a
class="reference-link" href="#root/_help_0Ofbk1aSuVRu">Image references</a>.</li>
</ul>

View File

@@ -0,0 +1,140 @@
<p>Trilium has always supported Markdown through its <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/mHbBMPDPkVV5/_help_Oau6X9rCuegd">import feature</a>,
however the file was either transformed to a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note
(converted to Trilium's internal HTML format) or saved as a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;note
with only syntax highlight.</p>
<p>This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_SL5f1Auq7sVN">Note types with split view</a>&nbsp;for
more information.</p>
<h2>Rationale</h2>
<p>The goal of this note type is to fill a gap: rendering Markdown but not
altering its structure or its whitespace which would inevitably change
otherwise through import/export.</p>
<p>Even if Markdown is now specially treated by having a preview mechanism,
Trilium remains at its core a WYSWYG editor so Markdown will not replace
text notes.</p>
<aside class="admonition note">
<p>Feature requests regarding the Markdown implementation will be considered,
but if they are outside the realm of Trilium they will not be implemented.
One of the core aspects of the Markdown integration is that it reuses components
that are already available through other features of the application.</p>
</aside>
<h2>Features</h2>
<h3>Source view pane</h3>
<ul>
<li>Syntax highlighting for the Markdown syntax.</li>
<li>Nested syntax highlighting for code inside code blocks.</li>
<li>When editing larger documents, the preview scrolls along with the source
editor.</li>
</ul>
<h3>Preview pane</h3>
<p>The following features are supported by Trilium's Markdown format and
will show up in the preview pane:</p>
<ul>
<li>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</li>
<li
>Code blocks with syntax highlight (e.g. <code spellcheck="false">```js</code>)
and automatic syntax highlight</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<code
spellcheck="false">```mermaid</code>
</li>
<li>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
builtin Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
&amp;nbsp;
&lt;/section&gt;n</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/QEAPj01N5f7w/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
</ul>
<h2>Creating Markdown notes</h2>
<p>There are two ways to create a Markdown note:</p>
<ol>
<li>Create a new note (e.g. in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>)
and select the type <em>Markdown</em>, just like all the other note types.</li>
<li
>Create a note of type&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;and
select as the language either <em>Markdown </em>or <em>GitHub-Flavored Markdown</em>.
This maintains compatibility with your existing notes prior to the introduction
of this feature.</li>
</ol>
<aside class="admonition note">
<p>There is no distinction between the new Markdown note type and code notes
of type Markdown; internally both are represented as&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;notes
with the proper MIME type (e.g. <code spellcheck="false">text/x-markdown</code>).</p>
</aside>
<h2>Import/export</h2>
<ul>
<li>
<p>By default, when importing a single Markdown file it automatically gets
converted to a&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note.
To avoid that and have it imported as a Markdown note instead:</p>
<ul>
<li>
<p>Right click the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and
select <em>Import into note</em>.</p>
</li>
<li>
<p>Select the file normally.</p>
</li>
<li>
<p>Uncheck <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</p>
</li>
</ul>
</li>
<li>
<p>When exporting Markdown files, the extension is preserved and the content
remains the same as in the source view.</p>
</li>
<li>
<p>Once exported as a Trilium ZIP, the ZIP will preserve the Markdown type
without converting to text notes thanks to the meta-information in it.</p>
</li>
</ul>
<h2>Conversion between text notes and Markdown notes</h2>
<p>Currently there is no built-in functionality to convert a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note
into a Markdown note or vice-versa. We do have plans to address this in
the future.</p>
<p>This can be achieved manually, for a single note:</p>
<ol>
<li>Export the file as Markdown, with single format.</li>
<li>Import the file again, but unchecking <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</li>
</ol>
<p>For multiple notes, the process is slightly more involved:</p>
<ol>
<li>Export the file as Markdown, ZIP.</li>
<li>Extract the archive.</li>
<li>Remove the <code spellcheck="false">!!!meta.json</code> file.</li>
<li>Compress the extracted files back into an archive.</li>
<li>Import the newly create archive, but unchecking <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</li>
</ol>
<h2>Sync-scrolling &amp; block highlight</h2>
<p>When scrolling through the editing pane, the preview pane will attempt
to synchronize its position to make it easier to see the preview.</p>
<p>In addition, the block in the preview matching the position of the cursor
in the source view will appear slightly highlighted.</p>
<p>The sync is currently one-way only, scrolling the preview will not synchronize
the position of the editor.</p>
<p>This feature cannot be disabled as of now; if the scrolling feels distracting,
consider temporarily switching to the editor mode and then switching to
preview mode when ready.</p>
<aside class="admonition note">
<p>This feature of synchronizing the scroll is based on blocks but it's provided
on a best-effort basis since our underlying Markdown library doesn't support
this feature natively, so we had to implement our own algorithm. Feel free
to <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report issues</a>,
but always provide a sample Markdown file to be able to reproduce it.</p>
</aside>

View File

@@ -6,12 +6,15 @@
<img style="aspect-ratio:886/663;" src="2_Mermaid Diagrams_image.png"
width="886" height="663">
</figure>
<h2>Types of diagrams</h2>
<p>Trilium supports Mermaid, which adds support for various diagrams such
as flowchart, sequence diagram, class diagram, state diagram, pie charts,
etc., all using a text description of the chart instead of manually drawing
the diagram.</p>
<p>This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_SL5f1Auq7sVN">Note types with split view</a>&nbsp;for
more information.</p>
<h2>Sample diagrams</h2>
<p>Starting with v0.103.0, Mermaid diagrams no longer start with a sample
flowchart, but instead a pane at the bottom will show all the supported
diagrams with sample code for each:</p>
@@ -49,30 +52,34 @@
<img src="1_Mermaid Diagrams_image.png">
</li>
<li>The preview can be moved around by holding the left mouse button and dragging.</li>
<li>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
<li
>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
</li>
<li>The size of the source/preview panes can be adjusted by hovering over
the border between them and dragging it with the mouse.</li>
<li>In the&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;area:
<ul>
<li>The source/preview can be laid out left-right or bottom-top via the <em>Move editing pane to the left / bottom</em> option.</li>
<li>Press <em>Lock editing</em> to automatically mark the note as read-only.
<li
>Press <em>Lock editing</em> to automatically mark the note as read-only.
In this mode, the code pane is hidden and the diagram is displayed full-size.
Similarly, press <em>Unlock editing</em> to mark a read-only note as editable.</li>
<li>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li
>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li
>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li>Press the <em>Export diagram as PNG</em> to download a normal image (at
1x scale, raster) of the diagram. Can be used to send the diagram in more
traditional channels such as e-mail.</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2>Errors in the diagram</h2>
<p>If there is an error in the source code, the error will be displayed in

View File

@@ -13,11 +13,13 @@
<ol>
<li>HTML language for the legacy/vanilla method, with what needs to be displayed
(for example <code spellcheck="false">&lt;p&gt;Hello world.&lt;/p&gt;</code>).</li>
<li>JSX for the Preact-based approach (see below).</li>
</ol>
<li
>JSX for the Preact-based approach (see below).</li>
</ol>
</li>
<li>Create a&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</li>
<li>Assign the <code spellcheck="false">renderNote</code> <a href="#root/_help_zEY4DaJG4YT5">relation</a> to
<li
>Assign the <code spellcheck="false">renderNote</code> <a href="#root/_help_zEY4DaJG4YT5">relation</a> to
point at the previously created code note.</li>
</ol>
<h2>Legacy scripting using jQuery</h2>
@@ -46,10 +48,9 @@ $dateEl.text(new Date());</code></pre>
need to provide a HTML anymore.</p>
<p>Here are the steps to creating a simple render note:</p>
<ol>
<li>
<p>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</p>
</li>
<li>
<li>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</li>
<li
>
<p>Create a child&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;note
with JSX as the language.
<br>As an example, use the following content:</p><pre><code class="language-text-x-trilium-auto">export default function() {
@@ -59,24 +60,21 @@ $dateEl.text(new Date());</code></pre>
&lt;/&gt;
);
}</code></pre>
</li>
<li>
<p>In the parent render note, define a <code spellcheck="false">~renderNote</code> relation
pointing to the newly created child.</p>
</li>
<li>
<p>Refresh the render note and it should display a “Hello world” message.</p>
</li>
</li>
<li>In the parent render note, define a <code spellcheck="false">~renderNote</code> relation
pointing to the newly created child.</li>
<li>Refresh the render note and it should display a “Hello world” message.</li>
</ol>
<h2>Refreshing the note</h2>
<p>It's possible to refresh the note via:</p>
<ul>
<li>The corresponding button in&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>.</li>
<li>The “Render active note” <a href="#root/_help_A9Oc6YKKc65v">keyboard shortcut</a> (not
<li
>The “Render active note” <a href="#root/_help_A9Oc6YKKc65v">keyboard shortcut</a> (not
assigned by default).</li>
</ul>
<h2>Examples</h2>
<ul>
<li><a class="reference-link" href="#root/_help_R7abl2fc6Mxi">Weight Tracker</a>&nbsp;which
is present in the&nbsp;<a class="reference-link" href="#root/_help_6tZeKvSHEUiB">Demo Notes</a>.</li>
<li><a class="reference-link" href="#root/_help_R7abl2fc6Mxi">[missing note]</a>&nbsp;which
is present in the&nbsp;<a class="reference-link" href="#root/_help_6tZeKvSHEUiB">[missing note]</a>.</li>
</ul>

View File

@@ -6,7 +6,6 @@ style="width:50%;">
<img style="aspect-ratio:812/585;" src="Saved Search_saved-search.gif"
width="812" height="585">
</figure>
<h2>Location</h2>
<p>By default, saved searches are stored in the day note. However, you can
designate a different note to store saved searches by marking it with the

View File

@@ -64,15 +64,15 @@
yet:</p>
<ul>
<li>Trilium-specific formulas (e.g. to obtain the title of a note).</li>
<li>User-defined formulas</li>
<li>Cross-workbook calculation</li>
<li
>User-defined formulas</li>
<li>Cross-workbook calculation</li>
</ul>
<p>If you would like us to work on these features, consider <a href="https://triliumnotes.org/en/support-us">supporting us</a>.</p>
<h2>Known limitations</h2>
<ul>
<li>
<p>It is possible to share a spreadsheet, case in which a best-effort HTML
rendering of the spreadsheet is done.</p>
<li>It is possible to share a spreadsheet, case in which a best-effort HTML
rendering of the spreadsheet is done.
<ul>
<li>For more advanced use cases, this will most likely not work as intended.
Feel free to <a href="#root/_help_wy8So3yZZlH9">report issues</a>, but keep in
@@ -80,12 +80,9 @@
the features of Univer.</li>
</ul>
</li>
<li>
<p>There is currently no export functionality, as stated previously.</p>
</li>
<li>
<p>There is no dedicated mobile support. Mobile support is currently experimental
in Univer and when it becomes stable, we could potentially integrate it
into Trilium as well.</p>
</li>
<li>There is currently no export functionality, as stated previously.</li>
<li
>There is no dedicated mobile support. Mobile support is currently experimental
in Univer and when it becomes stable, we could potentially integrate it
into Trilium as well.</li>
</ul>

View File

@@ -20,169 +20,171 @@
<p>Fore more information see&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>.</p>
<h2>Features and formatting</h2>
<p>Here's a list of various features supported by text notes:</p>
<table>
<thead>
<tr>
<th>Dedicated article</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_Gr6xFaF6ioJ5">General formatting</a>
</td>
<td>
<ul>
<li>Headings (section titles, paragraph)</li>
<li>Font size</li>
<li>Bold, italic, underline, strike-through</li>
<li>Superscript, subscript</li>
<li>Font color &amp; background color</li>
<li>Remove formatting</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_S6Xx8QIWTV66">Lists</a>
</td>
<td>
<ul>
<li>Bulleted lists</li>
<li>Numbered lists</li>
<li>To-do lists</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</td>
<td>
<ul>
<li>Block quotes</li>
<li>Admonitions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NdowYOC1GFKS">Tables</a>
</td>
<td>
<ul>
<li>Basic tables</li>
<li>Merging cells</li>
<li>Styling tables and cells.</li>
<li>Table captions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_UYuUB1ZekNQU">Developer-specific formatting</a>
</td>
<td>
<ul>
<li>Inline code</li>
<li>Code blocks</li>
<li>Keyboard shortcuts</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_AgjCISero73a">Footnotes</a>
</td>
<td>
<ul>
<li>Footnotes</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_mT0HEkOsz6i1">Images</a>
</td>
<td>
<ul>
<li>Images</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_QEAPj01N5f7w">Links</a>
</td>
<td>
<ul>
<li>External links</li>
<li>Internal Trilium links</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>
</td>
<td>
<ul>
<li>Include note</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_CohkqWQC1iBv">Insert buttons</a>
</td>
<td>
<ul>
<li>Symbols</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li>Mermaid diagrams</li>
<li>Horizontal ruler</li>
<li>Page break</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_dEHYtoWWi8ct">Other features</a>
</td>
<td>
<ul>
<li>Indentation
<ul>
<li>Markdown import</li>
</ul>
</li>
<li><a class="reference-link" href="#root/_help_2x0ZAX9ePtzV">Cut to subnote</a>
</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gLt3vA97tMcp">Premium features</a>
</td>
<td>
<ul>
<li><a class="reference-link" href="#root/_help_ZlN4nump6EbW">Slash Commands</a>
</li>
<li><a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>
</li>
<li><a class="reference-link" href="#root/_help_5wZallV2Qo1t">Format Painter</a>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<h2>Read-Only vs. Editing Mode</h2>
<p>Text notes are usually opened in edit mode. However, they may open in
read-only mode if the note is too big or the note is explicitly marked
as read-only. For more information, see&nbsp;<a class="reference-link"
href="#root/_help_CoFPLs3dRlXc">Read-Only Notes</a>.</p>
<h2>Keyboard shortcuts</h2>
<p>There are numerous keyboard shortcuts to format the text without having
to use the mouse. For a reference of all the key combinations, see&nbsp;
<a
class="reference-link" href="#root/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>. In addition, see&nbsp;<a class="reference-link"
href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>&nbsp;as an alternative
to the keyboard shortcuts.</p>
<h2>Technical details</h2>
<p>For the text editing functionality, Trilium uses a commercial product
(with an open-source base) called&nbsp;<a class="reference-link" href="#root/_help_MI26XDLSAlCD">CKEditor</a>.
This brings the benefit of having a powerful WYSIWYG (What You See Is What
You Get) editor.</p>
<figure
class="table">
<table>
<thead>
<tr>
<th>Dedicated article</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_Gr6xFaF6ioJ5">General formatting</a>
</td>
<td>
<ul>
<li>Headings (section titles, paragraph)</li>
<li>Font size</li>
<li>Bold, italic, underline, strike-through</li>
<li>Superscript, subscript</li>
<li>Font color &amp; background color</li>
<li>Remove formatting</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_S6Xx8QIWTV66">Lists</a>
</td>
<td>
<ul>
<li>Bulleted lists</li>
<li>Numbered lists</li>
<li>To-do lists</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</td>
<td>
<ul>
<li>Block quotes</li>
<li>Admonitions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NdowYOC1GFKS">Tables</a>
</td>
<td>
<ul>
<li>Basic tables</li>
<li>Merging cells</li>
<li>Styling tables and cells.</li>
<li>Table captions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_UYuUB1ZekNQU">Developer-specific formatting</a>
</td>
<td>
<ul>
<li>Inline code</li>
<li>Code blocks</li>
<li>Keyboard shortcuts</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_AgjCISero73a">Footnotes</a>
</td>
<td>
<ul>
<li>Footnotes</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_mT0HEkOsz6i1">Images</a>
</td>
<td>
<ul>
<li>Images</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_QEAPj01N5f7w">Links</a>
</td>
<td>
<ul>
<li>External links</li>
<li>Internal Trilium links</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>
</td>
<td>
<ul>
<li>Include note</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_CohkqWQC1iBv">Insert buttons</a>
</td>
<td>
<ul>
<li>Symbols</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li>Mermaid diagrams</li>
<li>Horizontal ruler</li>
<li>Page break</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_dEHYtoWWi8ct">Other features</a>
</td>
<td>
<ul>
<li>Indentation
<ul>
<li>Markdown import</li>
</ul>
</li>
<li><a class="reference-link" href="#root/_help_2x0ZAX9ePtzV">Cut to subnote</a>
</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gLt3vA97tMcp">Premium features</a>
</td>
<td>
<ul>
<li><a class="reference-link" href="#root/_help_ZlN4nump6EbW">Slash Commands</a>
</li>
<li><a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>
</li>
<li><a class="reference-link" href="#root/_help_5wZallV2Qo1t">Format Painter</a>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</figure>
<h2>Read-Only vs. Editing Mode</h2>
<p>Text notes are usually opened in edit mode. However, they may open in
read-only mode if the note is too big or the note is explicitly marked
as read-only. For more information, see&nbsp;<a class="reference-link"
href="#root/_help_CoFPLs3dRlXc">Read-Only Notes</a>.</p>
<h2>Keyboard shortcuts</h2>
<p>There are numerous keyboard shortcuts to format the text without having
to use the mouse. For a reference of all the key combinations, see&nbsp;
<a
class="reference-link" href="#root/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>. In addition, see&nbsp;<a class="reference-link"
href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>&nbsp;as an alternative
to the keyboard shortcuts.</p>
<h2>Technical details</h2>
<p>For the text editing functionality, Trilium uses a commercial product
(with an open-source base) called&nbsp;<a class="reference-link" href="#root/_help_MI26XDLSAlCD">CKEditor</a>.
This brings the benefit of having a powerful WYSIWYG (What You See Is What
You Get) editor.</p>

View File

@@ -21,7 +21,6 @@
insert it.</p>
<img src="1_Insert buttons_plus.png"
width="272" height="187">
<h2>Symbols</h2>
<figure class="image image-style-align-right">
<img style="aspect-ratio:346/322;" src="1_Insert buttons_image.png"
@@ -53,7 +52,6 @@ width="272" height="187">
<img style="aspect-ratio:1174/358;" src="6_Insert buttons_image.png"
width="1174" height="358">
</figure>
<h2>Horizontal ruler</h2>
<p>This feature will display a horizontal line, generally useful to separate
different sections of the text. To do so, press the

View File

@@ -11,8 +11,7 @@
<img
src="10_Tables_image.png" width="384"
height="100">
<h2>Navigating a table</h2>
<h2>Navigating a table</h2>
<ul>
<li>Using the mouse:
<ul>

View File

@@ -42,7 +42,6 @@
</tr>
</tbody>
</table>
<h2>Entity events</h2>
<p>Other events are bound to some entity, these are defined as <a href="#root/_help_zEY4DaJG4YT5">relations</a> -
meaning that script is triggered only if note has this script attached

View File

@@ -81,8 +81,8 @@ module.exports = new WordCountWidget();</code></pre>
<p>After you make changes it is necessary to <a href="#root/_help_s8alTXmpFR61">restart Trilium</a> so
that the layout can be rebuilt.</p>
<p>The widget only activates on text notes that have the <code spellcheck="false">#wordCount</code> label.
This label can be a <a href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/QEAPj01N5f7w/_help_hrZ1D00cLbal">reference link</a> to
enable the widget for an entire subtree.</p>
This label can be a <a href="#root/_help_hrZ1D00cLbal">reference link</a> to enable
the widget for an entire subtree.</p>
<p>At the bottom of the note you can see the resulting widget:</p>
<figure
class="image">

View File

@@ -455,5 +455,11 @@
"fulltext-after-expression": "“{{- token}}”不是有效表达式。要搜索文本,请将其放在属性筛选器之前(例如,“{{- token}} #label”而不是“#label {{- token}}”)。",
"unrecognized-expression": "无法识别的表达式“{{- token}}”"
}
},
"password": {
"incorrect": "您输入的密码不正确。"
},
"script": {
"wrong-environment": "无法执行笔记“{{-noteTitle}}”({{-noteId}})。这是一个 {{-actualEnv}} 脚本,但尝试在 {{-expectedEnv}} 中执行。"
}
}

View File

@@ -397,7 +397,8 @@
"migration": {
"old_version": "Direct migration from your current version is not supported. Please upgrade to the latest v0.60.4 first and only then to this version.",
"error_message": "Error during migration to version {{version}}: {{stack}}",
"wrong_db_version": "The version of the database ({{version}}) is newer than what the application expects ({{targetVersion}}), which means that it was created by a newer and incompatible version of Trilium. Upgrade to the latest version of Trilium to resolve this issue."
"wrong_db_version": "The version of the database ({{version}}) is newer than what the application expects ({{targetVersion}}), which means that it was created by a newer and incompatible version of Trilium. Upgrade to the latest version of Trilium to resolve this issue.",
"invalid_db_version": "The database version is not a valid number. This usually indicates a corrupted 'dbVersion' option in the database. Please restore from a backup."
},
"modals": {
"error_title": "Error"

View File

@@ -1,16 +1,10 @@
import { ADMONITION_TYPE_MAPPINGS } from "@triliumnext/commons";
import { gfm } from "@triliumnext/turndown-plugin-gfm";
import Turnish, { type Rule } from "turnish";
let instance: Turnish | null = null;
// TODO: Move this to a dedicated file someday.
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
export { ADMONITION_TYPE_MAPPINGS };
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;

View File

@@ -14,4 +14,16 @@ describe("Note type mappings", () => {
mime: "text/vnd.mermaid"
});
});
it("exports markdown code notes with a .md extension", () => {
// `mime-types` doesn't recognize Trilium's custom `text/x-markdown`;
// without the explicit fallback this was exporting as `.code`.
for (const mime of [ "text/x-markdown", "text/markdown", "text/x-gfm" ]) {
const note = buildNote({ type: "code", mime, title: "Doc" });
expect(mapByNoteType(note, "# hi", "markdown")).toMatchObject({
extension: "md",
mime
});
}
});
});

View File

@@ -34,6 +34,21 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
taskContext.taskSucceeded(null);
}
/**
* Extension fallback for MIME types the `mime-types` package doesn't recognize —
* mostly Trilium's `text/x-` custom MIMEs.
*/
export function mapCodeMimeToExtension(mime: string): string | null {
switch (mime) {
case "text/x-markdown":
case "text/markdown":
case "text/x-gfm":
return "md";
default:
return null;
}
}
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
let payload, extension, mime;
@@ -60,7 +75,11 @@ export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferL
}
} else if (note.type === "code") {
payload = content;
extension = mimeTypes.extension(note.mime) || "code";
// Our own map wins over the `mime-types` lookup so markdown MIMEs get
// the conventional `.md` rather than `.mkd` (what the lib returns for
// `text/x-markdown`) or `.code` (what the fallback produced when the
// lib didn't recognize `text/markdown` at all).
extension = mapCodeMimeToExtension(note.mime) || mimeTypes.extension(note.mime) || "code";
mime = note.mime;
} else if (note.type === "canvas") {
payload = content;

View File

@@ -5,6 +5,7 @@ import mimeTypes from "mime-types";
import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
import { mapCodeMimeToExtension } from "../single.js";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
@@ -84,7 +85,7 @@ export abstract class ZipExportProvider {
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
return "txt";
}
return mimeTypes.extension(mime) || "dat";
return mapCodeMimeToExtension(mime) || mimeTypes.extension(mime) || "dat";
}

View File

@@ -0,0 +1,90 @@
import fs from "fs";
import { default as path, dirname } from "path";
import { fileURLToPath } from "url";
import { beforeAll, describe, expect, it } from "vitest";
import becca from "../../becca/becca.js";
import type BNote from "../../becca/entities/bnote.js";
import cls from "../cls.js";
import sql_init from "../sql_init.js";
import TaskContext from "../task_context.js";
import enex from "./enex.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
async function testImport(fileName: string) {
const sample = fs.readFileSync(path.join(scriptDir, "samples", fileName));
const taskContext = TaskContext.getInstance("import-enex", "importNotes", {});
return new Promise<{ importedNote: BNote; rootNote: BNote }>((resolve, reject) => {
cls.init(async () => {
const rootNote = becca.getNote("root");
if (!rootNote) {
expect(rootNote).toBeTruthy();
return;
}
const importedNote = await enex.importEnex(taskContext, {
originalname: fileName,
mimetype: "application/enex+xml",
buffer: sample
}, rootNote as BNote);
resolve({
importedNote,
rootNote
});
});
});
}
describe("importEnex", () => {
beforeAll(async () => {
sql_init.initializeDb();
await sql_init.dbReady;
});
it("imports non-image resources as attachments instead of child notes", async () => {
const { importedNote } = await testImport("File with attachments.enex");
// The root import note should contain the individual notes as children
const test1 = importedNote.getChildNotes().find(n => n.title === "TEST1");
expect(test1).toBeTruthy();
// Non-image resources should be attachments, not child notes
const childNotes = test1!.getChildNotes();
expect(childNotes).toHaveLength(0);
// Should have two file attachments
const attachments = test1!.getAttachmentsByRole("file");
expect(attachments).toHaveLength(2);
const txt = attachments.find(a => a.title === "attachments1.txt");
expect(txt).toBeTruthy();
expect(txt!.mime).toBe("text/plain");
expect(txt!.getContent().toString()).toBe("111");
const bin = attachments.find(a => a.title === "attachments2");
expect(bin).toBeTruthy();
expect(bin!.mime).toBe("application/octet-stream");
expect(bin!.getContent().toString()).toBe("222");
// The note content should contain reference links to the attachments
const content = test1!.getContent().toString();
expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&amp;attachmentId=${txt!.attachmentId}"`);
expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&amp;attachmentId=${bin!.attachmentId}"`);
});
it("imports notes without attachments normally", async () => {
const { importedNote } = await testImport("File with attachments.enex");
const test2 = importedNote.getChildNotes().find(n => n.title === "TEST2");
expect(test2).toBeTruthy();
expect(test2!.getChildNotes()).toHaveLength(0);
expect(test2!.getAttachmentsByRole("file")).toHaveLength(0);
const test3 = importedNote.getChildNotes().find(n => n.title === "TEST3");
expect(test3).toBeTruthy();
expect(test3!.getChildNotes()).toHaveLength(0);
expect(test3!.getAttachmentsByRole("file")).toHaveLength(0);
});
}, 60_000);

View File

@@ -1,20 +1,21 @@
import type { AttributeType } from "@triliumnext/commons";
import { dayjs } from "@triliumnext/commons";
import sax from "sax";
import stream from "stream";
import { Throttle } from "stream-throttle";
import log from "../log.js";
import { md5, escapeHtml, fromBase64 } from "../utils.js";
import date_utils from "../date_utils.js";
import sql from "../sql.js";
import noteService from "../notes.js";
import imageService from "../image.js";
import protectedSessionService from "../protected_session.js";
import htmlSanitizer from "../html_sanitizer.js";
import sanitizeAttributeName from "../sanitize_attribute_name.js";
import type TaskContext from "../task_context.js";
import type BNote from "../../becca/entities/bnote.js";
import date_utils from "../date_utils.js";
import htmlSanitizer from "../html_sanitizer.js";
import imageService from "../image.js";
import log from "../log.js";
import noteService from "../notes.js";
import protectedSessionService from "../protected_session.js";
import sanitizeAttributeName from "../sanitize_attribute_name.js";
import sql from "../sql.js";
import type TaskContext from "../task_context.js";
import { escapeHtml, fromBase64,md5 } from "../utils.js";
import type { File } from "./common.js";
import type { AttributeType } from "@triliumnext/commons";
/**
* date format is e.g. 20181121T193703Z or 2013-04-14T16:19:00.000Z (Mac evernote, see #3496)
@@ -25,7 +26,7 @@ function parseDate(text: string) {
text = text.replace(/[-:]/g, "");
// insert - and : to convert it to trilium format
text = text.substr(0, 4) + "-" + text.substr(4, 2) + "-" + text.substr(6, 2) + " " + text.substr(9, 2) + ":" + text.substr(11, 2) + ":" + text.substr(13, 2) + ".000Z";
text = `${text.substr(0, 4) }-${ text.substr(4, 2) }-${ text.substr(6, 2) } ${ text.substr(9, 2) }:${ text.substr(11, 2) }:${ text.substr(13, 2) }.000Z`;
return text;
}
@@ -318,27 +319,17 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN
resource.mime = resource.mime || "application/octet-stream";
const createFileNote = () => {
const resourceNote = noteService.createNewNote({
parentNoteId: noteEntity.noteId,
const createFileAttachment = () => {
const attachment = noteEntity.saveAttachment({
role: "file",
mime: resource.mime || "application/octet-stream",
title: resource.title,
content: resource.content ?? "",
type: "file",
mime: resource.mime,
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
}).note;
content: resource.content ?? ""
});
for (const attr of resource.attributes) {
resourceNote.addAttribute(attr.type, attr.name, attr.value);
}
const attachmentLink = `<a class="reference-link" href="#root/${noteEntity.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${escapeHtml(resource.title)}</a>`;
updateDates(resourceNote, utcDateCreated, utcDateModified);
taskContext.increaseProgressCount();
const resourceLink = `<a href="#root/${resourceNote.noteId}">${escapeHtml(resource.title)}</a>`;
content = (content || "").replace(mediaRegex, resourceLink);
content = (content || "").replace(mediaRegex, attachmentLink);
};
if (resource.mime && resource.mime.startsWith("image/")) {
@@ -360,10 +351,10 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN
}
} catch (e: any) {
log.error(`error when saving image from ENEX file: ${e.message}`);
createFileNote();
createFileAttachment();
}
} else {
createFileNote();
createFileAttachment();
}
}

View File

@@ -1,233 +1,11 @@
import { renderToHtml as renderToHtmlShared } from "@triliumnext/commons";
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor, transclusionExtension, wikiLinkExtension } from "@triliumnext/commons";
import { parse, Renderer, type Tokens, use } from "marked";
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
import htmlSanitizer from "../html_sanitizer.js";
import utils from "../utils.js";
import importUtils from "./utils.js";
const escape = utils.escapeHtml;
/**
* Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
*/
class CustomMarkdownRenderer extends Renderer {
override heading(data: Tokens.Heading): string {
// Treat h1 as raw text.
if (data.depth === 1) {
return `<h1>${data.text}</h1>`;
}
return super.heading(data).trimEnd();
}
override paragraph(data: Tokens.Paragraph): string {
return super.paragraph(data).trimEnd();
}
override code({ text, lang }: Tokens.Code): string {
if (!text) {
return "";
}
// Escape the HTML.
text = escape(text);
// Unescape &quot
text = text.replace(/&quot;/g, '"');
const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang);
return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`;
}
override list(token: Tokens.List): string {
let result = super.list(token)
.replace("\n", "") // we replace the first one only.
.trimEnd();
// Handle todo-list in the CKEditor format.
if (token.items.some(item => item.task)) {
result = result.replace(/^<ul>/, "<ul class=\"todo-list\">");
}
return result;
}
override checkbox({ checked }: Tokens.Checkbox): string {
return `<input type="checkbox"${
checked ? 'checked="checked" ' : ''
}disabled="disabled">`;
}
override listitem(item: Tokens.ListItem): string {
// Handle todo-list in the CKEditor format.
if (item.task) {
let itemBody = '';
const checkbox = this.checkbox({ checked: !!item.checked, raw: "- [ ]", type: "checkbox" });
if (item.loose) {
if (item.tokens[0]?.type === 'paragraph') {
item.tokens[0].text = checkbox + item.tokens[0].text;
if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
item.tokens[0].tokens[0].text = checkbox + escape(item.tokens[0].tokens[0].text);
item.tokens[0].tokens[0].escaped = true;
}
} else {
item.tokens.unshift({
type: 'text',
raw: checkbox,
text: checkbox,
escaped: true,
});
}
} else {
itemBody += checkbox;
}
itemBody += `<span class="todo-list__label__description">${this.parser.parse(item.tokens.filter(t => t.type !== "checkbox"))}</span>`;
return `<li><label class="todo-list__label">${itemBody}</label></li>`;
}
return super.listitem(item).trimEnd();
}
override image(token: Tokens.Image): string {
return super.image(token)
.replace(` alt=""`, "");
}
override blockquote({ tokens }: Tokens.Blockquote): string {
const body = renderer.parser.parse(tokens);
const admonitionMatch = /^<p>\[\!([A-Z]+)\]/.exec(body);
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
const type = admonitionMatch[1].toLowerCase();
if (ADMONITION_TYPE_MAPPINGS[type]) {
const bodyWithoutHeader = body
.replace(/^<p>\[\!([A-Z]+)\]\s*/, "<p>")
.replace(/^<p><\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove.
return `<aside class="admonition ${type}">${bodyWithoutHeader.trim()}</aside>`;
}
}
return `<blockquote>${body}</blockquote>`;
}
codespan({ text }: Tokens.Codespan): string {
return `<code spellcheck="false">${escape(text)}</code>`;
}
function renderToHtml(content: string, title: string): string {
return renderToHtmlShared(content, title, { sanitize: htmlSanitizer.sanitize });
}
function renderToHtml(content: string, title: string) {
// Double-escape slashes in math expression because they are otherwise consumed by the parser somewhere.
content = content.replaceAll("\\$", "\\\\$");
// Extract formulas and replace them with placeholders to prevent interference from Markdown rendering
const { processedText, placeholderMap: formulaMap } = extractFormulas(content);
use({
// Order is important, especially for wikilinks.
extensions: [
transclusionExtension,
wikiLinkExtension
]
});
let html = parse(processedText, {
async: false,
renderer
}) as string;
// After rendering, replace placeholders back with the formula HTML
html = restoreFromMap(html, formulaMap);
// h1 handling needs to come before sanitization
html = importUtils.handleH1(html, title);
html = htmlSanitizer.sanitize(html);
// Add a trailing semicolon to CSS styles.
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\"");
// Remove slash for self-closing tags to match CKEditor's approach.
html = html.replace(/<(\w+)([^>]*)\s+\/>/g, "<$1$2>");
// Normalize non-breaking spaces to entity.
html = html.replaceAll("\u00a0", "&nbsp;");
return html;
}
function getNormalizedMimeFromMarkdownLanguage(language: string | undefined) {
if (language) {
const mimeDefinition = getMimeTypeFromMarkdownName(language);
if (mimeDefinition) {
return normalizeMimeTypeForCKEditor(mimeDefinition.mime);
}
}
return MIME_TYPE_AUTO;
}
function extractCodeBlocks(text: string): { processedText: string, placeholderMap: Map<string, string> } {
const codeMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
// Multi-line code block and Inline code
text = text.replace(/```[\s\S]*?```/g, (m) => {
const key = `<!--CODE_BLOCK_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
}).replace(/`[^`\n]+`/g, (m) => {
const key = `<!--INLINE_CODE_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
});
return { processedText: text, placeholderMap: codeMap };
}
function extractFormulas(text: string): { processedText: string, placeholderMap: Map<string, string> } {
// Protect the $ signs inside code blocks from being recognized as formulas.
const { processedText: noCodeText, placeholderMap: codeMap } = extractCodeBlocks(text);
const formulaMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
// Display math and Inline math
let processedText = noCodeText.replace(/(?<!\\)\$\$((?:(?!\n{2,})[\s\S])+?)\$\$/g, (_, formula) => {
const key = `<!--FORMULA_BLOCK_${timestamp}_${id++}-->`;
const rendered = `<span class="math-tex">\\[${formula}\\]</span>`;
formulaMap.set(key, rendered);
return key;
}).replace(/(?<!\\)\$(.+?)\$/g, (_, formula) => {
const key = `<!--FORMULA_INLINE_${timestamp}_${id++}-->`;
const rendered = `<span class="math-tex">\\(${formula}\\)</span>`;
formulaMap.set(key, rendered);
return key;
});
processedText = restoreFromMap(processedText, codeMap);
return { processedText, placeholderMap: formulaMap };
}
function restoreFromMap(text: string, map: Map<string, string>): string {
if (map.size === 0) return text;
const pattern = [...map.keys()]
.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
return text.replace(new RegExp(pattern, 'g'), match => map.get(match) ?? match);
}
const renderer = new CustomMarkdownRenderer({ async: false });
export default {
renderToHtml
};

View File

@@ -123,6 +123,16 @@ describe("#getType", () => {
[{textImportedAsText: false}, "text/x-markdown"], "file"
],
[
"w/ codeImportedAsCode: true and 'text/markdown' mime type (override) it should return 'code'",
[{codeImportedAsCode: true}, "text/markdown"], "code"
],
[
"w/ codeImportedAsCode: true and 'application/javascript' mime type (override) it should return 'code'",
[{codeImportedAsCode: true}, "application/javascript"], "code"
],
[
"w/ textImportedAsText: false and 'text/html' mime type it should return 'file'",
[{textImportedAsText: false}, "text/html"], "file"

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