Compare commits

...

34 Commits

Author SHA1 Message Date
Elian Doran
c4f55395a9 feat(client/jsx): disable debug info 2025-12-21 13:31:44 +02:00
Elian Doran
444c0c6107 chore(client/jsx): fix errors in API 2025-12-21 13:19:42 +02:00
Elian Doran
4da5cb43fc fet(client/jsx): expose basic React widgets 2025-12-21 13:16:05 +02:00
Elian Doran
e6b79e83c4 fet(client/jsx): basic support for JSX render notes 2025-12-21 11:18:42 +02:00
Elian Doran
6e67da7b1f chore(deps): revert sucrase from client 2025-12-21 10:32:54 +02:00
Elian Doran
9071e54bfe chore(client/jsx): use different method for launcher widget defs 2025-12-21 10:26:20 +02:00
Elian Doran
783b5ac8e3 feat(client/jsx): support launcher widgets 2025-12-21 10:23:34 +02:00
Elian Doran
f3f491d141 feat(client/bundle): respect position for TSX widgets 2025-12-21 10:02:13 +02:00
Elian Doran
f8bf301d12 feat(client/bundle): use new toast for script errors with known note ID 2025-12-20 23:34:36 +02:00
Elian Doran
2c25786fa2 feat(client/bundle): expose Trilium hooks 2025-12-20 23:26:10 +02:00
Elian Doran
1093acfe45 feat(client/bundle): make Preact custom widgets content-sized by default 2025-12-20 23:17:30 +02:00
Elian Doran
76f054bbd5 feat(client/bundle): support rendering in other places 2025-12-20 23:16:19 +02:00
Elian Doran
c558255450 feat(client/bundle): add button to open script note 2025-12-20 22:51:04 +02:00
Elian Doran
1e94125133 feat(client/bundle): display toast when parent is missing 2025-12-20 22:45:58 +02:00
Elian Doran
64a770175f refactor(client/bundle): use type for parent name 2025-12-20 22:40:03 +02:00
Elian Doran
e0416097e1 feat(script/jsx): support import syntax for api 2025-12-20 22:23:25 +02:00
Elian Doran
6c1b327f5f feat(script/jsx): support import syntax for preact 2025-12-20 22:14:45 +02:00
Elian Doran
284b66acd2 feat(script/jsx): support export default syntax 2025-12-20 21:59:03 +02:00
Elian Doran
dcd73ff9f9 test(script/jsx): JSX fragment 2025-12-20 21:37:41 +02:00
Elian Doran
645557b505 test(script/jsx): basic JSX processing 2025-12-20 21:35:52 +02:00
Elian Doran
22a83d9f82 refactor(script/jsx): "react-widget" -> "preact-widget" 2025-12-20 21:26:01 +02:00
Elian Doran
f64de3acca chore(script/jsx): move defineWidget into Preact API 2025-12-20 21:25:36 +02:00
Elian Doran
34d5793888 chore(script/jsx): expose RightPanelWidget 2025-12-20 21:19:53 +02:00
Elian Doran
44ca9f457c feat(script/jsx): add support for React hooks 2025-12-20 20:29:03 +02:00
Elian Doran
4d7e5bc8f6 chore(script/jsx): move Preact API in dedicated object 2025-12-20 20:10:19 +02:00
Elian Doran
644ff07a50 feat(script/jsx): get right panel widgets to actually render 2025-12-20 19:49:24 +02:00
Elian Doran
41220a9d1d fix(script/jsx): cannot find preact hydration function 2025-12-20 19:45:44 +02:00
Elian Doran
88945788d6 fix(script/jsx): critical crash if widget fails to render 2025-12-20 19:41:48 +02:00
Elian Doran
fe8f033409 chore(script/jsx): get widgets to be interpreted 2025-12-20 19:36:02 +02:00
Elian Doran
eee7c49f6e fix(script/jsx): module not defined 2025-12-20 19:28:26 +02:00
Elian Doran
d036bf0870 fix(client): full crash if server fails to obtain list of widgets 2025-12-20 19:18:50 +02:00
Elian Doran
fa8ff4bfbf chore(script/jsx): basic client-side logic to render bundles 2025-12-20 19:01:29 +02:00
Elian Doran
3619c0c3e4 feat(script/jsx): compile JSX on server side 2025-12-20 18:46:15 +02:00
Elian Doran
883e32f5c9 chore(script): install sucrase 2025-12-20 18:03:45 +02:00
18 changed files with 641 additions and 201 deletions

View File

@@ -989,6 +989,10 @@ export default class FNote {
); );
} }
isJsx() {
return (this.type === "code" && this.mime === "text/jsx");
}
/** @returns true if this note is HTML */ /** @returns true if this note is HTML */
isHtml() { isHtml() {
return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html"; return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html";
@@ -996,7 +1000,7 @@ export default class FNote {
/** @returns JS script environment - either "frontend" or "backend" */ /** @returns JS script environment - either "frontend" or "backend" */
getScriptEnv() { getScriptEnv() {
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend"))) { if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend")) || this.isJsx()) {
return "frontend"; return "frontend";
} }
@@ -1018,7 +1022,7 @@ export default class FNote {
* @returns a promise that resolves when the script has been run. Additionally, for front-end notes, the promise will contain the value that is returned by the script. * @returns a promise that resolves when the script has been run. Additionally, for front-end notes, the promise will contain the value that is returned by the script.
*/ */
async executeScript() { async executeScript() {
if (!this.isJavaScript()) { if (!(this.isJavaScript() || this.isJsx())) {
throw new Error(`Note ${this.noteId} is of type ${this.type} and mime ${this.mime} and thus cannot be executed`); throw new Error(`Note ${this.noteId} is of type ${this.type} and mime ${this.mime} and thus cannot be executed`);
} }

View File

@@ -184,7 +184,7 @@ export default class DesktopLayout {
.child(new HighlightsListWidget()) .child(new HighlightsListWidget())
.child(...this.customWidgets.get("right-pane")) .child(...this.customWidgets.get("right-pane"))
) )
.optChild(isNewLayout, <RightPanelContainer customWidgets={this.customWidgets.get("right-pane")} />) .optChild(isNewLayout, <RightPanelContainer widgetsByParent={this.customWidgets} />)
) )
.optChild(!launcherPaneIsHorizontal && isNewLayout, <StatusBar />) .optChild(!launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
) )

View File

