diff --git a/apps/client/src/services/attributes.ts b/apps/client/src/services/attributes.ts index 2bff933244..77558c9464 100644 --- a/apps/client/src/services/attributes.ts +++ b/apps/client/src/services/attributes.ts @@ -168,6 +168,49 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin return false; } +/** + * Toggles whether a dangerous attribute is enabled or not. When an attribute is disabled, its name is prefixed with `disabled:`. + * + * Note that this work for non-dangerous attributes as well. + * + * If there are multiple attributes with the same name, all of them will be toggled at the same time. + * + * @param note the note whose attribute to change. + * @param type the type of dangerous attribute (label or relation). + * @param name the name of the dangerous attribute. + * @param willEnable whether to enable or disable the attribute. + * @returns a promise that will resolve when the request to the server completes. + */ +async function toggleDangerousAttribute(note: FNote, type: "label" | "relation", name: string, willEnable: boolean) { + const attrs = [ + ...note.getOwnedAttributes(type, name), + ...note.getOwnedAttributes(type, `disabled:${name}`) + ]; + + for (const attr of attrs) { + const baseName = getNameWithoutDangerousPrefix(attr.name); + const newName = willEnable ? baseName : `disabled:${baseName}`; + if (newName === attr.name) continue; + + // We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically. + if (attr.type === "label") { + await setLabel(note.noteId, newName, attr.value); + } else { + await setRelation(note.noteId, newName, attr.value); + } + await removeAttributeById(note.noteId, attr.attributeId); + } +} + +/** + * Returns the name of an attribute without the `disabled:` prefix, or the same name if it's not disabled. + * @param name the name of an attribute. + * @returns the name without the `disabled:` prefix. + */ +function getNameWithoutDangerousPrefix(name: string) { + return name.startsWith("disabled:") ? name.substring(9) : name; +} + export default { addLabel, setLabel, @@ -177,5 +220,7 @@ export default { removeAttributeById, removeOwnedLabelByName, removeOwnedRelationByName, - isAffecting + isAffecting, + toggleDangerousAttribute, + getNameWithoutDangerousPrefix }; diff --git a/apps/client/src/services/render.ts b/apps/client/src/services/render.ts index adfd8a4949..f09d26532d 100644 --- a/apps/client/src/services/render.ts +++ b/apps/client/src/services/render.ts @@ -6,7 +6,7 @@ import bundleService, { type Bundle } from "./bundle.js"; import froca from "./froca.js"; import server from "./server.js"; -async function render(note: FNote, $el: JQuery) { +async function render(note: FNote, $el: JQuery, onError?: (e: unknown) => void) { const relations = note.getRelations("renderNote"); const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId); @@ -21,12 +21,14 @@ async function render(note: FNote, $el: JQuery) { $scriptContainer.append(bundle.html); // async so that scripts cannot block trilium execution - bundleService.executeBundle(bundle, note, $scriptContainer).then(result => { - // Render JSX - if (bundle.html === "") { - renderIfJsx(bundle, result, $el); - } - }); + bundleService.executeBundle(bundle, note, $scriptContainer) + .catch(onError) + .then(result => { + // Render JSX + if (bundle.html === "") { + renderIfJsx(bundle, result, $el).catch(onError); + } + }); } return renderNoteIds.length > 0; diff --git a/apps/client/src/stylesheets/theme-next/forms.css b/apps/client/src/stylesheets/theme-next/forms.css index bccf7ab6d4..2fc8a39dbd 100644 --- a/apps/client/src/stylesheets/theme-next/forms.css +++ b/apps/client/src/stylesheets/theme-next/forms.css @@ -838,7 +838,7 @@ input[type="range"] { text-align: center; } -.tn-centered-form input, +.tn-centered-form .input-group, .tn-centered-form button { margin-top: 12px; -} \ No newline at end of file +} diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 3656035497..d2892456de 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1067,15 +1067,21 @@ "click_on_canvas_to_place_new_note": "Click on canvas to place new note" }, "render": { - "note_detail_render_help_1": "This help note is shown because this note of type Render HTML doesn't have required relation to function properly.", - "note_detail_render_help_2": "Render HTML note type is used for scripting. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a relation called \"renderNote\" pointing to the HTML note to render." + "setup_title": "Display custom HTML or Preact JSX inside this note", + "setup_create_sample_preact": "Create sample note with Preact", + "setup_create_sample_html": "Create sample note with HTML", + "setup_sample_created": "A sample note was created as a child note.", + "disabled_description": "This render notes comes from an external source. To protect you from malicious content, it is not enabled by default. Make sure you trust the source before enabling it.", + "disabled_button_enable": "Enable render note" }, "web_view_setup": { "title": "Create a live view of a webpage directly into Trilium", "url_placeholder": "Enter or paste the website address, for example https://triliumnotes.org", "create_button": "Create Web View", "invalid_url_title": "Invalid address", - "invalid_url_message": "Insert a valid web address, for example https://triliumnotes.org." + "invalid_url_message": "Insert a valid web address, for example https://triliumnotes.org.", + "disabled_description": "This web view was imported from an external source. To help protect you from phishing or malicious content, it isn’t loading automatically. You can enable it if you trust the source.", + "disabled_button_enable": "Enable web view" }, "backend_log": { "refresh": "Refresh" @@ -2312,5 +2318,8 @@ "menu_change_to_widget": "Change to widget", "menu_change_to_frontend_script": "Change to frontend script", "menu_theme_base": "Theme base" + }, + "setup_form": { + "more_info": "Learn more" } } diff --git a/apps/client/src/widgets/layout/ActiveContentBadges.tsx b/apps/client/src/widgets/layout/ActiveContentBadges.tsx index 00099d0370..3049e46243 100644 --- a/apps/client/src/widgets/layout/ActiveContentBadges.tsx +++ b/apps/client/src/widgets/layout/ActiveContentBadges.tsx @@ -203,34 +203,14 @@ function ActiveContentToggle({ note, info }: { note: FNote, info: ActiveContentI switchOffTooltip={t("active_content_badges.toggle_tooltip_disable_tooltip", { type: title })} switchOnTooltip={t("active_content_badges.toggle_tooltip_enable_tooltip", { type: title })} onChange={async (willEnable) => { - const attrs = note.getOwnedAttributes() - .filter(attr => { - if (attr.isInheritable) return false; - const baseName = getNameWithoutPrefix(attr.name); - return DANGEROUS_ATTRIBUTES.some(item => item.name === baseName && item.type === attr.type); - }); - - for (const attr of attrs) { - const baseName = getNameWithoutPrefix(attr.name); - const newName = willEnable ? baseName : `disabled:${baseName}`; - if (newName === attr.name) continue; - - // We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically. - if (attr.type === "label") { - await attributes.setLabel(note.noteId, newName, attr.value); - } else { - await attributes.setRelation(note.noteId, newName, attr.value); - } - await attributes.removeAttributeById(note.noteId, attr.attributeId); - } + await Promise.all(note.getOwnedAttributes() + .map(attr => ({ name: attributes.getNameWithoutDangerousPrefix(attr.name), type: attr.type })) + .filter(({ name, type }) => DANGEROUS_ATTRIBUTES.some(item => item.name === name && item.type === type)) + .map(({ name, type }) => attributes.toggleDangerousAttribute(note, type, name, willEnable))); }} />; } -function getNameWithoutPrefix(name: string) { - return name.startsWith("disabled:") ? name.substring(9) : name; -} - function useActiveContentInfo(note: FNote | null | undefined) { const [ info, setInfo ] = useState(null); @@ -247,11 +227,9 @@ function useActiveContentInfo(note: FNote | null | undefined) { if (note.type === "render") { type = "renderNote"; isEnabled = note.hasRelation("renderNote"); - canToggleEnabled = note.hasRelation("renderNote") || note.hasRelation("disabled:renderNote"); } else if (note.type === "webView") { type = "webView"; isEnabled = note.hasLabel("webViewSrc"); - canToggleEnabled = note.hasLabelOrDisabled("webViewSrc"); } else if (note.type === "code" && note.mime === "application/javascript;env=backend") { type = "backendScript"; for (const backendLabel of [ "run", "customRequestHandler", "customResourceProvider" ]) { diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 8eb2cc0efd..c41e1e1a1c 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -551,7 +551,12 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: Re useTriliumEvent("entitiesReloaded", ({ loadResults }) => { for (const attr of loadResults.getAttributeRows()) { if (attr.type === "relation" && attr.name === relationName && attributes.isAffecting(attr, note)) { - setRelationValue(attr.value ?? null); + if (!attr.isDeleted) { + setRelationValue(attr.value ?? null); + } else { + setRelationValue(null); + } + break; } } }); @@ -601,6 +606,7 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLa } else { setLabelValue(null); } + break; } } }); diff --git a/apps/client/src/widgets/type_widgets/Render.css b/apps/client/src/widgets/type_widgets/Render.css index 2595da0d84..7597b47cd2 100644 --- a/apps/client/src/widgets/type_widgets/Render.css +++ b/apps/client/src/widgets/type_widgets/Render.css @@ -1,8 +1,7 @@ .note-detail-render { position: relative; -} -.note-detail-render .note-detail-render-help { - margin: 50px; - padding: 20px; -} \ No newline at end of file + &>.admonition { + margin: 1em; + } +} diff --git a/apps/client/src/widgets/type_widgets/Render.tsx b/apps/client/src/widgets/type_widgets/Render.tsx index f47e58f2de..4d4fd4cd8e 100644 --- a/apps/client/src/widgets/type_widgets/Render.tsx +++ b/apps/client/src/widgets/type_widgets/Render.tsx @@ -2,22 +2,59 @@ import "./Render.css"; import { useEffect, useRef, useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; import attributes from "../../services/attributes"; import { t } from "../../services/i18n"; +import note_create from "../../services/note_create"; import render from "../../services/render"; -import Alert from "../react/Alert"; -import { useTriliumEvent } from "../react/hooks"; -import RawHtml from "../react/RawHtml"; +import toast from "../../services/toast"; +import { getErrorMessage } from "../../services/utils"; +import Admonition from "../react/Admonition"; +import Button, { SplitButton } from "../react/Button"; +import FormGroup from "../react/FormGroup"; +import { FormListItem } from "../react/FormList"; +import { useNoteRelation, useTriliumEvent } from "../react/hooks"; +import NoteAutocomplete from "../react/NoteAutocomplete"; import { refToJQuerySelector } from "../react/react_utils"; +import SetupForm from "./helpers/SetupForm"; import { TypeWidgetProps } from "./type_widget"; -export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) { +const HELP_PAGE = "HcABDtFCkbFN"; + +const PREACT_SAMPLE = /*js*/`\ +export default function() { + return

Hello world.

; +} +`; + +const HTML_SAMPLE = /*html*/`\ +

Hello world.

+`; + +export default function Render(props: TypeWidgetProps) { + const { note } = props; + const [ renderNote ] = useNoteRelation(note, "renderNote"); + const [ disabledRenderNote ] = useNoteRelation(note, "disabled:renderNote"); + + if (disabledRenderNote) { + return ; + } + + if (!renderNote) { + return ; + } + + return ; +} + +function RenderContent({ note, noteContext, ntxId }: TypeWidgetProps) { const contentRef = useRef(null); - const [ renderNotesFound, setRenderNotesFound ] = useState(false); + const [ error, setError ] = useState(null); function refresh() { if (!contentRef) return; - render.render(note, refToJQuerySelector(contentRef)).then(setRenderNotesFound); + setError(null); + render.render(note, refToJQuerySelector(contentRef), setError); } useEffect(refresh, [ note ]); @@ -49,14 +86,72 @@ export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) { return ( <> - {!renderNotesFound && ( - -

{t("render.note_detail_render_help_1")}

-

-
+ {error && ( + + {getErrorMessage(error)} + )} -
); } + +function DisabledRender({ note }: TypeWidgetProps) { + return ( + +

{t("render.disabled_description")}

+