@@ -1,10 +1,16 @@
import { h, VNode } from "preact";
import Component from "../components/component.js";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import RightPanelWidget from "../widgets/right_panel_widget.js";
import froca from "./froca.js";
import type { Entity } from "./frontend_script_api.js";
import { WidgetDefinitionWithType } from "./frontend_script_api_preact.js";
import { t } from "./i18n.js";
import ScriptContext from "./script_context.js"; import ScriptContext from "./script_context.js";
import server from "./server.js"; import server from "./server.js";
import toastService, { showError } from "./toast.js"; import toastService, { showErrorForScriptNote } from "./toast.js";
import froca from "./froca.js"; import utils, { getErrorMessage } from "./utils.js";
import utils from "./utils.js";
import { t } from "./i18n.js";
import type { Entity } from "./frontend_script_api.js";
// TODO: Deduplicate with server. // TODO: Deduplicate with server.
export interface Bundle { export interface Bundle {
@@ -14,9 +20,12 @@ export interface Bundle {
allNoteIds: string[]; allNoteIds: string[];
} }
interface Widget { type LegacyWidget = (BasicWidget | RightPanelWidget) & {
parentWidget?: string; parentWidget?: string;
} };
export type Widget = (LegacyWidget | WidgetDefinitionWithType) & {
_noteId: string;
};
async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) { async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) {
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, { const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
@@ -27,6 +36,8 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
return await executeBundle(bundle, originEntity); return await executeBundle(bundle, originEntity);
} }
export type ParentName = "left-pane" | "center-pane" | "note-detail-pane" | "right-pane";
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) { export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container); const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
@@ -52,7 +63,7 @@ export async function executeBundle(bundle: Bundle, originEntity?: Entity | null
async function executeStartupBundles() { async function executeStartupBundles() {
const isMobile = utils.isMobile(); const isMobile = utils.isMobile();
const scriptBundles = await server.get<Bundle[]>("script/startup" + (isMobile ? "?mobile=true" : "")); const scriptBundles = await server.get<Bundle[]>(`script/startup${ isMobile ? "?mobile=true" : ""}`);
for (const bundle of scriptBundles) { for (const bundle of scriptBundles) {
await executeBundle(bundle); await executeBundle(bundle);
@@ -60,68 +71,108 @@ async function executeStartupBundles() {
} }
export class WidgetsByParent { export class WidgetsByParent {
private byParent: Record<string, Widget[]>; private legacyWidgets: Record<string, LegacyWidget[]>;
private preactWidgets: Record<string, WidgetDefinitionWithType[]>;
constructor() { constructor() {
this.byParent = {}; this.legacyWidgets = {};
this.preactWidgets = {};
} }
add(widget: Widget) { add(widget: Widget) {
if (!widget.parentWidget) { let hasParentWidget = false;
console.log(`Custom widget does not have mandatory 'parentWidget' property defined`); let isPreact = false;
return; if ("type" in widget && widget.type === "preact-widget") {
// React-based script.
const reactWidget = widget as WidgetDefinitionWithType;
this.preactWidgets[reactWidget.parent] = this.preactWidgets[reactWidget.parent] || [];
this.preactWidgets[reactWidget.parent].push(reactWidget);
isPreact = true;
hasParentWidget = !!reactWidget.parent;
} else if ("parentWidget" in widget && widget.parentWidget) {
this.legacyWidgets[widget.parentWidget] = this.legacyWidgets[widget.parentWidget] || [];
this.legacyWidgets[widget.parentWidget].push(widget);
hasParentWidget = !!widget.parentWidget;
} }
this.byParent[widget.parentWidget] = this.byParent[widget.parentWidget] || []; if (!hasParentWidget) {
this.byParent[widget.parentWidget].push(widget); showErrorForScriptNote(widget._noteId, t("toast.widget-missing-parent", {
property: isPreact ? "parent" : "parentWidget"
}));
}
} }
get(parentName: string) { get(parentName: ParentName) {
if (!this.byParent[parentName]) { const widgets: (Component | VNode)[] = this.getLegacyWidgets(parentName);
return []; for (const preactWidget of this.getPreactWidgets(parentName)) {
const el = h(preactWidget.render, {});
const widget = new ReactWrappedWidget(el);
widget.contentSized();
if (preactWidget.position) {
widget.position = preactWidget.position;
}
widgets.push(widget);
} }
return widgets;
}
getLegacyWidgets(parentName: ParentName): (BasicWidget | RightPanelWidget)[] {
if (!this.legacyWidgets[parentName]) return [];
return ( return (
this.byParent[parentName] this.legacyWidgets[parentName]
// previously, custom widgets were provided as a single instance, but that has the disadvantage // previously, custom widgets were provided as a single instance, but that has the disadvantage
// for splits where we actually need multiple instaces and thus having a class to instantiate is better // for splits where we actually need multiple instaces and thus having a class to instantiate is better
// https://github.com/zadam/trilium/issues/4274 // https://github.com/zadam/trilium/issues/4274
.map((w: any) => (w.prototype ? new w() : w)) .map((w: any) => (w.prototype ? new w() : w))
); );
} }
getPreactWidgets(parentName: ParentName) {
return this.preactWidgets[parentName] ?? [];
}
} }
async function getWidgetBundlesByParent() { async function getWidgetBundlesByParent() {
const scriptBundles = await server.get<Bundle[]>("script/widgets");
const widgetsByParent = new WidgetsByParent(); const widgetsByParent = new WidgetsByParent();
for (const bundle of scriptBundles) { try {
let widget; const scriptBundles = await server.get<Bundle[]>("script/widgets");
try { for (const bundle of scriptBundles) {
widget = await executeBundle(bundle); let widget;
if (widget) {
widget._noteId = bundle.noteId; try {
widgetsByParent.add(widget); widget = await executeBundle(bundle);
if (widget) {
widget._noteId = bundle.noteId;
widgetsByParent.add(widget);
}
} catch (e: any) {
const noteId = bundle.noteId;
const note = await froca.getNote(noteId);
toastService.showPersistent({
id: `custom-script-failure-${noteId}`,
title: t("toast.bundle-error.title"),
icon: "bx bx-error-circle",
message: t("toast.bundle-error.message", {
id: noteId,
title: note?.title,
message: e.message
})
});
logError("Widget initialization failed: ", e);
continue;
} }
} catch (e: any) {
const noteId = bundle.noteId;
const note = await froca.getNote(noteId);
toastService.showPersistent({
id: `custom-script-failure-${noteId}`,
title: t("toast.bundle-error.title"),
icon: "bx bx-error-circle",
message: t("toast.bundle-error.message", {
id: noteId,
title: note?.title,
message: e.message
})
});
logError("Widget initialization failed: ", e);
continue;
} }
} catch (e) {
toastService.showPersistent({
title: t("toast.widget-list-error.title"),
message: getErrorMessage(e),
icon: "bx bx-error-circle"
});
} }
return widgetsByParent; return widgetsByParent;

View File

@@ -1,26 +1,27 @@
import server from "./server.js"; import { dayjs, formatLogMessage } from "@triliumnext/commons";
import utils from "./utils.js";
import toastService from "./toast.js"; import appContext from "../components/app_context.js";
import linkService from "./link.js"; import type Component from "../components/component.js";
import type NoteContext from "../components/note_context.js";
import type FNote from "../entities/fnote.js";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
import RightPanelWidget from "../widgets/right_panel_widget.js";
import dateNotesService from "./date_notes.js";
import dialogService from "./dialog.js";
import froca from "./froca.js"; import froca from "./froca.js";
import { preactAPI } from "./frontend_script_api_preact.js";
import { t } from "./i18n.js";
import linkService from "./link.js";
import noteTooltipService from "./note_tooltip.js"; import noteTooltipService from "./note_tooltip.js";
import protectedSessionService from "./protected_session.js"; import protectedSessionService from "./protected_session.js";
import dateNotesService from "./date_notes.js";
import searchService from "./search.js"; import searchService from "./search.js";
import RightPanelWidget from "../widgets/right_panel_widget.js"; import server from "./server.js";
import ws from "./ws.js";
import appContext from "../components/app_context.js";
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import SpacedUpdate from "./spaced_update.js";
import shortcutService from "./shortcuts.js"; import shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js"; import SpacedUpdate from "./spaced_update.js";
import type FNote from "../entities/fnote.js"; import toastService from "./toast.js";
import { t } from "./i18n.js"; import utils from "./utils.js";
import { dayjs } from "@triliumnext/commons"; import ws from "./ws.js";
import type NoteContext from "../components/note_context.js";
import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
/** /**
* A whole number * A whole number
@@ -464,6 +465,8 @@ export interface Api {
* Log given message to the log pane in UI * Log given message to the log pane in UI
*/ */
log(message: string | object): void; log(message: string | object): void;
preact: typeof preactAPI;
} }
/** /**
@@ -533,9 +536,9 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
return params.map((p) => { return params.map((p) => {
if (typeof p === "function") { if (typeof p === "function") {
return `!@#Function: ${p.toString()}`; return `!@#Function: ${p.toString()}`;
} else {
return p;
} }
return p;
}); });
} }
@@ -562,9 +565,9 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return ret.executionResult; return ret.executionResult;
} else {
throw new Error(`server error: ${ret.error}`);
} }
throw new Error(`server error: ${ret.error}`);
}; };
this.runOnBackend = async (func, params = []) => { this.runOnBackend = async (func, params = []) => {
@@ -721,6 +724,8 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
this.logMessages[noteId].push(message); this.logMessages[noteId].push(message);
this.logSpacedUpdates[noteId].scheduleUpdate(); this.logSpacedUpdates[noteId].scheduleUpdate();
}; };
this.preact = preactAPI;
} }
export default FrontendScriptApi as any as { export default FrontendScriptApi as any as {

View File

@@ -0,0 +1,101 @@
import { Fragment, h, VNode } from "preact";
import * as hooks from "preact/hooks";
import ActionButton from "../widgets/react/ActionButton";
import Admonition from "../widgets/react/Admonition";
import Button from "../widgets/react/Button";
import CKEditor from "../widgets/react/CKEditor";
import Collapsible from "../widgets/react/Collapsible";
import Dropdown from "../widgets/react/Dropdown";
import FormCheckbox from "../widgets/react/FormCheckbox";
import FormDropdownList from "../widgets/react/FormDropdownList";
import { FormFileUploadActionButton,FormFileUploadButton } from "../widgets/react/FormFileUpload";
import FormGroup from "../widgets/react/FormGroup";
import { FormDropdownDivider, FormDropdownSubmenu,FormListItem } from "../widgets/react/FormList";
import FormRadioGroup from "../widgets/react/FormRadioGroup";
import FormText from "../widgets/react/FormText";
import FormTextArea from "../widgets/react/FormTextArea";
import FormTextBox from "../widgets/react/FormTextBox";
import FormToggle from "../widgets/react/FormToggle";
import * as triliumHooks from "../widgets/react/hooks";
import Icon from "../widgets/react/Icon";
import LinkButton from "../widgets/react/LinkButton";
import LoadingSpinner from "../widgets/react/LoadingSpinner";
import Modal from "../widgets/react/Modal";
import NoteAutocomplete from "../widgets/react/NoteAutocomplete";
import NoteLink from "../widgets/react/NoteLink";
import RawHtml from "../widgets/react/RawHtml";
import Slider from "../widgets/react/Slider";
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget";
export interface WidgetDefinition {
parent: "right-pane",
render: () => VNode,
position?: number,
}
export interface WidgetDefinitionWithType extends WidgetDefinition {
type: "preact-widget"
}
export interface LauncherWidgetDefinitionWithType {
type: "preact-launcher-widget"
render: () => VNode
}
export const preactAPI = Object.freeze({
// Core
h,
Fragment,
/**
* Method that must be run for widget scripts that run on Preact, using JSX. The method just returns the same definition, reserved for future typechecking and perhaps validation purposes.
*
* @param definition the widget definition.
*/
defineWidget(definition: WidgetDefinition) {
return {
type: "preact-widget",
...definition
};
},
defineLauncherWidget(definition: Omit<LauncherWidgetDefinitionWithType, "type">) {
return {
type: "preact-launcher-widget",
...definition
};
},
// Basic widgets
ActionButton,
Admonition,
Button,
CKEditor,
Collapsible,
Dropdown,
FormCheckbox,
FormDropdownList,
FormFileUploadButton, FormFileUploadActionButton,
FormGroup,
FormListItem, FormDropdownDivider, FormDropdownSubmenu,
FormRadioGroup,
FormText,
FormTextArea,
FormTextBox,
FormToggle,
Icon,
LinkButton,
LoadingSpinner,
Modal,
NoteAutocomplete,
NoteLink,
RawHtml,
Slider,
// Specialized widgets
RightPanelWidget,
...hooks,
...triliumHooks
});

View File

@@ -1,6 +1,10 @@
import server from "./server.js"; import { h, VNode } from "preact";
import bundleService, { type Bundle } from "./bundle.js";
import type FNote from "../entities/fnote.js"; import type FNote from "../entities/fnote.js";
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
import bundleService, { type Bundle } from "./bundle.js";
import froca from "./froca.js";
import server from "./server.js";
async function render(note: FNote, $el: JQuery<HTMLElement>) { async function render(note: FNote, $el: JQuery<HTMLElement>) {
const relations = note.getRelations("renderNote"); const relations = note.getRelations("renderNote");
@@ -17,12 +21,34 @@ async function render(note: FNote, $el: JQuery<HTMLElement>) {
$scriptContainer.append(bundle.html); $scriptContainer.append(bundle.html);
// async so that scripts cannot block trilium execution // async so that scripts cannot block trilium execution
bundleService.executeBundle(bundle, note, $scriptContainer); bundleService.executeBundle(bundle, note, $scriptContainer).then(result => {
// Render JSX
if (bundle.html === "") {
renderIfJsx(bundle, result, $el);
}
});
} }
return renderNoteIds.length > 0; return renderNoteIds.length > 0;
} }
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>) {
// Ensure the root script note is actually a JSX.
const rootScriptNoteId = await froca.getNote(bundle.noteId);
if (rootScriptNoteId?.mime !== "text/jsx") return;
// Ensure the output is a valid el.
if (typeof result !== "function") return;
// Obtain the parent component.
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
if (!closestComponent) return;
// Render the element.
const el = h(result as () => VNode, {});
renderReactWidgetAtElement(closestComponent, el, $el[0]);
}
export default { export default {
render render
}; };

View File

@@ -133,11 +133,11 @@ async function call<T>(method: string, url: string, componentId?: string, option
}; };
ipc.send("server-request", { ipc.send("server-request", {
requestId: requestId, requestId,
headers: headers, headers,
method: method, method,
url: `/${window.glob.baseApiUrl}${url}`, url: `/${window.glob.baseApiUrl}${url}`,
data: data data
}); });
})) as any; })) as any;
} else { } else {
@@ -161,7 +161,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
const options: JQueryAjaxSettings = { const options: JQueryAjaxSettings = {
url: window.glob.baseApiUrl + url, url: window.glob.baseApiUrl + url,
type: method, type: method,
headers: headers, headers,
timeout: 60000, timeout: 60000,
success: (body, textStatus, jqXhr) => { success: (body, textStatus, jqXhr) => {
const respHeaders: Headers = {}; const respHeaders: Headers = {};
@@ -288,8 +288,8 @@ async function reportError(method: string, url: string, statusCode: number, resp
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }), t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
15_000); 15_000);
} }
const { throwError } = await import("./ws.js"); const { logError } = await import("./ws.js");
throwError(`${statusCode} ${method} ${url} - ${message}`); logError(`${statusCode} ${method} ${url} - ${message}`);
} }
} }

View File

@@ -1,5 +1,8 @@
import { signal } from "@preact/signals"; import { signal } from "@preact/signals";
import appContext from "../components/app_context.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import utils from "./utils.js"; import utils from "./utils.js";
export interface ToastOptions { export interface ToastOptions {
@@ -61,6 +64,24 @@ function showErrorTitleAndMessage(title: string, message: string, timeout = 1000
}); });
} }
export async function showErrorForScriptNote(noteId: string, message: string) {
const note = await froca.getNote(noteId, true);
showPersistent({
id: `custom-widget-failure-${noteId}`,
title: note?.title ?? "",
icon: note?.getIcon() ?? "bx bx-error-circle",
message,
timeout: 15_000,
buttons: [
{
text: t("toast.open-script-note"),
onClick: () => appContext.tabManager.openInNewTab(noteId, null, true)
}
]
});
}
//#region Toast store //#region Toast store
export const toasts = signal<ToastOptionsWithRequiredId[]>([]); export const toasts = signal<ToastOptionsWithRequiredId[]>([]);
@@ -74,7 +95,7 @@ function addToast(opts: ToastOptions) {
function updateToast(id: string, partial: Partial<ToastOptions>) { function updateToast(id: string, partial: Partial<ToastOptions>) {
toasts.value = toasts.value.map(toast => { toasts.value = toasts.value.map(toast => {
if (toast.id === id) { if (toast.id === id) {
return { ...toast, ...partial } return { ...toast, ...partial };
} }
return toast; return toast;
}); });

View File

@@ -22,7 +22,15 @@
"bundle-error": { "bundle-error": {
"title": "Failed to load a custom script", "title": "Failed to load a custom script",
"message": "Script from note with ID \"{{id}}\", titled \"{{title}}\" could not be executed due to:\n\n{{message}}" "message": "Script from note with ID \"{{id}}\", titled \"{{title}}\" could not be executed due to:\n\n{{message}}"
} },
"widget-list-error": {
"title": "Failed to obtain the list of widgets from the server"
},
"widget-render-error": {
"title": "Failed to render a custom React widget"
},
"widget-missing-parent": "Custom widget does not have mandatory '{{property}}' property defined.",
"open-script-note": "Open script note"
}, },
"add_link": { "add_link": {
"add_link": "Add link", "add_link": "Add link",

View File

@@ -1,8 +1,9 @@
import { isValidElement, VNode } from "preact"; import { isValidElement, VNode } from "preact";
import Component, { TypedComponent } from "../components/component.js"; import Component, { TypedComponent } from "../components/component.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import toastService from "../services/toast.js"; import toastService, { showErrorForScriptNote } from "../services/toast.js";
import { renderReactWidget } from "./react/react_utils.jsx"; import { renderReactWidget } from "./react/react_utils.jsx";
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> { export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
@@ -56,9 +57,9 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
optChild(condition: boolean, ...components: (T | VNode)[]) { optChild(condition: boolean, ...components: (T | VNode)[]) {
if (condition) { if (condition) {
return this.child(...components); return this.child(...components);
} else {
return this;
} }
return this;
} }
id(id: string) { id(id: string) {
@@ -172,16 +173,11 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
const noteId = this._noteId; const noteId = this._noteId;
if (this._noteId) { if (this._noteId) {
froca.getNote(noteId, true).then((note) => { froca.getNote(noteId, true).then((note) => {
toastService.showPersistent({ showErrorForScriptNote(noteId, t("toast.widget-error.message-custom", {
id: `custom-widget-failure-${noteId}`, id: noteId,
title: t("toast.widget-error.title"), title: note?.title,
icon: "bx bx-error-circle", message: e.message || e.toString()
message: t("toast.widget-error.message-custom", { }));
id: noteId,
title: note?.title,
message: e.message || e.toString()
})
});
}); });
} else { } else {
toastService.showPersistent({ toastService.showPersistent({
@@ -213,7 +209,7 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
toggleInt(show: boolean | null | undefined) { toggleInt(show: boolean | null | undefined) {
this.$widget.toggleClass("hidden-int", !show) this.$widget.toggleClass("hidden-int", !show)
.toggleClass("visible", !!show); .toggleClass("visible", !!show);
} }
isHiddenInt() { isHiddenInt() {
@@ -222,7 +218,7 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
toggleExt(show: boolean | null | "" | undefined) { toggleExt(show: boolean | null | "" | undefined) {
this.$widget.toggleClass("hidden-ext", !show) this.$widget.toggleClass("hidden-ext", !show)
.toggleClass("visible", !!show); .toggleClass("visible", !!show);
} }
isHiddenExt() { isHiddenExt() {
@@ -250,9 +246,9 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
getClosestNtxId() { getClosestNtxId() {
if (this.$widget) { if (this.$widget) {
return this.$widget.closest("[data-ntx-id]").attr("data-ntx-id"); return this.$widget.closest("[data-ntx-id]").attr("data-ntx-id");
} else {
return null;
} }
return null;
} }
cleanup() {} cleanup() {}

View File

@@ -1,17 +1,20 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks"; import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks";
import appContext, { CommandNames } from "../../components/app_context";
import FNote from "../../entities/fnote";
import date_notes from "../../services/date_notes";
import dialog from "../../services/dialog";
import { LauncherWidgetDefinitionWithType } from "../../services/frontend_script_api_preact";
import { t } from "../../services/i18n";
import toast from "../../services/toast";
import { getErrorMessage, isMobile } from "../../services/utils";
import BasicWidget from "../basic_widget";
import NoteContextAwareWidget from "../note_context_aware_widget";
import QuickSearchWidget from "../quick_search";
import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget, useTriliumOptionBool } from "../react/hooks"; import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget, useTriliumOptionBool } from "../react/hooks";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import BasicWidget from "../basic_widget";
import FNote from "../../entities/fnote";
import QuickSearchWidget from "../quick_search";
import { getErrorMessage, isMobile } from "../../services/utils";
import date_notes from "../../services/date_notes";
import { CustomNoteLauncher } from "./GenericButtons"; import { CustomNoteLauncher } from "./GenericButtons";
import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
import dialog from "../../services/dialog";
import { t } from "../../services/i18n";
import appContext, { CommandNames } from "../../components/app_context";
import toast from "../../services/toast";
export function CommandButton({ launcherNote }: LauncherNoteProps) { export function CommandButton({ launcherNote }: LauncherNoteProps) {
const { icon, title } = useLauncherIconAndTitle(launcherNote); const { icon, title } = useLauncherIconAndTitle(launcherNote);
@@ -23,7 +26,7 @@ export function CommandButton({ launcherNote }: LauncherNoteProps) {
text={title} text={title}
triggerCommand={command as CommandNames} triggerCommand={command as CommandNames}
/> />
) );
} }
// we're intentionally displaying the launcher title and icon instead of the target, // we're intentionally displaying the launcher title and icon instead of the target,
@@ -75,7 +78,7 @@ export function ScriptLauncher({ launcherNote }: LauncherNoteProps) {
text={title} text={title}
onClick={launch} onClick={launch}
/> />
) );
} }
export function AiChatButton({ launcherNote }: LauncherNoteProps) { export function AiChatButton({ launcherNote }: LauncherNoteProps) {
@@ -88,7 +91,7 @@ export function AiChatButton({ launcherNote }: LauncherNoteProps) {
text={title} text={title}
triggerCommand="createAiChat" triggerCommand="createAiChat"
/> />
) );
} }
export function TodayLauncher({ launcherNote }: LauncherNoteProps) { export function TodayLauncher({ launcherNote }: LauncherNoteProps) {
@@ -114,12 +117,13 @@ export function QuickSearchLauncherWidget() {
<div> <div>
{isEnabled && <LegacyWidgetRenderer widget={widget} />} {isEnabled && <LegacyWidgetRenderer widget={widget} />}
</div> </div>
) );
} }
export function CustomWidget({ launcherNote }: LauncherNoteProps) { export function CustomWidget({ launcherNote }: LauncherNoteProps) {
const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget"); const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget");
const [ widget, setWidget ] = useState<BasicWidget>(); const [ widget, setWidget ] = useState<BasicWidget | NoteContextAwareWidget | LauncherWidgetDefinitionWithType>();
const parentComponent = useContext(ParentComponent) as BasicWidget | null; const parentComponent = useContext(ParentComponent) as BasicWidget | null;
parentComponent?.contentSized(); parentComponent?.contentSized();
@@ -146,9 +150,13 @@ export function CustomWidget({ launcherNote }: LauncherNoteProps) {
return ( return (
<div> <div>
{widget && <LegacyWidgetRenderer widget={widget} />} {widget && (
("type" in widget && widget.type === "preact-launcher-widget")
? <ReactWidgetRenderer widget={widget as LauncherWidgetDefinitionWithType} />
: <LegacyWidgetRenderer widget={widget as BasicWidget} />
)}
</div> </div>
) );
} }
export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) { export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) {
@@ -158,3 +166,8 @@ export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) {
return widgetEl; return widgetEl;
} }
function ReactWidgetRenderer({ widget }: { widget: LauncherWidgetDefinitionWithType }) {
const El = widget.render;
return <El />;
}

View File

@@ -1,8 +1,9 @@
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
import { escapeQuotes } from "../../services/utils";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { CSSProperties, memo } from "preact/compat"; import { CSSProperties, memo } from "preact/compat";
import { useCallback,useEffect, useMemo, useRef } from "preact/hooks";
import { escapeQuotes } from "../../services/utils";
import { useUniqueName } from "./hooks"; import { useUniqueName } from "./hooks";
interface FormCheckboxProps { interface FormCheckboxProps {
@@ -18,7 +19,7 @@ interface FormCheckboxProps {
containerStyle?: CSSProperties; containerStyle?: CSSProperties;
} }
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => { export default function FormCheckbox({ name, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) {
const labelRef = useRef<HTMLLabelElement>(null); const labelRef = useRef<HTMLLabelElement>(null);
const id = useUniqueName(name); const id = useUniqueName(name);
@@ -35,7 +36,7 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
const labelStyle = useMemo(() => const labelStyle = useMemo(() =>
hint ? { textDecoration: "underline dotted var(--main-text-color)" } : undefined, hint ? { textDecoration: "underline dotted var(--main-text-color)" } : undefined,
[hint] [hint]
); );
const handleChange = useCallback((e: Event) => { const handleChange = useCallback((e: Event) => {
@@ -65,6 +66,4 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
</label> </label>
</div> </div>
); );
}); }
export default FormCheckbox;

View File

@@ -2,10 +2,11 @@
import "./RightPanelContainer.css"; import "./RightPanelContainer.css";
import Split from "@triliumnext/split.js"; import Split from "@triliumnext/split.js";
import { VNode } from "preact"; import { isValidElement, VNode } from "preact";
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import appContext from "../../components/app_context"; import appContext from "../../components/app_context";
import { WidgetsByParent } from "../../services/bundle";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import options from "../../services/options"; import options from "../../services/options";
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer";
@@ -23,12 +24,12 @@ const MIN_WIDTH_PERCENT = 5;
interface RightPanelWidgetDefinition { interface RightPanelWidgetDefinition {
el: VNode; el: VNode;
enabled: boolean; enabled: boolean;
position: number; position?: number;
} }
export default function RightPanelContainer({ customWidgets }: { customWidgets: BasicWidget[] }) { export default function RightPanelContainer({ widgetsByParent }: { widgetsByParent: WidgetsByParent }) {
const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible");
const items = useItems(rightPaneVisible, customWidgets); const items = useItems(rightPaneVisible, widgetsByParent);
useSplit(rightPaneVisible); useSplit(rightPaneVisible);
return ( return (
@@ -51,7 +52,7 @@ export default function RightPanelContainer({ customWidgets }: { customWidgets:
); );
} }
function useItems(rightPaneVisible: boolean, customWidgets: BasicWidget[]) { function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
const { note } = useActiveNoteContext(); const { note } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type"); const noteType = useNoteProperty(note, "type");
const [ highlightsList ] = useTriliumOptionJson<string[]>("highlightsList"); const [ highlightsList ] = useTriliumOptionJson<string[]>("highlightsList");
@@ -61,23 +62,38 @@ function useItems(rightPaneVisible: boolean, customWidgets: BasicWidget[]) {
{ {
el: <TableOfContents />, el: <TableOfContents />,
enabled: (noteType === "text" || noteType === "doc"), enabled: (noteType === "text" || noteType === "doc"),
position: 10,
}, },
{ {
el: <HighlightsList />, el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0, enabled: noteType === "text" && highlightsList.length > 0,
position: 20,
}, },
...customWidgets.map((w, i) => ({ ...widgetsByParent.getLegacyWidgets("right-pane").map((widget, i) => ({
el: <CustomWidget key={w._noteId} originalWidget={w as LegacyRightPanelWidget} />, el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
enabled: true, enabled: true,
position: w.position ?? 30 + i * 10 position: widget.position
})) })),
...widgetsByParent.getPreactWidgets("right-pane").map((widget) => {
const El = widget.render;
return {
el: <El />,
enabled: true,
position: widget.position
};
})
]; ];
// Assign a position to items that don't have one yet.
let pos = 10;
for (const definition of definitions) {
if (!definition.position) {
definition.position = pos;
pos += 10;
}
}
return definitions return definitions
.filter(e => e.enabled) .filter(e => e.enabled)
.toSorted((a, b) => a.position - b.position) .toSorted((a, b) => (a.position ?? 10) - (b.position ?? 10))
.map(e => e.el); .map(e => e.el);
} }
@@ -99,7 +115,7 @@ function useSplit(visible: boolean) {
}, [ visible ]); }, [ visible ]);
} }
function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) { function CustomLegacyWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
return ( return (

View File

@@ -30,7 +30,8 @@
"dependencies": { "dependencies": {
"better-sqlite3": "12.5.0", "better-sqlite3": "12.5.0",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"node-html-parser": "7.0.1" "node-html-parser": "7.0.1",
"sucrase": "3.35.1"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "0.71.2", "@anthropic-ai/sdk": "0.71.2",

View File

@@ -1,26 +1,25 @@
"use strict";
import protectedSessionService from "../../services/protected_session.js";
import log from "../../services/log.js";
import sql from "../../services/sql.js";
import optionService from "../../services/options.js";
import eraseService from "../../services/erase.js";
import utils from "../../services/utils.js";
import dateUtils from "../../services/date_utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import BRevision from "./brevision.js";
import BAttachment from "./battachment.js";
import TaskContext from "../../services/task_context.js";
import { dayjs } from "@triliumnext/commons";
import eventService from "../../services/events.js";
import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons"; import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons";
import type BBranch from "./bbranch.js"; import { dayjs } from "@triliumnext/commons";
import BAttribute from "./battribute.js";
import type { NotePojo } from "../becca-interface.js";
import searchService from "../../services/search/services/search.js";
import cloningService from "../../services/cloning.js"; import cloningService from "../../services/cloning.js";
import noteService from "../../services/notes.js"; import dateUtils from "../../services/date_utils.js";
import eraseService from "../../services/erase.js";
import eventService from "../../services/events.js";
import handlers from "../../services/handlers.js"; import handlers from "../../services/handlers.js";
import log from "../../services/log.js";
import noteService from "../../services/notes.js";
import optionService from "../../services/options.js";
import protectedSessionService from "../../services/protected_session.js";
import searchService from "../../services/search/services/search.js";
import sql from "../../services/sql.js";
import TaskContext from "../../services/task_context.js";
import utils from "../../services/utils.js";
import type { NotePojo } from "../becca-interface.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import BAttachment from "./battachment.js";
import BAttribute from "./battribute.js";
import type BBranch from "./bbranch.js";
import BRevision from "./brevision.js";
const LABEL = "label"; const LABEL = "label";
const RELATION = "relation"; const RELATION = "relation";
@@ -296,6 +295,10 @@ class BNote extends AbstractBeccaEntity<BNote> {
); );
} }
isJsx() {
return (this.type === "code" && this.mime === "text/jsx");
}
/** @returns true if this note is HTML */ /** @returns true if this note is HTML */
isHtml() { isHtml() {
return ["code", "file", "render"].includes(this.type) && this.mime === "text/html"; return ["code", "file", "render"].includes(this.type) && this.mime === "text/html";
@@ -355,9 +358,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
return this.__attributeCache.filter((attr) => attr.type === type); return this.__attributeCache.filter((attr) => attr.type === type);
} else if (name) { } else if (name) {
return this.__attributeCache.filter((attr) => attr.name === name); return this.__attributeCache.filter((attr) => attr.name === name);
} else {
return this.__attributeCache;
} }
return this.__attributeCache;
} }
private __ensureAttributeCacheIsAvailable() { private __ensureAttributeCacheIsAvailable() {
@@ -692,9 +695,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
return this.ownedAttributes.filter((attr) => attr.type === type); return this.ownedAttributes.filter((attr) => attr.type === type);
} else if (name) { } else if (name) {
return this.ownedAttributes.filter((attr) => attr.name === name); return this.ownedAttributes.filter((attr) => attr.name === name);
} else {
return this.ownedAttributes;
} }
return this.ownedAttributes;
} }
/** /**
@@ -745,9 +748,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
return 1; return 1;
} else if (a.parentNote?.isHiddenCompletely()) { } else if (a.parentNote?.isHiddenCompletely()) {
return 1; return 1;
} else {
return 0;
} }
return 0;
}); });
this.parents = this.parentBranches.map((branch) => branch.parentNote).filter((note) => !!note) as BNote[]; this.parents = this.parentBranches.map((branch) => branch.parentNote).filter((note) => !!note) as BNote[];
@@ -1178,9 +1181,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
return a.isArchived ? 1 : -1; return a.isArchived ? 1 : -1;
} else if (a.isHidden !== b.isHidden) { } else if (a.isHidden !== b.isHidden) {
return a.isHidden ? 1 : -1; return a.isHidden ? 1 : -1;
} else {
return a.notePath.length - b.notePath.length;
} }
return a.notePath.length - b.notePath.length;
}); });
return notePaths; return notePaths;
@@ -1257,9 +1260,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
} else { } else {
new BAttribute({ new BAttribute({
noteId: this.noteId, noteId: this.noteId,
type: type, type,
name: name, name,
value: value value
}).save(); }).save();
} }
} }
@@ -1292,11 +1295,11 @@ class BNote extends AbstractBeccaEntity<BNote> {
addAttribute(type: AttributeType, name: string, value: string = "", isInheritable: boolean = false, position: number | null = null): BAttribute { addAttribute(type: AttributeType, name: string, value: string = "", isInheritable: boolean = false, position: number | null = null): BAttribute {
return new BAttribute({ return new BAttribute({
noteId: this.noteId, noteId: this.noteId,
type: type, type,
name: name, name,
value: value, value,
isInheritable: isInheritable, isInheritable,
position: position position
}).save(); }).save();
} }
@@ -1470,10 +1473,10 @@ class BNote extends AbstractBeccaEntity<BNote> {
role: "image", role: "image",
mime: this.mime, mime: this.mime,
title: this.title, title: this.title,
content: content content
}); });
let parentContent = parentNote.getContent(); const parentContent = parentNote.getContent();
const oldNoteUrl = `api/images/${this.noteId}/`; const oldNoteUrl = `api/images/${this.noteId}/`;
const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`; const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`;
@@ -1712,14 +1715,14 @@ class BNote extends AbstractBeccaEntity<BNote> {
} else if (this.type === "text") { } else if (this.type === "text") {
if (this.isFolder()) { if (this.isFolder()) {
return "bx bx-folder"; return "bx bx-folder";
} else {
return "bx bx-note";
} }
return "bx bx-note";
} else if (this.type === "code" && this.mime.startsWith("text/x-sql")) { } else if (this.type === "code" && this.mime.startsWith("text/x-sql")) {
return "bx bx-data"; return "bx bx-data";
} else {
return NOTE_TYPE_ICONS[this.type];
} }
return NOTE_TYPE_ICONS[this.type];
} }
// TODO: Deduplicate with fnote // TODO: Deduplicate with fnote
@@ -1729,7 +1732,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
// TODO: Deduplicate with fnote // TODO: Deduplicate with fnote
getFilteredChildBranches() { getFilteredChildBranches() {
let childBranches = this.getChildBranches(); const childBranches = this.getChildBranches();
if (!childBranches) { if (!childBranches) {
console.error(`No children for '${this.noteId}'. This shouldn't happen.`); console.error(`No children for '${this.noteId}'. This shouldn't happen.`);

View File

@@ -1,9 +1,11 @@
import { trimIndentation } from "@triliumnext/commons";
import becca from "../becca/becca.js"; import becca from "../becca/becca.js";
import { note, NoteBuilder } from "../test/becca_mocking.js";
import cls from "./cls.js";
import { executeBundle, getScriptBundle } from "./script.js";
import BBranch from "../becca/entities/bbranch.js"; import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js"; import BNote from "../becca/entities/bnote.js";
import { note, NoteBuilder } from "../test/becca_mocking.js";
import cls from "./cls.js";
import { buildJsx, executeBundle, getScriptBundle } from "./script.js";
describe("Script", () => { describe("Script", () => {
@@ -84,3 +86,96 @@ describe("Script", () => {
}); });
}); });
}); });
describe("JSX building", () => {
it("processes basic JSX", () => {
const script = trimIndentation`\
function MyComponent() {
return <p>Hello world.</p>;
}
`;
const expected = trimIndentation`\
"use strict";function MyComponent() {
return api.preact.h('p', null, "Hello world." );
}
`;
expect(buildJsx(script).code).toStrictEqual(expected);
});
it("processes fragments", () => {
const script = trimIndentation`\
function MyComponent() {
return <>
<p>Hi</p>
<p>there</p>
</>;
}
`;
const expected = trimIndentation`\
"use strict";function MyComponent() {
return api.preact.h(api.preact.Fragment, null
, api.preact.h('p', null, "Hi")
, api.preact.h('p', null, "there")
);
}
`;
expect(buildJsx(script).code).toStrictEqual(expected);
});
it("rewrites export", () => {
const script = trimIndentation`\
const { defineWidget } = api.preact;
export default defineWidget({
parent: "right-pane",
render() {
return <></>;
}
});
`;
const expected = trimIndentation`\
"use strict";Object.defineProperty(exports, "__esModule", {value: true});const { defineWidget } = api.preact;
module.exports = defineWidget({
parent: "right-pane",
render() {
return api.preact.h(api.preact.Fragment, null);
}
});
`;
expect(buildJsx(script).code).toStrictEqual(expected);
});
it("rewrites React API imports", () => {
const script = trimIndentation`\
import { defineWidget, RightPanelWidget} from "trilium:preact";
defineWidget({
render() {
return <RightPanelWidget />;
}
});
`;
const expected = trimIndentation`\
"use strict";const _triliumpreact = api.preact;
_triliumpreact.defineWidget.call(void 0, {
render() {
return api.preact.h(_triliumpreact.RightPanelWidget, null );
}
});
`;
expect(buildJsx(script).code).toStrictEqual(expected);
});
it("rewrites internal API imports", () => {
const script = trimIndentation`\
import { log } from "trilium:api";
log("Hi");
`;
const expected = trimIndentation`\
"use strict";const _triliumapi = api;
_triliumapi.log.call(void 0, "Hi");
`;
console.log(buildJsx(script).code);
expect(buildJsx(script).code).toStrictEqual(expected);
});
});

View File

@@ -1,9 +1,11 @@
import ScriptContext from "./script_context.js"; import { transform } from "sucrase";
import cls from "./cls.js";
import log from "./log.js";
import becca from "../becca/becca.js"; import becca from "../becca/becca.js";
import type BNote from "../becca/entities/bnote.js"; import type BNote from "../becca/entities/bnote.js";
import type { ApiParams } from "./backend_script_api_interface.js"; import type { ApiParams } from "./backend_script_api_interface.js";
import cls from "./cls.js";
import log from "./log.js";
import ScriptContext from "./script_context.js";
export interface Bundle { export interface Bundle {
note?: BNote; note?: BNote;
@@ -110,9 +112,9 @@ function getParams(params?: ScriptParams) {
.map((p) => { .map((p) => {
if (typeof p === "string" && p.startsWith("!@#Function: ")) { if (typeof p === "string" && p.startsWith("!@#Function: ")) {
return p.substr(13); return p.substr(13);
} else {
return JSON.stringify(p);
} }
return JSON.stringify(p);
}) })
.join(","); .join(",");
} }
@@ -145,7 +147,7 @@ export function getScriptBundle(note: BNote, root: boolean = true, scriptEnv: st
return; return;
} }
if (!note.isJavaScript() && !note.isHtml()) { if (!(note.isJavaScript() || note.isHtml() || note.isJsx())) {
return; return;
} }
@@ -158,7 +160,7 @@ export function getScriptBundle(note: BNote, root: boolean = true, scriptEnv: st
} }
const bundle: Bundle = { const bundle: Bundle = {
note: note, note,
script: "", script: "",
html: "", html: "",
allNotes: [note] allNotes: [note]
@@ -192,12 +194,18 @@ export function getScriptBundle(note: BNote, root: boolean = true, scriptEnv: st
// only frontend scripts are async. Backend cannot be async because of transaction management. // only frontend scripts are async. Backend cannot be async because of transaction management.
const isFrontend = scriptEnv === "frontend"; const isFrontend = scriptEnv === "frontend";
if (note.isJavaScript()) { if (note.isJsx() || note.isJavaScript()) {
let scriptContent = note.getContent();
if (note.isJsx()) {
scriptContent = buildJsx(scriptContent).code;
}
bundle.script += ` bundle.script += `
apiContext.modules['${note.noteId}'] = { exports: {} }; apiContext.modules['${note.noteId}'] = { exports: {} };
${root ? "return " : ""}${isFrontend ? "await" : ""} ((${isFrontend ? "async" : ""} function(exports, module, require, api${modules.length > 0 ? ", " : ""}${modules.map((child) => sanitizeVariableName(child.title)).join(", ")}) { ${root ? "return " : ""}${isFrontend ? "await" : ""} ((${isFrontend ? "async" : ""} function(exports, module, require, api${modules.length > 0 ? ", " : ""}${modules.map((child) => sanitizeVariableName(child.title)).join(", ")}) {
try { try {
${overrideContent || note.getContent()}; ${overrideContent || scriptContent};
} catch (e) { throw new Error("Load of script note \\"${note.title}\\" (${note.noteId}) failed with: " + e.message); } } catch (e) { throw new Error("Load of script note \\"${note.title}\\" (${note.noteId}) failed with: " + e.message); }
for (const exportKey in exports) module.exports[exportKey] = exports[exportKey]; for (const exportKey in exports) module.exports[exportKey] = exports[exportKey];
return module.exports; return module.exports;
@@ -210,6 +218,39 @@ return module.exports;
return bundle; return bundle;
} }
export function buildJsx(contentRaw: string | Buffer) {
const content = Buffer.isBuffer(contentRaw) ? contentRaw.toString("utf-8") : contentRaw;
const output = transform(content, {
transforms: ["jsx", "imports"],
jsxPragma: "api.preact.h",
jsxFragmentPragma: "api.preact.Fragment",
production: true
});
let code = output.code;
// Rewrite ESM-like exports to `module.exports =`.
code = output.code.replaceAll(
/\bexports\s*\.\s*default\s*=\s*/g,
'module.exports = '
);
// Rewrite ESM-like imports to Preact, to `const { foo } = api.preact`
code = code.replaceAll(
/var\s+(\w+)\s*=\s*require\(['"]trilium:preact['"]\);?/g,
'const $1 = api.preact;'
);
// Rewrite ESM-like imports to internal API, to `const { foo } = api.preact`
code = code.replaceAll(
/var\s+(\w+)\s*=\s*require\(['"]trilium:api['"]\);?/g,
'const $1 = api;'
);
output.code = code;
return output;
}
function sanitizeVariableName(str: string) { function sanitizeVariableName(str: string) {
return str.replace(/[^a-z0-9_]/gim, ""); return str.replace(/[^a-z0-9_]/gim, "");
} }

68
pnpm-lock.yaml generated
View File

@@ -494,6 +494,9 @@ importers:
node-html-parser: node-html-parser:
specifier: 7.0.1 specifier: 7.0.1
version: 7.0.1 version: 7.0.1
sucrase:
specifier: 3.35.1
version: 3.35.1
devDependencies: devDependencies:
'@anthropic-ai/sdk': '@anthropic-ai/sdk':
specifier: 0.71.2 specifier: 0.71.2
@@ -6255,6 +6258,9 @@ packages:
any-base@1.1.0: any-base@1.1.0:
resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
anymatch@3.1.3: anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6951,6 +6957,10 @@ packages:
commander@2.20.3: commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
commander@5.1.0: commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -10618,6 +10628,9 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nan@2.22.2: nan@2.22.2:
resolution: {integrity: sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==} resolution: {integrity: sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==}
@@ -11238,6 +11251,10 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'} engines: {node: '>=6'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pixelmatch@5.3.0: pixelmatch@5.3.0:
resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==}
hasBin: true hasBin: true
@@ -13136,6 +13153,11 @@ packages:
stylis@4.3.6: stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
sugarss@4.0.1: sugarss@4.0.1:
resolution: {integrity: sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw==} resolution: {integrity: sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw==}
engines: {node: '>=12.0'} engines: {node: '>=12.0'}
@@ -13279,6 +13301,13 @@ packages:
text-decoder@1.2.3: text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
thingies@2.5.0: thingies@2.5.0:
resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==}
engines: {node: '>=10.18'} engines: {node: '>=10.18'}
@@ -13430,6 +13459,9 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'} engines: {node: '>=6.10'}
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
ts-loader@9.5.4: ts-loader@9.5.4:
resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==} resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -15290,8 +15322,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0
'@ckeditor/ckeditor5-watchdog': 47.3.0 '@ckeditor/ckeditor5-watchdog': 47.3.0
es-toolkit: 1.39.5 es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-dev-build-tools@54.2.2(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': '@ckeditor/ckeditor5-dev-build-tools@54.2.2(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)':
dependencies: dependencies:
@@ -15797,8 +15827,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0
'@ckeditor/ckeditor5-widget': 47.3.0 '@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0 ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-mention@47.3.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': '@ckeditor/ckeditor5-mention@47.3.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
dependencies: dependencies:
@@ -21149,6 +21177,8 @@ snapshots:
any-base@1.1.0: {} any-base@1.1.0: {}
any-promise@1.3.0: {}
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
@@ -22097,6 +22127,8 @@ snapshots:
commander@2.20.3: {} commander@2.20.3: {}
commander@4.1.1: {}
commander@5.1.0: {} commander@5.1.0: {}
commander@6.2.0: {} commander@6.2.0: {}
@@ -26816,6 +26848,12 @@ snapshots:
mute-stream@2.0.0: mute-stream@2.0.0:
optional: true optional: true
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
nan@2.22.2: nan@2.22.2:
optional: true optional: true
@@ -27473,6 +27511,8 @@ snapshots:
pify@4.0.1: pify@4.0.1:
optional: true optional: true
pirates@4.0.7: {}
pixelmatch@5.3.0: pixelmatch@5.3.0:
dependencies: dependencies:
pngjs: 6.0.0 pngjs: 6.0.0
@@ -29714,6 +29754,16 @@ snapshots:
stylis@4.3.6: {} stylis@4.3.6: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
commander: 4.1.1
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.7
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
sugarss@4.0.1(postcss@8.5.6): sugarss@4.0.1(postcss@8.5.6):
dependencies: dependencies:
postcss: 8.5.6 postcss: 8.5.6
@@ -29950,6 +30000,14 @@ snapshots:
dependencies: dependencies:
b4a: 1.6.7 b4a: 1.6.7
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
thenify@3.3.1:
dependencies:
any-promise: 1.3.0
thingies@2.5.0(tslib@2.8.1): thingies@2.5.0(tslib@2.8.1):
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -30085,6 +30143,8 @@ snapshots:
ts-dedent@2.2.0: {} ts-dedent@2.2.0: {}
ts-interface-checker@0.1.13: {}
ts-loader@9.5.4(typescript@5.0.4)(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2)): ts-loader@9.5.4(typescript@5.0.4)(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2)):
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2