Port settings to React (#6660)

This commit is contained in:
Elian Doran
2025-08-20 20:28:54 +03:00
committed by GitHub
137 changed files with 5624 additions and 6908 deletions

View File

@@ -52,6 +52,7 @@
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.27.1",
"react-i18next": "15.6.1",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",

View File

@@ -35,8 +35,10 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
} else if (ec.entityName === "blobs") {
// NOOP - these entities are handled at the backend level and don't require frontend processing
} else if (ec.entityName === "etapi_tokens") {
loadResults.hasEtapiTokenChanges = true;
} else {
throw new Error(`Unknown entityName '${ec.entityName}'`);
}
@@ -77,9 +79,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
noteAttributeCache.invalidate();
}
// TODO: Remove after porting the file
// @ts-ignore
const appContext = (await import("../components/app_context.js")).default as any;
const appContext = (await import("../components/app_context.js")).default;
await appContext.triggerEvent("entitiesReloaded", { loadResults });
}
}

View File

@@ -3,6 +3,7 @@ import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend";
import server from "./server.js";
import type { Locale } from "@triliumnext/commons";
import { initReactI18next } from "react-i18next";
let locales: Locale[] | null;
@@ -16,6 +17,7 @@ export async function initLocale() {
locales = await server.get<Locale[]>("options/locales");
i18next.use(initReactI18next);
await i18next.use(i18nextHttpBackend).init({
lng: locale,
fallbackLng: "en",

View File

@@ -1,4 +1,4 @@
import type { AttachmentRow } from "@triliumnext/commons";
import type { AttachmentRow, EtapiTokenRow } from "@triliumnext/commons";
import type { AttributeType } from "../entities/fattribute.js";
import type { EntityChange } from "../server_types.js";
@@ -53,6 +53,7 @@ type EntityRowMappings = {
options: OptionRow;
revisions: RevisionRow;
note_reordering: NoteReorderingRow;
etapi_tokens: EtapiTokenRow;
};
export type EntityRowNames = keyof EntityRowMappings;
@@ -68,6 +69,7 @@ export default class LoadResults {
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: string[];
private attachmentRows: AttachmentRow[];
public hasEtapiTokenChanges: boolean = false;
constructor(entityChanges: EntityChange[]) {
const entities: Record<string, Record<string, any>> = {};
@@ -215,7 +217,8 @@ export default class LoadResults {
this.revisionRows.length === 0 &&
this.contentNoteIdToComponentId.length === 0 &&
this.optionNames.length === 0 &&
this.attachmentRows.length === 0
this.attachmentRows.length === 0 &&
!this.hasEtapiTokenChanges
);
}

View File

@@ -1,7 +1,8 @@
import { OptionNames } from "@triliumnext/commons";
import server from "./server.js";
import { isShare } from "./utils.js";
type OptionValue = number | string;
export type OptionValue = number | string;
class Options {
initializedPromise: Promise<void>;
@@ -76,6 +77,10 @@ class Options {
await server.put(`options`, payload);
}
async saveMany<T extends OptionNames>(newValues: Record<T, OptionValue>) {
await server.put<void>("options", newValues);
}
async toggle(key: string) {
await this.save(key, (!this.is(key)).toString());
}

View File

@@ -5,7 +5,7 @@ const SVG_MIME = "image/svg+xml";
export const isShare = !window.glob;
function reloadFrontendApp(reason?: string) {
export function reloadFrontendApp(reason?: string) {
if (reason) {
logInfo(`Frontend app reload: ${reason}`);
}
@@ -13,7 +13,7 @@ function reloadFrontendApp(reason?: string) {
window.location.reload();
}
function restartDesktopApp() {
export function restartDesktopApp() {
if (!isElectron()) {
reloadFrontendApp();
return;
@@ -125,7 +125,7 @@ function formatDateISO(date: Date) {
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
}
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
export function formatDateTime(date: Date, userSuppliedFormat?: string): string {
if (userSuppliedFormat?.trim()) {
return dayjs(date).format(userSuppliedFormat);
} else {
@@ -144,7 +144,7 @@ function now() {
/**
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
*/
function isElectron() {
export function isElectron() {
return !!(window && window.process && window.process.type);
}
@@ -218,7 +218,7 @@ function randomString(len: number) {
return text;
}
function isMobile() {
export function isMobile() {
return (
window.glob?.device === "mobile" ||
// window.glob.device is not available in setup
@@ -306,7 +306,7 @@ function copySelectionToClipboard() {
}
}
function dynamicRequire(moduleName: string) {
export function dynamicRequire(moduleName: string) {
if (typeof __non_webpack_require__ !== "undefined") {
return __non_webpack_require__(moduleName);
} else {
@@ -374,33 +374,36 @@ async function openInAppHelp($button: JQuery<HTMLElement>) {
const inAppHelpPage = $button.attr("data-in-app-help");
if (inAppHelpPage) {
// Dynamic import to avoid import issues in tests.
const appContext = (await import("../components/app_context.js")).default;
const activeContext = appContext.tabManager.getActiveContext();
if (!activeContext) {
return;
}
const subContexts = activeContext.getSubContexts();
const targetNote = `_help_${inAppHelpPage}`;
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
const viewScope: ViewScope = {
viewMode: "contextual-help",
};
if (!helpSubcontext) {
// The help is not already open, open a new split with it.
const { ntxId } = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", {
ntxId,
notePath: targetNote,
hoistedNoteId: "_help",
viewScope
})
} else {
// There is already a help window open, make sure it opens on the right note.
helpSubcontext.setNote(targetNote, { viewScope });
}
openInAppHelpFromUrl(inAppHelpPage);
}
}
export async function openInAppHelpFromUrl(inAppHelpPage: string) {
// Dynamic import to avoid import issues in tests.
const appContext = (await import("../components/app_context.js")).default;
const activeContext = appContext.tabManager.getActiveContext();
if (!activeContext) {
return;
}
const subContexts = activeContext.getSubContexts();
const targetNote = `_help_${inAppHelpPage}`;
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
const viewScope: ViewScope = {
viewMode: "contextual-help",
};
if (!helpSubcontext) {
// The help is not already open, open a new split with it.
const { ntxId } = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", {
ntxId,
notePath: targetNote,
hoistedNoteId: "_help",
viewScope
})
} else {
// There is already a help window open, make sure it opens on the right note.
helpSubcontext.setNote(targetNote, { viewScope });
}
}
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
@@ -735,6 +738,35 @@ function isLaunchBarConfig(noteId: string) {
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
}
export function toggleBodyClass(prefix: string, value: string) {
const $body = $("body");
for (const clazz of Array.from($body[0].classList)) {
// create copy to safely iterate over while removing classes
if (clazz.startsWith(prefix)) {
$body.removeClass(clazz);
}
}
$body.addClass(prefix + value);
}
export function arrayEqual<T>(a: T[], b: T[]) {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
for (let i=0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
export default {
reloadFrontendApp,
restartDesktopApp,

View File

@@ -139,6 +139,15 @@ textarea,
color: var(--muted-text-color);
}
.form-group.disabled {
opacity: 0.5;
pointer-events: none;
}
.form-group {
margin-bottom: 15px;
}
/* Add a gap between consecutive radios / check boxes */
label.tn-radio + label.tn-radio,
label.tn-checkbox + label.tn-checkbox {
@@ -1738,16 +1747,12 @@ button.close:hover {
margin-bottom: 10px;
}
.options-number-input {
.options-section input[type="number"] {
/* overriding settings from .form-control */
width: 10em !important;
flex-grow: 0 !important;
}
.options-mime-types {
column-width: 250px;
}
textarea {
cursor: auto;
}

View File

@@ -181,9 +181,7 @@ div.note-detail-empty {
}
.options-section:not(.tn-no-card) {
margin: auto;
min-width: var(--options-card-min-width);
max-width: var(--options-card-max-width);
margin: auto;
border-radius: 12px;
border: 1px solid var(--card-border-color) !important;
box-shadow: var(--card-box-shadow);
@@ -192,6 +190,11 @@ div.note-detail-empty {
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
}
body.desktop .option-section:not(.tn-no-card) {
min-width: var(--options-card-min-width);
max-width: var(--options-card-max-width);
}
.note-detail-content-widget-content.options {
--default-padding: 15px;
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
@@ -233,11 +236,6 @@ div.note-detail-empty {
margin-bottom: 0;
}
.options-section .options-mime-types {
padding: 0;
margin: 0;
}
.options-section .form-group {
margin-bottom: 1em;
}

View File

@@ -1165,7 +1165,7 @@
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "笔记修订快照间隔",
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <doc>wiki</doc>。",
"snapshot_time_interval_label": "笔记修订快照时间间隔:"
},
"revisions_snapshot_limit": {
@@ -1878,7 +1878,7 @@
},
"custom_date_time_format": {
"title": "自定义日期/时间格式",
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
"description": "通过<shortcut />或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
"format_string": "日期/时间格式字符串:",
"formatted_time": "格式化后日期/时间:"
},

File diff suppressed because it is too large Load Diff

View File

@@ -1253,7 +1253,12 @@
"selected_provider": "Selected Provider",
"selected_provider_description": "Choose the AI provider for chat and completion features",
"select_model": "Select model...",
"select_provider": "Select provider..."
"select_provider": "Select provider...",
"ai_enabled": "AI features enabled",
"ai_disabled": "AI features disabled",
"no_models_found_online": "No models found. Please check your API key and settings.",
"no_models_found_ollama": "No Ollama models found. Please check if Ollama is running.",
"error_fetching": "Error fetching models: {{error}}"
},
"zoom_factor": {
"title": "Zoom Factor (desktop build only)",
@@ -1310,7 +1315,7 @@
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> for more info.",
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <doc>wiki</doc> for more info.",
"snapshot_time_interval_label": "Note revision snapshot time interval:"
},
"revisions_snapshot_limit": {
@@ -1372,7 +1377,7 @@
},
"custom_date_time_format": {
"title": "Custom Date/Time Format",
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
"description": "Customize the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens.",
"format_string": "Format string:",
"formatted_time": "Formatted date/time:"
},
@@ -2001,5 +2006,15 @@
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
"background_effects_button": "Enable background effects",
"dismiss": "Dismiss"
},
"settings": {
"related_settings": "Related settings"
},
"settings_appearance": {
"related_code_blocks": "Color scheme for code blocks in text notes",
"related_code_notes": "Color scheme for code notes"
},
"units": {
"percentage": "%"
}
}

View File

@@ -1310,7 +1310,7 @@
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Intervalo de instantáneas de revisiones de notas",
"note_revisions_snapshot_description": "El intervalo de tiempo de la instantánea de revisión de nota es el tiempo después de lo cual se creará una nueva revisión para la nota. Ver <a href=\"https://triliumnext.github.io/docs/wiki/note-revisions.html\" class=\"external\"> wiki </a> para obtener más información.",
"note_revisions_snapshot_description": "El intervalo de tiempo de la instantánea de revisión de nota es el tiempo después de lo cual se creará una nueva revisión para la nota. Ver <doc>wiki</doc> para obtener más información.",
"snapshot_time_interval_label": "Intervalo de tiempo de la instantánea de revisión de notas:"
},
"revisions_snapshot_limit": {
@@ -1372,7 +1372,7 @@
},
"custom_date_time_format": {
"title": "Formato de fecha/hora personalizada",
"description": "Personalizar el formado de fecha y la hora insertada vía <kbd></kbd> o la barra de herramientas. Véa la <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">documentación de Day.js</a> para más tokens de formato disponibles.",
"description": "Personalizar el formado de fecha y la hora insertada vía <shortcut /> o la barra de herramientas. Véa la <doc>documentación de Day.js</doc> para más tokens de formato disponibles.",
"format_string": "Cadena de formato:",
"formatted_time": "Fecha/hora personalizada:"
},

View File

@@ -1163,7 +1163,7 @@
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Délai d'enregistrement automatique d'une version de note",
"note_revisions_snapshot_description": "Le délai d'enregistrement automatique des versions de note définit le temps avant la création automatique d'une nouvelle version de note. Consultez le <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> pour plus d'informations.",
"note_revisions_snapshot_description": "Le délai d'enregistrement automatique des versions de note définit le temps avant la création automatique d'une nouvelle version de note. Consultez le <doc>wiki</doc> pour plus d'informations.",
"snapshot_time_interval_label": "Délai d'enregistrement automatique de version de note :"
},
"revisions_snapshot_limit": {

View File

@@ -835,7 +835,7 @@
},
"custom_date_time_format": {
"title": "日付/時刻フォーマットのカスタム",
"description": "<kbd></kbd>またはツールバーから挿入される日付と時刻のフォーマットをカスタマイズする。 利用可能なトークンについては <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js ドキュメント</a> を参照してください。",
"description": "<shortcut />またはツールバーから挿入される日付と時刻のフォーマットをカスタマイズする。 利用可能なトークンについては <doc>Day.js ドキュメント</doc> を参照してください。",
"format_string": "文字列形式:",
"formatted_time": "日付/時刻形式:"
},

View File

@@ -1076,7 +1076,7 @@
"note_revisions": "Revizii ale notiței"
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_description": "Intervalul de salvare a reviziilor este timpul după care se crează o nouă revizie a unei notițe. Vedeți <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki-ul</a> pentru mai multe informații.",
"note_revisions_snapshot_description": "Intervalul de salvare a reviziilor este timpul după care se crează o nouă revizie a unei notițe. Vedeți <doc>wiki-ul</doc> pentru mai multe informații.",
"note_revisions_snapshot_interval_title": "Intervalul de salvare a reviziilor",
"snapshot_time_interval_label": "Intervalul de salvare a reviziilor:"
},
@@ -1871,7 +1871,7 @@
},
"custom_date_time_format": {
"title": "Format dată/timp personalizat",
"description": "Personalizați formatul de dată și timp inserat prin <kbd></kbd> sau din bara de unelte. Vedeți <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Documentația Day.js</a> pentru câmpurile de formatare disponibile.",
"description": "Personalizați formatul de dată și timp inserat prin <shortcut /> sau din bara de unelte. Vedeți <doc>Documentația Day.js</doc> pentru câmpurile de formatare disponibile.",
"format_string": "Șir de formatare:",
"formatted_time": "Data și ora formatate:"
},

View File

@@ -1778,12 +1778,12 @@
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "筆記歷史快照間隔",
"note_revisions_snapshot_description": "筆記歷史快照間隔是建立新筆記修訂的時間。如需詳細資訊,請參閱 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
"note_revisions_snapshot_description": "筆記歷史快照間隔是建立新筆記修訂的時間。如需詳細資訊,請參閱 <doc>wiki</doc>。",
"snapshot_time_interval_label": "筆記歷史快照時間間隔:"
},
"custom_date_time_format": {
"title": "自訂日期 / 時間格式",
"description": "透過 <kbd></kbd> 或工具列自訂插入日期和時間的格式。有關可用的格式及符號,請參閱 <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
"description": "透過 <shortcut /> 或工具列自訂插入日期和時間的格式。有關可用的格式及符號,請參閱 <doc>Day.js docs</doc>。",
"format_string": "格式化字串:",
"formatted_time": "格式化日期 / 時間:"
},

View File

@@ -2,6 +2,7 @@ import FlexContainer from "./flex_container.js";
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
import type BasicWidget from "../basic_widget.js";
import type NoteContext from "../../components/note_context.js";
import Component from "../../components/component.js";
interface NoteContextEvent {
noteContext: NoteContext;
@@ -152,6 +153,8 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
for (const ntxId of ntxIds) {
this.$widget.find(`[data-ntx-id="${ntxId}"]`).remove();
const widget = this.widgets[ntxId];
recursiveCleanup(widget);
delete this.widgets[ntxId];
}
}
@@ -237,3 +240,12 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
return Promise.all(promises);
}
}
function recursiveCleanup(widget: Component) {
for (const child of widget.children) {
recursiveCleanup(child);
}
if ("cleanup" in widget && typeof widget.cleanup === "function") {
widget.cleanup();
}
}

View File

@@ -107,7 +107,7 @@ function AddLinkDialogComponent() {
}}
show={shown}
>
<FormGroup label={t("add_link.note")}>
<FormGroup label={t("add_link.note")} name="note">
<NoteAutocomplete
inputRef={autocompleteRef}
onChange={setSuggestion}

View File

@@ -64,7 +64,7 @@ function BranchPrefixDialogComponent() {
footer={<Button text={t("branch_prefix.save")} />}
show={shown}
>
<FormGroup label={t("branch_prefix.prefix")}>
<FormGroup label={t("branch_prefix.prefix")} name="prefix">
<div class="input-group">
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />

View File

@@ -94,7 +94,8 @@ function AvailableActionsList() {
<td>{ actionGroup.title }:</td>
{actionGroup.actions.map(({ actionName, actionTitle }) =>
<Button
small text={actionTitle}
size="small"
text={actionTitle}
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
/>
)}

View File

@@ -69,15 +69,15 @@ function CloneToDialogComponent() {
>
<h5>{t("clone_to.notes_to_clone")}</h5>
<NoteList style={{ maxHeight: "200px", overflow: "auto" }} noteIds={clonedNoteIds} />
<FormGroup label={t("clone_to.target_parent_note")}>
<FormGroup name="target-parent-note" label={t("clone_to.target_parent_note")}>
<NoteAutocomplete
placeholder={t("clone_to.search_for_note_by_its_name")}
onChange={setSuggestion}
inputRef={autoCompleteRef}
/>
</FormGroup>
<FormGroup label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
<FormTextBox name="clone-prefix" onChange={setPrefix} />
<FormGroup name="clone-prefix" label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
<FormTextBox onChange={setPrefix} />
</FormGroup>
</Modal>
)

View File

@@ -4,7 +4,7 @@ import tree from "../../services/tree";
import Button from "../react/Button";
import FormCheckbox from "../react/FormCheckbox";
import FormFileUpload from "../react/FormFileUpload";
import FormGroup from "../react/FormGroup";
import FormGroup, { FormMultiGroup } from "../react/FormGroup";
import Modal from "../react/Modal";
import RawHtml from "../react/RawHtml";
import ReactBasicWidget from "../react/ReactBasicWidget";
@@ -55,11 +55,11 @@ function ImportDialogComponent() {
footer={<Button text={t("import.import")} primary disabled={!files} />}
show={shown}
>
<FormGroup label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
<FormGroup name="files" label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
<FormFileUpload multiple onChange={setFiles} />
</FormGroup>
<FormGroup label={t("import.options")}>
<FormMultiGroup label={t("import.options")}>
<FormCheckbox
name="safe-import" hint={t("import.safeImportTooltip")} label={t("import.safeImport")}
currentValue={safeImport} onChange={setSafeImport}
@@ -84,7 +84,7 @@ function ImportDialogComponent() {
name="replace-underscores-with-spaces" label={t("import.replaceUnderscoresWithSpaces")}
currentValue={replaceUnderscoresWithSpaces} onChange={setReplaceUnderscoresWithSpaces}
/>
</FormGroup>
</FormMultiGroup>
</Modal>
);
}

View File

@@ -43,7 +43,7 @@ function IncludeNoteDialogComponent() {
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
show={shown}
>
<FormGroup label={t("include_note.label_note")}>
<FormGroup name="note" label={t("include_note.label_note")}>
<NoteAutocomplete
placeholder={t("include_note.placeholder_search")}
onChange={setSuggestion}
@@ -55,8 +55,9 @@ function IncludeNoteDialogComponent() {
/>
</FormGroup>
<FormGroup label={t("include_note.box_size_prompt")}>
<FormRadioGroup name="include-note-box-size"
<FormGroup name="include-note-box-size" label={t("include_note.box_size_prompt")}>
<FormRadioGroup
name="include-note-box-size"
currentValue={boxSize} onChange={setBoxSize}
values={[
{ label: t("include_note.box_size_small"), value: "small" },

View File

@@ -57,7 +57,7 @@ function MoveToDialogComponent() {
<h5>{t("move_to.notes_to_move")}</h5>
<NoteList branchIds={movedBranchIds} />
<FormGroup label={t("move_to.target_parent_note")}>
<FormGroup name="parent-note" label={t("move_to.target_parent_note")}>
<NoteAutocomplete
onChange={setSuggestion}
inputRef={autoCompleteRef}

View File

@@ -83,7 +83,7 @@ function NoteTypeChooserDialogComponent() {
show={shown}
stackable
>
<FormGroup label={t("note_type_chooser.change_path_prompt")}>
<FormGroup name="parent-note" label={t("note_type_chooser.change_path_prompt")}>
<NoteAutocomplete
onChange={setParentNote}
placeholder={t("note_type_chooser.search_placeholder")}
@@ -95,7 +95,7 @@ function NoteTypeChooserDialogComponent() {
/>
</FormGroup>
<FormGroup label={t("note_type_chooser.modal_body")}>
<FormGroup name="note-type" label={t("note_type_chooser.modal_body")}>
<FormList onSelect={onNoteTypeSelected}>
{noteTypes.map((_item) => {
if (_item.title === "----") {

View File

@@ -25,6 +25,7 @@ export interface PromptDialogOptions {
defaultValue?: string;
shown?: PromptShownDialogCallback;
callback?: (value: string | null) => void;
readOnly?: boolean;
}
function PromptDialogComponent() {
@@ -32,24 +33,26 @@ function PromptDialogComponent() {
const formRef = useRef<HTMLFormElement>(null);
const labelRef = useRef<HTMLLabelElement>(null);
const answerRef = useRef<HTMLInputElement>(null);
const [ opts, setOpts ] = useState<PromptDialogOptions>();
const [ value, setValue ] = useState("");
const opts = useRef<PromptDialogOptions>();
const [ value, setValue ] = useState("");
const [ shown, setShown ] = useState(false);
const submitValue = useRef<string>(null);
useTriliumEvent("showPromptDialog", (opts) => {
setOpts(opts);
useTriliumEvent("showPromptDialog", (newOpts) => {
opts.current = newOpts;
setValue(newOpts.defaultValue ?? "");
setShown(true);
})
return (
<Modal
className="prompt-dialog"
title={opts?.title ?? t("prompt.title")}
title={opts.current?.title ?? t("prompt.title")}
size="lg"
zIndex={2000}
modalRef={modalRef} formRef={formRef}
onShown={() => {
opts?.shown?.({
opts.current?.shown?.({
$dialog: refToJQuerySelector(modalRef),
$question: refToJQuerySelector(labelRef),
$answer: refToJQuerySelector(answerRef),
@@ -58,24 +61,25 @@ function PromptDialogComponent() {
answerRef.current?.focus();
}}
onSubmit={() => {
const modal = BootstrapModal.getOrCreateInstance(modalRef.current!);
modal.hide();
opts?.callback?.(value);
submitValue.current = value;
setShown(false);
}}
onHidden={() => {
opts?.callback?.(null);
setShown(false);
opts.current?.callback?.(submitValue.current);
submitValue.current = null;
opts.current = undefined;
}}
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
show={shown}
stackable
>
<FormGroup label={opts?.message} labelRef={labelRef}>
<FormGroup name="prompt-dialog-answer" label={opts.current?.message} labelRef={labelRef}>
<FormTextBox
name="prompt-dialog-answer"
inputRef={answerRef}
currentValue={value} onChange={setValue} />
currentValue={value} onChange={setValue}
readOnly={opts.current?.readOnly}
/>
</FormGroup>
</Modal>
);

View File

@@ -57,7 +57,8 @@ function RecentChangesDialogComponent() {
header={
<Button
text={t("recent_changes.erase_notes_button")}
small style={{ padding: "0 10px" }}
size="small"
style={{ padding: "0 10px" }}
onClick={() => {
server.post("notes/erase-deleted-notes-now").then(() => {
setNeedsRefresh(true);

View File

@@ -55,7 +55,7 @@ function RevisionsDialogComponent() {
helpPageId="vZWERwf8U3nx"
bodyStyle={{ display: "flex", height: "80vh" }}
header={
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} small style={{ padding: "0 10px" }}
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} size="small" style={{ padding: "0 10px" }}
onClick={async () => {
const text = t("revisions.confirm_delete_all");

View File

@@ -83,11 +83,8 @@ function SortChildNotesDialogComponent() {
label={t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
currentValue={sortNatural} onChange={setSortNatural}
/>
<FormGroup className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
<FormTextBox
name="sort-locale"
currentValue={sortLocale} onChange={setSortLocale}
/>
<FormGroup name="sort-locale" className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
<FormTextBox currentValue={sortLocale} onChange={setSortLocale} />
</FormGroup>
</Modal>
)

View File

@@ -51,13 +51,12 @@ function UploadAttachmentsDialogComponent() {
onHidden={() => setShown(false)}
show={shown}
>
<FormGroup label={t("upload_attachments.choose_files")} description={description}>
<FormGroup name="files" label={t("upload_attachments.choose_files")} description={description}>
<FormFileUpload onChange={setFiles} multiple />
</FormGroup>
<FormGroup label={t("upload_attachments.options")}>
<FormCheckbox
name="shrink-images"
<FormGroup name="shrink-images" label={t("upload_attachments.options")}>
<FormCheckbox
hint={t("upload_attachments.tooltip")} label={t("upload_attachments.shrink_images")}
currentValue={shrinkImages} onChange={setShrinkImages}
/>

View File

@@ -0,0 +1,14 @@
import { ComponentChildren } from "preact";
interface AdmonitionProps {
type: "warning" | "note" | "caution";
children: ComponentChildren;
}
export default function Admonition({ type, children }: AdmonitionProps) {
return (
<div className={`admonition ${type}`} role="alert">
{children}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { ComponentChildren } from "preact";
interface AlertProps {
type: "info" | "danger";
type: "info" | "danger" | "warning";
title?: string;
children: ComponentChildren;
}

View File

@@ -4,6 +4,7 @@ import { useRef, useMemo } from "preact/hooks";
import { memo } from "preact/compat";
interface ButtonProps {
name?: string;
/** Reference to the button element. Mostly useful for requesting focus. */
buttonRef?: RefObject<HTMLButtonElement>;
text: string;
@@ -14,11 +15,11 @@ interface ButtonProps {
onClick?: () => void;
primary?: boolean;
disabled?: boolean;
small?: boolean;
size?: "normal" | "small" | "micro";
style?: CSSProperties;
}
const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) => {
const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style }: ButtonProps) => {
// Memoize classes array to prevent recreation
const classes = useMemo(() => {
const classList: string[] = ["btn"];
@@ -30,11 +31,13 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
if (className) {
classList.push(className);
}
if (small) {
if (size === "small") {
classList.push("btn-sm");
} else if (size === "micro") {
classList.push("btn-micro");
}
return classList.join(" ");
}, [primary, className, small]);
}, [primary, className, size]);
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
@@ -52,6 +55,7 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
return (
<button
name={name}
className={classes}
type={onClick ? "button" : "submit"}
onClick={onClick}

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
interface ColumnProps {
md?: number;
children: ComponentChildren;
className?: string;
style?: CSSProperties;
}
export default function Column({ md, children, className, style }: ColumnProps) {
return (
<div className={`col-md-${md ?? 6} ${className ?? ""}`} style={style}>
{children}
</div>
)
}

View File

@@ -2,10 +2,12 @@ import { Tooltip } from "bootstrap";
import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
import { escapeQuotes } from "../../services/utils";
import { ComponentChildren } from "preact";
import { memo } from "preact/compat";
import { CSSProperties, memo } from "preact/compat";
import { useUniqueName } from "./hooks";
interface FormCheckboxProps {
name: string;
id?: string;
name?: string;
label: string | ComponentChildren;
/**
* If set, the checkbox label will be underlined and dotted, indicating a hint. When hovered, it will show the hint text.
@@ -14,9 +16,11 @@ interface FormCheckboxProps {
currentValue: boolean;
disabled?: boolean;
onChange(newValue: boolean): void;
containerStyle?: CSSProperties;
}
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => {
const FormCheckbox = memo(({ name, id: _id, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => {
const id = _id ?? useUniqueName(name);
const labelRef = useRef<HTMLLabelElement>(null);
// Fix: Move useEffect outside conditional
@@ -46,7 +50,7 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
return (
<div className="form-checkbox">
<div className="form-checkbox" style={containerStyle}>
<label
className="form-check-label tn-checkbox"
style={labelStyle}
@@ -54,9 +58,10 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
ref={labelRef}
>
<input
id={id}
className="form-check-input"
type="checkbox"
name={name}
name={id}
checked={currentValue || false}
value="1"
disabled={disabled}

View File

@@ -1,24 +1,43 @@
import { ComponentChildren, RefObject } from "preact";
import { cloneElement, ComponentChildren, RefObject, VNode } from "preact";
import { CSSProperties } from "preact/compat";
import { useUniqueName } from "./hooks";
interface FormGroupProps {
name: string;
labelRef?: RefObject<HTMLLabelElement>;
label?: string;
title?: string;
className?: string;
children: ComponentChildren;
children: VNode<any>;
description?: string | ComponentChildren;
disabled?: boolean;
style?: CSSProperties;
}
export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) {
export default function FormGroup({ name, label, title, className, children, description, labelRef, disabled, style }: FormGroupProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
return (
<div className={`form-group ${className}`} title={title}
style={{ "margin-bottom": "15px" }}>
<label style={{ width: "100%" }} ref={labelRef}>
{label && <div style={{ "margin-bottom": "10px" }}>{label}</div> }
{children}
</label>
<div className={`form-group ${className} ${disabled ? "disabled" : ""}`} title={title} style={style}>
{ label &&
<label style={{ width: "100%" }} ref={labelRef} htmlFor={id}>{label}</label>}
{childWithId}
{description && <small className="form-text">{description}</small>}
</div>
);
}
/**
* Similar to {@link FormGroup} but allows more than one child. Due to this behaviour, there is no automatic ID assignment.
*/
export function FormMultiGroup({ label, children }: { label: string, children: ComponentChildren }) {
return (
<div className={`form-group`}>
{label && <label>{label}</label>}
{children}
</div>
);
}

View File

@@ -1,30 +1,56 @@
import type { ComponentChildren } from "preact";
import { useUniqueName } from "./hooks";
interface FormRadioProps {
name: string;
currentValue?: string;
values: {
value: string;
label: string;
label: string | ComponentChildren;
inlineDescription?: string | ComponentChildren;
}[];
onChange(newValue: string): void;
}
export default function FormRadioGroup({ name, values, currentValue, onChange }: FormRadioProps) {
export default function FormRadioGroup({ values, ...restProps }: FormRadioProps) {
return (
<>
{(values || []).map(({ value, label }) => (
<div className="form-check">
<label className="form-check-label tn-radio">
<input
className="form-check-input"
type="radio"
name={name}
value={value}
checked={value === currentValue}
onChange={e => onChange((e.target as HTMLInputElement).value)} />
{label}
</label>
<div role="group">
{(values || []).map(({ value, label, inlineDescription }) => (
<div className="form-checkbox">
<FormRadio
value={value}
label={label} inlineDescription={inlineDescription}
labelClassName="form-check-label"
{...restProps}
/>
</div>
))}
</>
</div>
);
}
export function FormInlineRadioGroup({ values, ...restProps }: FormRadioProps) {
return (
<div role="group">
{values.map(({ value, label }) => (<FormRadio value={value} label={label} {...restProps} />))}
</div>
)
}
function FormRadio({ name, value, label, currentValue, onChange, labelClassName, inlineDescription }: Omit<FormRadioProps, "values"> & { value: string, label: ComponentChildren, inlineDescription?: ComponentChildren, labelClassName?: string }) {
return (
<label className={`tn-radio ${labelClassName ?? ""}`}>
<input
className="form-check-input"
type="radio"
name={useUniqueName(name)}
value={value}
checked={value === currentValue}
onChange={e => onChange((e.target as HTMLInputElement).value)}
/>
{inlineDescription ?
<><strong>{label}</strong> - {inlineDescription}</>
: label}
</label>
)
}

View File

@@ -0,0 +1,79 @@
import type { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
type OnChangeListener = (newValue: string) => void;
export interface FormSelectGroup<T> {
title: string;
items: T[];
}
interface ValueConfig<T, Q> {
values: Q[];
/** The property of an item of {@link values} to be used as the key, uniquely identifying it. The key will be passed to the change listener. */
keyProperty: keyof T;
/** The property of an item of {@link values} to be used as the label, representing a human-readable version of the key. If missing, {@link keyProperty} will be used instead. */
titleProperty?: keyof T;
/** The current value of the combobox. The value will be looked up by going through {@link values} and looking an item whose {@link #keyProperty} value matches this one */
currentValue?: string;
}
interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
id?: string;
onChange: OnChangeListener;
style?: CSSProperties;
}
/**
* Combobox component that takes in any object array as data. Each item of the array is rendered as an item, and the key and values are obtained by looking into the object by a specified key.
*/
export default function FormSelect<T>({ id, onChange, style, ...restProps }: FormSelectProps<T, T>) {
return (
<FormSelectBody id={id} onChange={onChange} style={style}>
<FormSelectGroup {...restProps} />
</FormSelectBody>
);
}
/**
* Similar to {@link FormSelect}, but the top-level elements are actually groups.
*/
export function FormSelectWithGroups<T>({ id, values, keyProperty, titleProperty, currentValue, onChange }: FormSelectProps<T, FormSelectGroup<T>>) {
return (
<FormSelectBody id={id} onChange={onChange}>
{values.map(({ title, items }) => {
return (
<optgroup label={title}>
<FormSelectGroup values={items} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
</optgroup>
);
})}
</FormSelectBody>
)
}
function FormSelectBody({ id, children, onChange, style }: { id?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties }) {
return (
<select
id={id}
class="form-select"
onChange={e => onChange((e.target as HTMLInputElement).value)}
style={style}
>
{children}
</select>
)
}
function FormSelectGroup<T>({ values, keyProperty, titleProperty, currentValue }: ValueConfig<T, T>) {
return values.map(item => {
return (
<option
value={item[keyProperty] as any}
selected={item[keyProperty] === currentValue}
>
{item[titleProperty ?? keyProperty] ?? item[keyProperty] as any}
</option>
);
});
}

View File

@@ -0,0 +1,5 @@
import { ComponentChildren } from "preact";
export default function FormText({ children }: { children: ComponentChildren }) {
return <p className="form-text use-tn-links">{children}</p>
}

View File

@@ -0,0 +1,18 @@
interface FormTextAreaProps {
id?: string;
currentValue: string;
onBlur?(newValue: string): void;
rows: number;
}
export default function FormTextArea({ id, onBlur, rows, currentValue }: FormTextAreaProps) {
return (
<textarea
id={id}
rows={rows}
onBlur={(e) => {
onBlur?.(e.currentTarget.value);
}}
style={{ width: "100%" }}
>{currentValue}</textarea>
)
}

View File

@@ -1,27 +1,48 @@
import type { InputHTMLAttributes, RefObject } from "preact/compat";
interface FormTextBoxProps extends Pick<InputHTMLAttributes<HTMLInputElement>, "placeholder" | "autoComplete" | "className" | "type" | "name" | "pattern" | "title" | "style"> {
interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "onBlur" | "value"> {
id?: string;
currentValue?: string;
onChange?(newValue: string): void;
onChange?(newValue: string, validity: ValidityState): void;
onBlur?(newValue: string): void;
inputRef?: RefObject<HTMLInputElement>;
}
export default function FormTextBox({ id, type, name, className, currentValue, onChange, autoComplete, inputRef, placeholder, title, pattern, style }: FormTextBoxProps) {
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, onBlur,...rest}: FormTextBoxProps) {
if (type === "number" && currentValue) {
const { min, max } = rest;
const currentValueNum = parseInt(currentValue, 10);
if (min && currentValueNum < parseInt(String(min), 10)) {
currentValue = String(min);
} else if (max && currentValueNum > parseInt(String(max), 10)) {
currentValue = String(max);
}
}
return (
<input
ref={inputRef}
type={type ?? "text"}
className={`form-control ${className ?? ""}`}
id={id}
name={name}
type={type ?? "text"}
value={currentValue}
autoComplete={autoComplete}
placeholder={placeholder}
title={title}
pattern={pattern}
onInput={e => onChange?.(e.currentTarget.value)}
style={style}
onInput={onChange && (e => {
const target = e.currentTarget;
onChange?.(target.value, target.validity);
})}
onBlur={onBlur && (e => {
const target = e.currentTarget;
onBlur(target.value);
})}
{...rest}
/>
);
}
export function FormTextBoxWithUnit(props: FormTextBoxProps & { unit: string }) {
return (
<label class="input-group tn-number-unit-pair">
<FormTextBox {...props} />
<span class="input-group-text">{props.unit}</span>
</label>
)
}

View File

@@ -0,0 +1,33 @@
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks";
import keyboard_actions from "../../services/keyboard_actions";
interface KeyboardShortcutProps {
actionName: KeyboardActionNames;
}
export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) {
const [ action, setAction ] = useState<ActionKeyboardShortcut>();
useEffect(() => {
keyboard_actions.getAction(actionName).then(setAction);
}, []);
if (!action) {
return <></>;
}
return (
<>
{action.effectiveShortcuts?.map((shortcut, i) => {
const keys = shortcut.split("+");
return keys
.map((key, i) => (
<>
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
</>
))
}).reduce<any>((acc, item) => (acc.length ? [...acc, ", ", item] : [item]), [])}
</>
);
}

View File

@@ -0,0 +1,17 @@
import { ComponentChild } from "preact";
interface LinkButtonProps {
onClick: () => void;
text: ComponentChild;
}
export default function LinkButton({ onClick, text }: LinkButtonProps) {
return (
<a class="tn-link" href="javascript:" onClick={(e) => {
e.preventDefault();
onClick();
}}>
{text}
</a>
)
}

View File

@@ -6,6 +6,7 @@ import type { RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
interface NoteAutocompleteProps {
id?: string;
inputRef?: RefObject<HTMLInputElement>;
text?: string;
placeholder?: string;
@@ -18,7 +19,7 @@ interface NoteAutocompleteProps {
noteId?: string;
}
export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
export default function NoteAutocomplete({ id, inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
const ref = _ref ?? useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -74,6 +75,7 @@ export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, on
return (
<div className="input-group" style={containerStyle}>
<input
id={id}
ref={ref}
className="note-autocomplete form-control"
placeholder={placeholder ?? t("add_link.search_note")} />

View File

@@ -24,7 +24,7 @@ function getProps({ className, html, style }: RawHtmlProps) {
}
}
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
if (typeof html === "object" && "length" in html) {
html = html[0];
}

View File

@@ -22,11 +22,18 @@ export default abstract class ReactBasicWidget extends BasicWidget {
* @returns the rendered wrapped DOM element.
*/
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
const renderContainer = new DocumentFragment();
return renderReactWidgetAtElement(parentComponent, el, new DocumentFragment()).children();
}
export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.Element, container: Element | DocumentFragment) {
render((
<ParentComponent.Provider value={parentComponent}>
{el}
</ParentComponent.Provider>
), renderContainer);
return $(renderContainer.firstChild as HTMLElement);
), container);
return $(container) as JQuery<HTMLElement>;
}
export function disposeReactWidget(container: Element) {
render(null, container);
}

View File

@@ -1,7 +1,15 @@
import { useContext, useEffect, useRef } from "preact/hooks";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { EventData, EventNames } from "../../components/app_context";
import { ParentComponent } from "./ReactBasicWidget";
import SpacedUpdate from "../../services/spaced_update";
import { OptionNames } from "@triliumnext/commons";
import options, { type OptionValue } from "../../services/options";
import utils, { reloadFrontendApp } from "../../services/utils";
import Component from "../../components/component";
import server from "../../services/server";
type TriliumEventHandler<T extends EventNames> = (data: EventData<T>) => void;
const registeredHandlers: Map<Component, Map<EventNames, TriliumEventHandler<any>[]>> = new Map();
/**
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
@@ -12,32 +20,67 @@ import SpacedUpdate from "../../services/spaced_update";
* @param handler the handler to be invoked when the event is triggered.
* @param enabled determines whether the event should be listened to or not. Useful to conditionally limit the listener based on a state (e.g. a modal being displayed).
*/
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void, enabled = true) {
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: TriliumEventHandler<T>, enabled = true) {
const parentWidget = useContext(ParentComponent);
useEffect(() => {
if (!parentWidget || !enabled) {
return;
}
// Create a unique handler name for this specific event listener
const handlerName = `${eventName}Event`;
const originalHandler = parentWidget[handlerName];
// Override the event handler to call our handler
parentWidget[handlerName] = async function(data: EventData<T>) {
// Call original handler if it exists
if (originalHandler) {
await originalHandler.call(parentWidget, data);
if (!parentWidget) {
return;
}
const handlerName = `${eventName}Event`;
const customHandler = useMemo(() => {
return async (data: EventData<T>) => {
// Inform the attached event listeners.
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName) ?? [];
for (const eventHandler of eventHandlers) {
eventHandler(data);
}
// Call our React component's handler
handler(data);
};
}
}, [ eventName, parentWidget ]);
// Cleanup: restore original handler on unmount or when disabled
useEffect(() => {
// Attach to the list of handlers.
let handlersByWidget = registeredHandlers.get(parentWidget);
if (!handlersByWidget) {
handlersByWidget = new Map();
registeredHandlers.set(parentWidget, handlersByWidget);
}
let handlersByWidgetAndEventName = handlersByWidget.get(eventName);
if (!handlersByWidgetAndEventName) {
handlersByWidgetAndEventName = [];
handlersByWidget.set(eventName, handlersByWidgetAndEventName);
}
if (!handlersByWidgetAndEventName.includes(handler)) {
handlersByWidgetAndEventName.push(handler);
}
// Apply the custom event handler.
if (parentWidget[handlerName] && parentWidget[handlerName] !== customHandler) {
console.warn(`Widget ${parentWidget.componentId} already had an event listener and it was replaced by the React one.`);
}
parentWidget[handlerName] = customHandler;
return () => {
parentWidget[handlerName] = originalHandler;
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName);
if (!eventHandlers || !eventHandlers.includes(handler)) {
return;
}
// Remove the event handler from the array.
const newEventHandlers = eventHandlers.filter(e => e !== handler);
if (newEventHandlers.length) {
registeredHandlers.get(parentWidget)?.set(eventName, newEventHandlers);
} else {
registeredHandlers.get(parentWidget)?.delete(eventName);
}
if (!registeredHandlers.get(parentWidget)?.size) {
registeredHandlers.delete(parentWidget);
}
};
}, [parentWidget, enabled, eventName, handler]);
}, [ eventName, parentWidget, handler ]);
}
export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000) {
@@ -63,4 +106,90 @@ export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000)
}, [interval]);
return spacedUpdateRef.current;
}
/**
* Allows a React component to read and write a Trilium option, while also watching for external changes.
*
* Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if
* the option is changed somewhere else in the client.
*
* @param name the name of the option to listen for.
* @param needsRefresh whether to reload the frontend whenever the value is changed.
* @returns an array where the first value is the current option value and the second value is the setter.
*/
export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [string, (newValue: OptionValue) => Promise<void>] {
const initialValue = options.get(name);
const [ value, setValue ] = useState(initialValue);
const wrappedSetValue = useMemo(() => {
return async (newValue: OptionValue) => {
await options.save(name, newValue);
if (needsRefresh) {
reloadFrontendApp(`option change: ${name}`);
}
}
}, [ name, needsRefresh ]);
useTriliumEvent("entitiesReloaded", useCallback(({ loadResults }) => {
if (loadResults.getOptionNames().includes(name)) {
const newValue = options.get(name);
setValue(newValue);
}
}, [ name ]));
return [
value,
wrappedSetValue
]
}
export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): [boolean, (newValue: boolean) => Promise<void>] {
const [ value, setValue ] = useTriliumOption(name, needsRefresh);
return [
(value === "true"),
(newValue) => setValue(newValue ? "true" : "false")
]
}
export function useTriliumOptionInt(name: OptionNames): [number, (newValue: number) => Promise<void>] {
const [ value, setValue ] = useTriliumOption(name);
return [
(parseInt(value, 10)),
(newValue) => setValue(newValue)
]
}
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] {
const [ value, setValue ] = useTriliumOption(name);
return [
(JSON.parse(value) as T),
(newValue => setValue(JSON.stringify(newValue)))
];
}
export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
const values: Record<string, string> = {};
for (const name of names) {
values[name] = options.get(name);
}
return [
values as Record<T, string>,
options.saveMany
] as const;
}
/**
* Generates a unique name via a random alphanumeric string of a fixed length.
*
* <p>
* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs.
*
* @param prefix a prefix to add to the unique name.
* @returns a name with the given prefix and a random alpanumeric string appended to it.
*/
export function useUniqueName(prefix?: string) {
return useMemo(() => (prefix ? prefix + "-" : "") + utils.randomString(10), [ prefix ]);
}

View File

@@ -1,199 +0,0 @@
import TypeWidget from "./type_widget.js";
import ElectronIntegrationOptions from "./options/appearance/electron_integration.js";
import ThemeOptions from "./options/appearance/theme.js";
import FontsOptions from "./options/appearance/fonts.js";
import MaxContentWidthOptions from "./options/appearance/max_content_width.js";
import KeyboardShortcutsOptions from "./options/shortcuts.js";
import HeadingStyleOptions from "./options/text_notes/heading_style.js";
import TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
import HighlightsListOptions from "./options/text_notes/highlights_list.js";
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js";
import DateTimeFormatOptions from "./options/text_notes/date_time_format.js";
import CodeEditorOptions from "./options/code_notes/code_editor.js";
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
import ImageOptions from "./options/images/images.js";
import SpellcheckOptions from "./options/spellcheck.js";
import PasswordOptions from "./options/password/password.js";
import ProtectedSessionTimeoutOptions from "./options/password/protected_session_timeout.js";
import EtapiOptions from "./options/etapi.js";
import BackupOptions from "./options/backup.js";
import SyncOptions from "./options/sync.js";
import SearchEngineOptions from "./options/other/search_engine.js";
import TrayOptions from "./options/other/tray.js";
import NoteErasureTimeoutOptions from "./options/other/note_erasure_timeout.js";
import RevisionsSnapshotIntervalOptions from "./options/other/revisions_snapshot_interval.js";
import RevisionSnapshotsLimitOptions from "./options/other/revision_snapshots_limit.js";
import NetworkConnectionsOptions from "./options/other/network_connections.js";
import HtmlImportTagsOptions from "./options/other/html_import_tags.js";
import AdvancedSyncOptions from "./options/advanced/sync.js";
import DatabaseIntegrityCheckOptions from "./options/advanced/database_integrity_check.js";
import VacuumDatabaseOptions from "./options/advanced/vacuum_database.js";
import DatabaseAnonymizationOptions from "./options/advanced/database_anonymization.js";
import BackendLogWidget from "./content/backend_log.js";
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
import RibbonOptions from "./options/appearance/ribbon.js";
import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js';
import LocalizationOptions from "./options/i18n/i18n.js";
import CodeBlockOptions from "./options/text_notes/code_block.js";
import EditorOptions from "./options/text_notes/editor.js";
import ShareSettingsOptions from "./options/other/share_settings.js";
import AiSettingsOptions from "./options/ai_settings.js";
import type FNote from "../../entities/fnote.js";
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
import { t } from "../../services/i18n.js";
import LanguageOptions from "./options/i18n/language.js";
import type BasicWidget from "../basic_widget.js";
import CodeTheme from "./options/code_notes/code_theme.js";
import RelatedSettings from "./options/appearance/related_settings.js";
import EditorFeaturesOptions from "./options/text_notes/features.js";
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
<style>
.type-contentWidget .note-detail {
height: 100%;
}
.note-detail-content-widget {
height: 100%;
}
.note-detail-content-widget-content {
padding: 15px;
height: 100%;
}
.note-detail.full-height .note-detail-content-widget-content {
padding: 0;
}
</style>
<div class="note-detail-content-widget-content"></div>
</div>`;
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAwareWidget)[]> = {
_optionsAppearance: [
ThemeOptions,
FontsOptions,
ElectronIntegrationOptions,
MaxContentWidthOptions,
RibbonOptions
],
_optionsShortcuts: [
KeyboardShortcutsOptions
],
_optionsTextNotes: [
EditorOptions,
EditorFeaturesOptions,
HeadingStyleOptions,
CodeBlockOptions,
TableOfContentsOptions,
HighlightsListOptions,
TextAutoReadOnlySizeOptions,
DateTimeFormatOptions
],
_optionsCodeNotes: [
CodeEditorOptions,
CodeTheme,
CodeMimeTypesOptions,
CodeAutoReadOnlySizeOptions
],
_optionsImages: [
ImageOptions
],
_optionsSpellcheck: [
SpellcheckOptions
],
_optionsPassword: [
PasswordOptions,
ProtectedSessionTimeoutOptions
],
_optionsMFA: [MultiFactorAuthenticationOptions],
_optionsEtapi: [
EtapiOptions
],
_optionsBackup: [
BackupOptions
],
_optionsSync: [
SyncOptions
],
_optionsAi: [AiSettingsOptions],
_optionsOther: [
SearchEngineOptions,
TrayOptions,
NoteErasureTimeoutOptions,
AttachmentErasureTimeoutOptions,
RevisionsSnapshotIntervalOptions,
RevisionSnapshotsLimitOptions,
HtmlImportTagsOptions,
ShareSettingsOptions,
NetworkConnectionsOptions
],
_optionsLocalization: [
LocalizationOptions,
LanguageOptions
],
_optionsAdvanced: [
AdvancedSyncOptions,
DatabaseIntegrityCheckOptions,
DatabaseAnonymizationOptions,
VacuumDatabaseOptions
],
_backendLog: [
BackendLogWidget
]
};
/**
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
*
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
*/
export default class ContentWidgetTypeWidget extends TypeWidget {
private $content!: JQuery<HTMLElement>;
private widget?: BasicWidget;
static getType() {
return "contentWidget";
}
doRender() {
this.$widget = $(TPL);
this.$content = this.$widget.find(".note-detail-content-widget-content");
super.doRender();
}
async doRefresh(note: FNote) {
this.$content.empty();
this.children = [];
const contentWidgets = [
...((CONTENT_WIDGETS as Record<string, typeof NoteContextAwareWidget[]>)[note.noteId]),
RelatedSettings
];
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
if (contentWidgets) {
for (const clazz of contentWidgets) {
const widget = new clazz();
if (this.noteContext) {
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
}
this.child(widget);
this.$content.append(widget.render());
this.widget = widget;
await widget.refresh();
}
} else {
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
}
}
}

View File

@@ -0,0 +1,137 @@
import TypeWidget from "./type_widget.js";
import type FNote from "../../entities/fnote.js";
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
import { t } from "../../services/i18n.js";
import type BasicWidget from "../basic_widget.js";
import type { JSX } from "preact/jsx-runtime";
import AppearanceSettings from "./options/appearance.jsx";
import { disposeReactWidget, renderReactWidget, renderReactWidgetAtElement } from "../react/ReactBasicWidget.jsx";
import ImageSettings from "./options/images.jsx";
import AdvancedSettings from "./options/advanced.jsx";
import InternationalizationOptions from "./options/i18n.jsx";
import SyncOptions from "./options/sync.jsx";
import EtapiSettings from "./options/etapi.js";
import BackupSettings from "./options/backup.js";
import SpellcheckSettings from "./options/spellcheck.js";
import PasswordSettings from "./options/password.jsx";
import ShortcutSettings from "./options/shortcuts.js";
import TextNoteSettings from "./options/text_notes.jsx";
import CodeNoteSettings from "./options/code_notes.jsx";
import OtherSettings from "./options/other.jsx";
import BackendLogWidget from "./content/backend_log.js";
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js";
import AiSettings from "./options/ai_settings.jsx";
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
<style>
.type-contentWidget .note-detail {
height: 100%;
}
.note-detail-content-widget {
height: 100%;
}
.note-detail-content-widget-content {
padding: 15px;
height: 100%;
}
.note-detail.full-height .note-detail-content-widget-content {
padding: 0;
}
</style>
<div class="note-detail-content-widget-content"></div>
</div>`;
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = {
_optionsAppearance: <AppearanceSettings />,
_optionsShortcuts: <ShortcutSettings />,
_optionsTextNotes: <TextNoteSettings />,
_optionsCodeNotes: <CodeNoteSettings />,
_optionsImages: <ImageSettings />,
_optionsSpellcheck: <SpellcheckSettings />,
_optionsPassword: <PasswordSettings />,
_optionsMFA: <MultiFactorAuthenticationSettings />,
_optionsEtapi: <EtapiSettings />,
_optionsBackup: <BackupSettings />,
_optionsSync: <SyncOptions />,
_optionsAi: <AiSettings />,
_optionsOther: <OtherSettings />,
_optionsLocalization: <InternationalizationOptions />,
_optionsAdvanced: <AdvancedSettings />,
_backendLog: [
BackendLogWidget
]
};
/**
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
*
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
*/
export default class ContentWidgetTypeWidget extends TypeWidget {
private $content!: JQuery<HTMLElement>;
private widget?: BasicWidget;
static getType() {
return "contentWidget";
}
doRender() {
this.$widget = $(TPL);
this.$content = this.$widget.find(".note-detail-content-widget-content");
super.doRender();
}
async doRefresh(note: FNote) {
this.$content.empty();
this.children = [];
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[note.noteId];
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
// Unknown widget.
if (!contentWidgets) {
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
return;
}
// Legacy widget.
if (Array.isArray(contentWidgets)) {
for (const clazz of contentWidgets) {
const widget = new clazz();
if (this.noteContext) {
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
}
this.child(widget);
this.$content.append(widget.render());
this.widget = widget;
await widget.refresh();
}
return;
}
// React widget.
renderReactWidgetAtElement(this, contentWidgets, this.$content[0]);
}
cleanup(): void {
if (this.noteId) {
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[this.noteId];
if (contentWidgets && !Array.isArray(contentWidgets)) {
disposeReactWidget(this.$content[0]);
}
}
super.cleanup();
}
}

View File

@@ -0,0 +1,175 @@
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import OptionsSection from "./components/OptionsSection"
import Column from "../../react/Column";
import { useEffect, useState } from "preact/hooks";
export default function AdvancedSettings() {
return <>
<AdvancedSyncOptions />
<DatabaseIntegrityOptions />
<DatabaseAnonymizationOptions />
<VacuumDatabaseOptions />
</>;
}
function AdvancedSyncOptions() {
return (
<OptionsSection title={t("sync.title")}>
<Button
text={t("sync.force_full_sync_button")}
onClick={async () => {
await server.post("sync/force-full-sync");
toast.showMessage(t("sync.full_sync_triggered"));
}}
/>
<Button
text={t("sync.fill_entity_changes_button")}
onClick={async () => {
toast.showMessage(t("sync.filling_entity_changes"));
await server.post("sync/fill-entity-changes");
toast.showMessage(t("sync.sync_rows_filled_successfully"));
}}
/>
</OptionsSection>
);
}
function DatabaseIntegrityOptions() {
return (
<OptionsSection title={t("database_integrity_check.title")}>
<FormText>{t("database_integrity_check.description")}</FormText>
<Button
text={t("database_integrity_check.check_button")}
onClick={async () => {
toast.showMessage(t("database_integrity_check.checking_integrity"));
const { results } = await server.get<DatabaseCheckIntegrityResponse>("database/check-integrity");
if (results.length === 1 && results[0].integrity_check === "ok") {
toast.showMessage(t("database_integrity_check.integrity_check_succeeded"));
} else {
toast.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
}
}}
/>
<Button
text={t("consistency_checks.find_and_fix_button")}
onClick={async () => {
toast.showMessage(t("consistency_checks.finding_and_fixing_message"));
await server.post("database/find-and-fix-consistency-issues");
toast.showMessage(t("consistency_checks.issues_fixed_message"));
}}
/>
</OptionsSection>
)
}
function DatabaseAnonymizationOptions() {
const [ existingAnonymizedDatabases, setExistingAnonymizedDatabases ] = useState<AnonymizedDbResponse[]>([]);
function refreshAnonymizedDatabase() {
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then(setExistingAnonymizedDatabases);
}
useEffect(refreshAnonymizedDatabase, []);
return (
<OptionsSection title={t("database_anonymization.title")}>
<FormText>{t("database_anonymization.choose_anonymization")}</FormText>
<div className="row">
<DatabaseAnonymizationOption
title={t("database_anonymization.full_anonymization")}
description={t("database_anonymization.full_anonymization_description")}
buttonText={t("database_anonymization.save_fully_anonymized_database")}
buttonClick={async () => {
toast.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/full");
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
<DatabaseAnonymizationOption
title={t("database_anonymization.light_anonymization")}
description={t("database_anonymization.light_anonymization_description")}
buttonText={t("database_anonymization.save_lightly_anonymized_database")}
buttonClick={async () => {
toast.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/light");
if (!resp.success) {
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toast.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
refreshAnonymizedDatabase();
}
}}
/>
</div>
<hr />
<ExistingAnonymizedDatabases databases={existingAnonymizedDatabases} />
</OptionsSection>
)
}
function DatabaseAnonymizationOption({ title, description, buttonText, buttonClick }: { title: string, description: string, buttonText: string, buttonClick: () => void }) {
return (
<Column md={6} style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginTop: "1em" }}>
<h5>{title}</h5>
<FormText>{description}</FormText>
<Button text={buttonText} onClick={buttonClick} />
</Column>
)
}
function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbResponse[] }) {
if (!databases.length) {
return <FormText>{t("database_anonymization.no_anonymized_database_yet")}</FormText>
}
return (
<table className="table table-stripped">
<thead>
<th>{t("database_anonymization.existing_anonymized_databases")}</th>
</thead>
<tbody>
{databases.map(({ filePath }) => (
<tr>
<td>{filePath}</td>
</tr>
))}
</tbody>
</table>
)
}
function VacuumDatabaseOptions() {
return (
<OptionsSection title={t("vacuum_database.title")}>
<FormText>{t("vacuum_database.description")}</FormText>
<Button
text={t("vacuum_database.button_text")}
onClick={async () => {
toast.showMessage(t("vacuum_database.vacuuming_database"));
await server.post("database/vacuum-database");
toast.showMessage(t("vacuum_database.database_vacuumed"));
}}
/>
</OptionsSection>
)
}

View File

@@ -1,119 +0,0 @@
import OptionsWidget from "../options_widget.js";
import toastService from "../../../../services/toast.js";
import server from "../../../../services/server.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<style>
.database-database-anonymization-option {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 1em;
}
.database-database-anonymization-option p {
margin-top: .75em;
flex-grow: 1;
}
</style>
<h4>${t("database_anonymization.title")}</h4>
<div class="row">
<p class="form-text">${t("database_anonymization.choose_anonymization")}</p>
<div class="col-md-6 database-database-anonymization-option">
<h5>${t("database_anonymization.full_anonymization")}</h5>
<p class="form-text">${t("database_anonymization.full_anonymization_description")}</p>
<button class="anonymize-full-button btn btn-secondary">${t("database_anonymization.save_fully_anonymized_database")}</button>
</div>
<div class="col-md-6 database-database-anonymization-option">
<h5>${t("database_anonymization.light_anonymization")}</h5>
<p class="form-text">${t("database_anonymization.light_anonymization_description")}</p>
<button class="anonymize-light-button btn btn-secondary">${t("database_anonymization.save_lightly_anonymized_database")}</button>
</div>
</div>
<hr />
<table class="existing-anonymized-databases-table table table-stripped">
<thead>
<th>${t("database_anonymization.existing_anonymized_databases")}</th>
</thead>
<tbody class="existing-anonymized-databases">
</tbody>
</table>
</div>`;
// TODO: Deduplicate with server
interface AnonymizeResponse {
success: boolean;
anonymizedFilePath: string;
}
interface AnonymizedDbResponse {
filePath: string;
}
export default class DatabaseAnonymizationOptions extends OptionsWidget {
private $anonymizeFullButton!: JQuery<HTMLElement>;
private $anonymizeLightButton!: JQuery<HTMLElement>;
private $existingAnonymizedDatabases!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$anonymizeFullButton = this.$widget.find(".anonymize-full-button");
this.$anonymizeLightButton = this.$widget.find(".anonymize-light-button");
this.$anonymizeFullButton.on("click", async () => {
toastService.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
const resp = await server.post<AnonymizeResponse>("database/anonymize/full");
if (!resp.success) {
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toastService.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
}
this.refresh();
});
this.$anonymizeLightButton.on("click", async () => {
toastService.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
const resp = await server.post<AnonymizeResponse>("database/anonymize/light");
if (!resp.success) {
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
} else {
toastService.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
}
this.refresh();
});
this.$existingAnonymizedDatabases = this.$widget.find(".existing-anonymized-databases");
}
optionsLoaded(options: OptionMap) {
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then((anonymizedDatabases) => {
this.$existingAnonymizedDatabases.empty();
if (!anonymizedDatabases.length) {
anonymizedDatabases = [{ filePath: t("database_anonymization.no_anonymized_database_yet") }];
}
for (const { filePath } of anonymizedDatabases) {
this.$existingAnonymizedDatabases.append($("<tr>").append($("<td>").text(filePath)));
}
});
}
}

View File

@@ -1,53 +0,0 @@
import OptionsWidget from "../options_widget.js";
import toastService from "../../../../services/toast.js";
import server from "../../../../services/server.js";
import { t } from "../../../../services/i18n.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("database_integrity_check.title")}</h4>
<p class="form-text">${t("database_integrity_check.description")}</p>
<button class="check-integrity-button btn btn-secondary">${t("database_integrity_check.check_button")}</button>
<button class="find-and-fix-consistency-issues-button btn btn-secondary">${t("consistency_checks.find_and_fix_button")}</button>
</div>
`;
// TODO: Deduplicate with server
interface Response {
results: {
integrity_check: string;
}[];
}
export default class DatabaseIntegrityCheckOptions extends OptionsWidget {
private $checkIntegrityButton!: JQuery<HTMLElement>;
private $findAndFixConsistencyIssuesButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$checkIntegrityButton = this.$widget.find(".check-integrity-button");
this.$checkIntegrityButton.on("click", async () => {
toastService.showMessage(t("database_integrity_check.checking_integrity"));
const { results } = await server.get<Response>("database/check-integrity");
if (results.length === 1 && results[0].integrity_check === "ok") {
toastService.showMessage(t("database_integrity_check.integrity_check_succeeded"));
} else {
toastService.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
}
});
this.$findAndFixConsistencyIssuesButton = this.$widget.find(".find-and-fix-consistency-issues-button");
this.$findAndFixConsistencyIssuesButton.on("click", async () => {
toastService.showMessage(t("consistency_checks.finding_and_fixing_message"));
await server.post("database/find-and-fix-consistency-issues");
toastService.showMessage(t("consistency_checks.issues_fixed_message"));
});
}
}

View File

@@ -1,40 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("sync.title")}</h4>
<button class="force-full-sync-button btn btn-secondary">${t("sync.force_full_sync_button")}</button>
<button class="fill-entity-changes-button btn btn-secondary">${t("sync.fill_entity_changes_button")}</button>
</div>`;
export default class AdvancedSyncOptions extends OptionsWidget {
private $forceFullSyncButton!: JQuery<HTMLElement>;
private $fillEntityChangesButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$forceFullSyncButton = this.$widget.find(".force-full-sync-button");
this.$fillEntityChangesButton = this.$widget.find(".fill-entity-changes-button");
this.$forceFullSyncButton.on("click", async () => {
await server.post("sync/force-full-sync");
toastService.showMessage(t("sync.full_sync_triggered"));
});
this.$fillEntityChangesButton.on("click", async () => {
toastService.showMessage(t("sync.filling_entity_changes"));
await server.post("sync/fill-entity-changes");
toastService.showMessage(t("sync.sync_rows_filled_successfully"));
});
}
async optionsLoaded(options: OptionMap) {}
}

View File

@@ -1,29 +0,0 @@
import OptionsWidget from "../options_widget.js";
import toastService from "../../../../services/toast.js";
import server from "../../../../services/server.js";
import { t } from "../../../../services/i18n.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("vacuum_database.title")}</h4>
<p class="form-text">${t("vacuum_database.description")}</p>
<button class="vacuum-database-button btn btn-secondary">${t("vacuum_database.button_text")}</button>
</div>`;
export default class VacuumDatabaseOptions extends OptionsWidget {
private $vacuumDatabaseButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$vacuumDatabaseButton = this.$widget.find(".vacuum-database-button");
this.$vacuumDatabaseButton.on("click", async () => {
toastService.showMessage(t("vacuum_database.vacuuming_database"));
await server.post("database/vacuum-database");
toastService.showMessage(t("vacuum_database.database_vacuumed"));
});
}
}

View File

@@ -1,2 +0,0 @@
import AiSettingsWidget from './ai_settings/index.js';
export default AiSettingsWidget;

View File

@@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import toast from "../../../services/toast";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import Admonition from "../../react/Admonition";
import FormSelect from "../../react/FormSelect";
import FormTextBox from "../../react/FormTextBox";
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
import server from "../../../services/server";
import Button from "../../react/Button";
import FormTextArea from "../../react/FormTextArea";
export default function AiSettings() {
return (
<>
<EnableAiSettings />
<ProviderSettings />
</>
);
}
function EnableAiSettings() {
const [ aiEnabled, setAiEnabled ] = useTriliumOptionBool("aiEnabled");
return (
<>
<OptionsSection title={t("ai_llm.title")}>
<FormGroup name="ai-enabled" description={t("ai_llm.enable_ai_description")}>
<FormCheckbox
label={t("ai_llm.enable_ai_features")}
currentValue={aiEnabled} onChange={(isEnabled) => {
if (isEnabled) {
toast.showMessage(t("ai_llm.ai_enabled"));
} else {
toast.showMessage(t("ai_llm.ai_disabled"));
}
setAiEnabled(isEnabled);
}}
/>
</FormGroup>
{aiEnabled && <Admonition type="warning">{t("ai_llm.experimental_warning")}</Admonition>}
</OptionsSection>
</>
);
}
function ProviderSettings() {
const [ aiSelectedProvider, setAiSelectedProvider ] = useTriliumOption("aiSelectedProvider");
const [ aiTemperature, setAiTemperature ] = useTriliumOption("aiTemperature");
const [ aiSystemPrompt, setAiSystemPrompt ] = useTriliumOption("aiSystemPrompt");
return (
<OptionsSection title={t("ai_llm.provider_configuration")}>
<FormGroup name="selected-provider" label={t("ai_llm.selected_provider")} description={t("ai_llm.selected_provider_description")}>
<FormSelect
values={[
{ value: "", text: t("ai_llm.select_provider") },
{ value: "openai", text: "OpenAI" },
{ value: "anthropic", text: "Anthropic" },
{ value: "ollama", text: "Ollama" }
]}
currentValue={aiSelectedProvider} onChange={setAiSelectedProvider}
keyProperty="value" titleProperty="text"
/>
</FormGroup>
{
aiSelectedProvider === "openai" ?
<SingleProviderSettings
title={t("ai_llm.openai_settings")}
apiKeyDescription={t("ai_llm.openai_api_key_description")}
baseUrlDescription={t("ai_llm.openai_url_description")}
modelDescription={t("ai_llm.openai_model_description")}
validationErrorMessage={t("ai_llm.empty_key_warning.openai")}
apiKeyOption="openaiApiKey" baseUrlOption="openaiBaseUrl" modelOption="openaiDefaultModel"
provider={aiSelectedProvider}
/>
: aiSelectedProvider === "anthropic" ?
<SingleProviderSettings
title={t("ai_llm.anthropic_settings")}
apiKeyDescription={t("ai_llm.anthropic_api_key_description")}
modelDescription={t("ai_llm.anthropic_model_description")}
baseUrlDescription={t("ai_llm.anthropic_url_description")}
validationErrorMessage={t("ai_llm.empty_key_warning.anthropic")}
apiKeyOption="anthropicApiKey" baseUrlOption="anthropicBaseUrl" modelOption="anthropicDefaultModel"
provider={aiSelectedProvider}
/>
: aiSelectedProvider === "ollama" ?
<SingleProviderSettings
title={t("ai_llm.ollama_settings")}
baseUrlDescription={t("ai_llm.ollama_url_description")}
modelDescription={t("ai_llm.ollama_model_description")}
validationErrorMessage={t("ai_llm.ollama_no_url")}
baseUrlOption="ollamaBaseUrl"
provider={aiSelectedProvider} modelOption="ollamaDefaultModel"
/>
:
<></>
}
<FormGroup name="ai-temperature" label={t("ai_llm.temperature")} description={t("ai_llm.temperature_description")}>
<FormTextBox
type="number" min="0" max="2" step="0.1"
currentValue={aiTemperature} onChange={setAiTemperature}
/>
</FormGroup>
<FormGroup name="system-prompt" label={t("ai_llm.system_prompt")} description={t("ai_llm.system_prompt_description")}>
<FormTextArea
rows={3}
currentValue={aiSystemPrompt} onBlur={setAiSystemPrompt}
/>
</FormGroup>
</OptionsSection>
)
}
interface SingleProviderSettingsProps {
provider: string;
title: string;
apiKeyDescription?: string;
baseUrlDescription: string;
modelDescription: string;
validationErrorMessage: string;
apiKeyOption?: OptionNames;
baseUrlOption: OptionNames;
modelOption: OptionNames;
}
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
const [ apiKey, setApiKey ] = apiKeyOption ? useTriliumOption(apiKeyOption) : [];
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
return (
<div class="provider-settings">
<div class="card mt-3">
<div class="card-header">
<h5>{title}</h5>
</div>
<div class="card-body">
{!isValid && <Admonition type="caution">{validationErrorMessage}</Admonition> }
{apiKeyOption && (
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
<FormTextBox
type="password" autoComplete="off"
currentValue={apiKey} onChange={setApiKey}
/>
</FormGroup>
)}
<FormGroup name="base-url" label={t("ai_llm.url")} description={baseUrlDescription}>
<FormTextBox
currentValue={baseUrl ?? "https://api.openai.com/v1"} onChange={setBaseUrl}
/>
</FormGroup>
{isValid &&
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
</FormGroup>
}
</div>
</div>
</div>
)
}
function ModelSelector({ provider, baseUrl, modelOption }: { provider: string; baseUrl: string, modelOption: OptionNames }) {
const [ model, setModel ] = useTriliumOption(modelOption);
const [ models, setModels ] = useState<{ name: string, id: string }[]>([]);
const loadProviders = useCallback(async () => {
switch (provider) {
case "openai":
case "anthropic": {
try {
const response = await server.get<OpenAiOrAnthropicModelResponse>(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
if (response.success) {
setModels(response.chatModels.toSorted((a, b) => a.name.localeCompare(b.name)));
} else {
toast.showError(t("ai_llm.no_models_found_online"));
}
} catch (e) {
toast.showError(t("ai_llm.error_fetching", { error: e }));
}
break;
}
case "ollama": {
try {
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`);
if (response.success) {
setModels(response.models
.map(model => ({
name: model.name,
id: model.model
}))
.toSorted((a, b) => a.name.localeCompare(b.name)));
} else {
toast.showError(t("ai_llm.no_models_found_ollama"));
}
} catch (e) {
toast.showError(t("ai_llm.error_fetching", { error: e }));
}
break;
}
}
}, [provider]);
useEffect(() => {
loadProviders();
}, [provider]);
return (
<>
<FormSelect
values={models}
keyProperty="id" titleProperty="name"
currentValue={model} onChange={setModel}
/>
<Button
text={t("ai_llm.refresh_models")}
onClick={loadProviders}
size="small"
style={{ marginTop: "0.5em" }}
/>
</>
)
}

View File

@@ -1,362 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { TPL } from "./template.js";
import { t } from "../../../../services/i18n.js";
import type { OptionDefinitions, OptionMap } from "@triliumnext/commons";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { ProviderService } from "./providers.js";
export default class AiSettingsWidget extends OptionsWidget {
private ollamaModelsRefreshed = false;
private openaiModelsRefreshed = false;
private anthropicModelsRefreshed = false;
private providerService: ProviderService | null = null;
doRender() {
this.$widget = $(TPL);
this.providerService = new ProviderService(this.$widget);
// Setup event handlers for options
this.setupEventHandlers();
return this.$widget;
}
/**
* Helper method to set up a change event handler for an option
* @param selector The jQuery selector for the element
* @param optionName The name of the option to update
* @param validateAfter Whether to run validation after the update
* @param isCheckbox Whether the element is a checkbox
*/
setupChangeHandler(selector: string, optionName: keyof OptionDefinitions, validateAfter: boolean = false, isCheckbox: boolean = false) {
if (!this.$widget) return;
const $element = this.$widget.find(selector);
$element.on('change', async () => {
let value: string;
if (isCheckbox) {
value = $element.prop('checked') ? 'true' : 'false';
} else {
value = $element.val() as string;
}
await this.updateOption(optionName, value);
// Special handling for aiEnabled option
if (optionName === 'aiEnabled') {
try {
const isEnabled = value === 'true';
if (isEnabled) {
toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled");
} else {
toastService.showMessage(t("ai_llm.ai_disabled") || "AI features disabled");
}
} catch (error) {
console.error('Error toggling AI:', error);
toastService.showError(t("ai_llm.ai_toggle_error") || "Error toggling AI features");
}
}
if (validateAfter) {
await this.displayValidationWarnings();
}
});
}
/**
* Set up all event handlers for options
*/
setupEventHandlers() {
if (!this.$widget) return;
// Core AI options
this.setupChangeHandler('.ai-enabled', 'aiEnabled', true, true);
this.setupChangeHandler('.ai-selected-provider', 'aiSelectedProvider', true);
this.setupChangeHandler('.ai-temperature', 'aiTemperature');
this.setupChangeHandler('.ai-system-prompt', 'aiSystemPrompt');
// OpenAI options
this.setupChangeHandler('.openai-api-key', 'openaiApiKey', true);
this.setupChangeHandler('.openai-base-url', 'openaiBaseUrl', true);
this.setupChangeHandler('.openai-default-model', 'openaiDefaultModel');
// Anthropic options
this.setupChangeHandler('.anthropic-api-key', 'anthropicApiKey', true);
this.setupChangeHandler('.anthropic-default-model', 'anthropicDefaultModel');
this.setupChangeHandler('.anthropic-base-url', 'anthropicBaseUrl');
// Voyage options
this.setupChangeHandler('.voyage-api-key', 'voyageApiKey');
// Ollama options
this.setupChangeHandler('.ollama-base-url', 'ollamaBaseUrl');
this.setupChangeHandler('.ollama-default-model', 'ollamaDefaultModel');
const $refreshModels = this.$widget.find('.refresh-models');
$refreshModels.on('click', async () => {
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(true, this.ollamaModelsRefreshed) || false;
});
// Add tab change handler for Ollama tab
const $ollamaTab = this.$widget.find('#nav-ollama-tab');
$ollamaTab.on('shown.bs.tab', async () => {
// Only refresh the models if we haven't done it before
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(false, this.ollamaModelsRefreshed) || false;
});
// OpenAI models refresh button
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
$refreshOpenAIModels.on('click', async () => {
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(true, this.openaiModelsRefreshed) || false;
});
// Add tab change handler for OpenAI tab
const $openaiTab = this.$widget.find('#nav-openai-tab');
$openaiTab.on('shown.bs.tab', async () => {
// Only refresh the models if we haven't done it before
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(false, this.openaiModelsRefreshed) || false;
});
// Anthropic models refresh button
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
$refreshAnthropicModels.on('click', async () => {
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(true, this.anthropicModelsRefreshed) || false;
});
// Add tab change handler for Anthropic tab
const $anthropicTab = this.$widget.find('#nav-anthropic-tab');
$anthropicTab.on('shown.bs.tab', async () => {
// Only refresh the models if we haven't done it before
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(false, this.anthropicModelsRefreshed) || false;
});
// Add provider selection change handlers for dynamic settings visibility
this.$widget.find('.ai-selected-provider').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
this.$widget.find('.provider-settings').hide();
if (selectedProvider) {
this.$widget.find(`.${selectedProvider}-provider-settings`).show();
// Automatically fetch models for the newly selected provider
await this.fetchModelsForProvider(selectedProvider, 'chat');
}
});
// Add base URL change handlers to trigger model fetching
this.$widget.find('.openai-base-url').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedProvider === 'openai') {
await this.fetchModelsForProvider('openai', 'chat');
}
});
this.$widget.find('.anthropic-base-url').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedProvider === 'anthropic') {
await this.fetchModelsForProvider('anthropic', 'chat');
}
});
this.$widget.find('.ollama-base-url').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedProvider === 'ollama') {
await this.fetchModelsForProvider('ollama', 'chat');
}
});
// Add API key change handlers to trigger model fetching
this.$widget.find('.openai-api-key').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedProvider === 'openai') {
await this.fetchModelsForProvider('openai', 'chat');
}
});
this.$widget.find('.anthropic-api-key').on('change', async () => {
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedProvider === 'anthropic') {
await this.fetchModelsForProvider('anthropic', 'chat');
}
});
}
/**
* Display warnings for validation issues with providers
*/
async displayValidationWarnings() {
if (!this.$widget) return;
const $warningDiv = this.$widget.find('.provider-validation-warning');
// Check if AI is enabled
const aiEnabled = this.$widget.find('.ai-enabled').prop('checked');
if (!aiEnabled) {
$warningDiv.hide();
return;
}
// Get selected provider
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
// Start with experimental warning
const allWarnings = [
t("ai_llm.experimental_warning")
];
// Check for selected provider configuration
const providerWarnings: string[] = [];
if (selectedProvider === 'openai') {
const openaiApiKey = this.$widget.find('.openai-api-key').val();
if (!openaiApiKey) {
providerWarnings.push(t("ai_llm.empty_key_warning.openai"));
}
} else if (selectedProvider === 'anthropic') {
const anthropicApiKey = this.$widget.find('.anthropic-api-key').val();
if (!anthropicApiKey) {
providerWarnings.push(t("ai_llm.empty_key_warning.anthropic"));
}
} else if (selectedProvider === 'ollama') {
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val();
if (!ollamaBaseUrl) {
providerWarnings.push(t("ai_llm.ollama_no_url"));
}
}
// Add provider warnings to all warnings
allWarnings.push(...providerWarnings);
// Show or hide warnings
if (allWarnings.length > 0) {
const warningHtml = '<strong>' + t("ai_llm.configuration_warnings") + '</strong><ul>' +
allWarnings.map(warning => `<li>${warning}</li>`).join('') + '</ul>';
$warningDiv.html(warningHtml).show();
} else {
$warningDiv.hide();
}
}
/**
* Helper to get display name for providers
*/
getProviderDisplayName(provider: string): string {
switch(provider) {
case 'openai': return 'OpenAI';
case 'anthropic': return 'Anthropic';
case 'ollama': return 'Ollama';
case 'voyage': return 'Voyage';
case 'local': return 'Local';
default: return provider.charAt(0).toUpperCase() + provider.slice(1);
}
}
/**
* Set model dropdown value, adding the option if it doesn't exist
*/
setModelDropdownValue(selector: string, value: string | undefined) {
if (!this.$widget || !value) return;
const $dropdown = this.$widget.find(selector);
// Check if the value already exists as an option
if ($dropdown.find(`option[value="${value}"]`).length === 0) {
// Add the custom value as an option
$dropdown.append(`<option value="${value}">${value} (current)</option>`);
}
// Set the value
$dropdown.val(value);
}
/**
* Fetch models for a specific provider and model type
*/
async fetchModelsForProvider(provider: string, modelType: 'chat') {
if (!this.providerService) return;
try {
switch (provider) {
case 'openai':
this.openaiModelsRefreshed = await this.providerService.refreshOpenAIModels(false, this.openaiModelsRefreshed);
break;
case 'anthropic':
this.anthropicModelsRefreshed = await this.providerService.refreshAnthropicModels(false, this.anthropicModelsRefreshed);
break;
case 'ollama':
this.ollamaModelsRefreshed = await this.providerService.refreshOllamaModels(false, this.ollamaModelsRefreshed);
break;
default:
console.log(`Model fetching not implemented for provider: ${provider}`);
}
} catch (error) {
console.error(`Error fetching models for ${provider}:`, error);
}
}
/**
* Update provider settings visibility based on selected providers
*/
updateProviderSettingsVisibility() {
if (!this.$widget) return;
// Update AI provider settings visibility
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
this.$widget.find('.provider-settings').hide();
if (selectedAiProvider) {
this.$widget.find(`.${selectedAiProvider}-provider-settings`).show();
}
}
/**
* Called when the options have been loaded from the server
*/
async optionsLoaded(options: OptionMap) {
if (!this.$widget) return;
// AI Options
this.$widget.find('.ai-enabled').prop('checked', options.aiEnabled !== 'false');
this.$widget.find('.ai-temperature').val(options.aiTemperature || '0.7');
this.$widget.find('.ai-system-prompt').val(options.aiSystemPrompt || '');
this.$widget.find('.ai-selected-provider').val(options.aiSelectedProvider || 'openai');
// OpenAI Section
this.$widget.find('.openai-api-key').val(options.openaiApiKey || '');
this.$widget.find('.openai-base-url').val(options.openaiBaseUrl || 'https://api.openai.com/v1');
this.setModelDropdownValue('.openai-default-model', options.openaiDefaultModel);
// Anthropic Section
this.$widget.find('.anthropic-api-key').val(options.anthropicApiKey || '');
this.$widget.find('.anthropic-base-url').val(options.anthropicBaseUrl || 'https://api.anthropic.com');
this.setModelDropdownValue('.anthropic-default-model', options.anthropicDefaultModel);
// Voyage Section
this.$widget.find('.voyage-api-key').val(options.voyageApiKey || '');
// Ollama Section
this.$widget.find('.ollama-base-url').val(options.ollamaBaseUrl || 'http://localhost:11434');
this.setModelDropdownValue('.ollama-default-model', options.ollamaDefaultModel);
// Show/hide provider settings based on selected providers
this.updateProviderSettingsVisibility();
// Automatically fetch models for currently selected providers
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
if (selectedAiProvider) {
await this.fetchModelsForProvider(selectedAiProvider, 'chat');
}
// Display validation warnings
this.displayValidationWarnings();
}
cleanup() {
// Cleanup method for widget
}
}

View File

@@ -1,2 +0,0 @@
import AiSettingsWidget from './ai_settings_widget.js';
export default AiSettingsWidget;

View File

@@ -1,31 +0,0 @@
// Interface for the Ollama model response
export interface OllamaModelResponse {
success: boolean;
models: Array<{
name: string;
model: string;
details?: {
family?: string;
parameter_size?: string;
}
}>;
}
export interface OpenAIModelResponse {
success: boolean;
chatModels: Array<{
id: string;
name: string;
type: string;
}>;
}
export interface AnthropicModelResponse {
success: boolean;
chatModels: Array<{
id: string;
name: string;
type: string;
}>;
}

View File

@@ -1,252 +0,0 @@
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { t } from "../../../../services/i18n.js";
import options from "../../../../services/options.js";
import type { OpenAIModelResponse, AnthropicModelResponse, OllamaModelResponse } from "./interfaces.js";
export class ProviderService {
constructor(private $widget: JQuery<HTMLElement>) {
// AI provider settings
}
/**
* Ensures the dropdown has the correct value set, prioritizing:
* 1. Current UI value if present
* 2. Value from database options if available
* 3. Falling back to first option if neither is available
*/
private ensureSelectedValue($select: JQuery<HTMLElement>, currentValue: string | number | string[] | undefined | null, optionName: string) {
if (currentValue) {
$select.val(currentValue);
// If the value doesn't exist anymore, select the first option
if (!$select.val()) {
$select.prop('selectedIndex', 0);
}
} else {
// If no current value exists in the dropdown but there's a default in the database
const savedModel = options.get(optionName);
if (savedModel) {
$select.val(savedModel);
// If the saved model isn't in the dropdown, select the first option
if (!$select.val()) {
$select.prop('selectedIndex', 0);
}
}
}
}
/**
* Refreshes the list of OpenAI models
* @param showLoading Whether to show loading indicators and toasts
* @param openaiModelsRefreshed Reference to track if models have been refreshed
* @returns Promise that resolves when the refresh is complete
*/
async refreshOpenAIModels(showLoading: boolean, openaiModelsRefreshed: boolean): Promise<boolean> {
if (!this.$widget) return false;
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
// If we've already refreshed and we're not forcing a refresh, don't do it again
if (openaiModelsRefreshed && !showLoading) {
return openaiModelsRefreshed;
}
if (showLoading) {
$refreshOpenAIModels.prop('disabled', true);
$refreshOpenAIModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
}
try {
const openaiBaseUrl = this.$widget.find('.openai-base-url').val() as string;
const response = await server.get<OpenAIModelResponse>(`llm/providers/openai/models?baseUrl=${encodeURIComponent(openaiBaseUrl)}`);
if (response && response.success) {
// Update the chat models dropdown
if (response.chatModels?.length > 0) {
const $chatModelSelect = this.$widget.find('.openai-default-model');
const currentChatValue = $chatModelSelect.val();
// Clear existing options
$chatModelSelect.empty();
// Sort models by name
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
// Add models to the dropdown
sortedChatModels.forEach(model => {
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
});
// Try to restore the previously selected value
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'openaiDefaultModel');
}
if (showLoading) {
// Show success message
const totalModels = (response.chatModels?.length || 0);
toastService.showMessage(`${totalModels} OpenAI models found.`);
}
return true;
} else if (showLoading) {
toastService.showError(`No OpenAI models found. Please check your API key and settings.`);
}
return openaiModelsRefreshed;
} catch (e) {
console.error(`Error fetching OpenAI models:`, e);
if (showLoading) {
toastService.showError(`Error fetching OpenAI models: ${e}`);
}
return openaiModelsRefreshed;
} finally {
if (showLoading) {
$refreshOpenAIModels.prop('disabled', false);
$refreshOpenAIModels.html(`<span class="bx bx-refresh"></span>`);
}
}
}
/**
* Refreshes the list of Anthropic models
* @param showLoading Whether to show loading indicators and toasts
* @param anthropicModelsRefreshed Reference to track if models have been refreshed
* @returns Promise that resolves when the refresh is complete
*/
async refreshAnthropicModels(showLoading: boolean, anthropicModelsRefreshed: boolean): Promise<boolean> {
if (!this.$widget) return false;
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
// If we've already refreshed and we're not forcing a refresh, don't do it again
if (anthropicModelsRefreshed && !showLoading) {
return anthropicModelsRefreshed;
}
if (showLoading) {
$refreshAnthropicModels.prop('disabled', true);
$refreshAnthropicModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
}
try {
const anthropicBaseUrl = this.$widget.find('.anthropic-base-url').val() as string;
const response = await server.get<AnthropicModelResponse>(`llm/providers/anthropic/models?baseUrl=${encodeURIComponent(anthropicBaseUrl)}`);
if (response && response.success) {
// Update the chat models dropdown
if (response.chatModels?.length > 0) {
const $chatModelSelect = this.$widget.find('.anthropic-default-model');
const currentChatValue = $chatModelSelect.val();
// Clear existing options
$chatModelSelect.empty();
// Sort models by name
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
// Add models to the dropdown
sortedChatModels.forEach(model => {
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
});
// Try to restore the previously selected value
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'anthropicDefaultModel');
}
if (showLoading) {
// Show success message
const totalModels = (response.chatModels?.length || 0);
toastService.showMessage(`${totalModels} Anthropic models found.`);
}
return true;
} else if (showLoading) {
toastService.showError(`No Anthropic models found. Please check your API key and settings.`);
}
return anthropicModelsRefreshed;
} catch (e) {
console.error(`Error fetching Anthropic models:`, e);
if (showLoading) {
toastService.showError(`Error fetching Anthropic models: ${e}`);
}
return anthropicModelsRefreshed;
} finally {
if (showLoading) {
$refreshAnthropicModels.prop('disabled', false);
$refreshAnthropicModels.html(`<span class="bx bx-refresh"></span>`);
}
}
}
/**
* Refreshes the list of Ollama models
* @param showLoading Whether to show loading indicators and toasts
* @param ollamaModelsRefreshed Reference to track if models have been refreshed
* @returns Promise that resolves when the refresh is complete
*/
async refreshOllamaModels(showLoading: boolean, ollamaModelsRefreshed: boolean): Promise<boolean> {
if (!this.$widget) return false;
const $refreshModels = this.$widget.find('.refresh-models');
// If we've already refreshed and we're not forcing a refresh, don't do it again
if (ollamaModelsRefreshed && !showLoading) {
return ollamaModelsRefreshed;
}
if (showLoading) {
$refreshModels.prop('disabled', true);
$refreshModels.text(t("ai_llm.refreshing_models"));
}
try {
// Use the general Ollama base URL
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val() as string;
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(ollamaBaseUrl)}`);
if (response && response.success && response.models && response.models.length > 0) {
// Update the LLM model dropdown
const $modelSelect = this.$widget.find('.ollama-default-model');
const currentModelValue = $modelSelect.val();
// Clear existing options
$modelSelect.empty();
// Sort models by name to make them easier to find
const sortedModels = [...response.models].sort((a, b) => a.name.localeCompare(b.name));
// Add all models to the dropdown
sortedModels.forEach(model => {
$modelSelect.append(`<option value="${model.name}">${model.name}</option>`);
});
// Try to restore the previously selected value
this.ensureSelectedValue($modelSelect, currentModelValue, 'ollamaDefaultModel');
if (showLoading) {
toastService.showMessage(`${response.models.length} Ollama models found.`);
}
return true;
} else if (showLoading) {
toastService.showError(`No Ollama models found. Please check if Ollama is running.`);
}
return ollamaModelsRefreshed;
} catch (e) {
console.error(`Error fetching Ollama models:`, e);
if (showLoading) {
toastService.showError(`Error fetching Ollama models: ${e}`);
}
return ollamaModelsRefreshed;
} finally {
if (showLoading) {
$refreshModels.prop('disabled', false);
$refreshModels.html(`<span class="bx bx-refresh"></span>`);
}
}
}
}

View File

@@ -1,135 +0,0 @@
import { t } from "../../../../services/i18n.js";
export const TPL = `
<div class="options-section">
<h4>${t("ai_llm.title")}</h4>
<!-- Add warning alert div -->
<div class="provider-validation-warning alert alert-warning" style="display: none;"></div>
<div class="form-group">
<label class="tn-checkbox">
<input class="ai-enabled form-check-input" type="checkbox">
${t("ai_llm.enable_ai_features")}
</label>
<div class="form-text">${t("ai_llm.enable_ai_description")}</div>
</div>
</div>
<!-- AI settings template -->
<div class="ai-providers-section options-section">
<h4>${t("ai_llm.provider_configuration")}</h4>
<div class="form-group">
<label>${t("ai_llm.selected_provider")}</label>
<select class="ai-selected-provider form-control">
<option value="">${t("ai_llm.select_provider")}</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="ollama">Ollama</option>
</select>
<div class="form-text">${t("ai_llm.selected_provider_description")}</div>
</div>
<!-- OpenAI Provider Settings -->
<div class="provider-settings openai-provider-settings" style="display: none;">
<div class="card mt-3">
<div class="card-header">
<h5>${t("ai_llm.openai_settings")}</h5>
</div>
<div class="card-body">
<div class="form-group">
<label>${t("ai_llm.api_key")}</label>
<input type="password" class="openai-api-key form-control" autocomplete="off" />
<div class="form-text">${t("ai_llm.openai_api_key_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.url")}</label>
<input type="text" class="openai-base-url form-control" />
<div class="form-text">${t("ai_llm.openai_url_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.model")}</label>
<select class="openai-default-model form-control">
<option value="">${t("ai_llm.select_model")}</option>
</select>
<div class="form-text">${t("ai_llm.openai_model_description")}</div>
<button class="btn btn-sm btn-outline-secondary refresh-openai-models">${t("ai_llm.refresh_models")}</button>
</div>
</div>
</div>
</div>
<!-- Anthropic Provider Settings -->
<div class="provider-settings anthropic-provider-settings" style="display: none;">
<div class="card mt-3">
<div class="card-header">
<h5>${t("ai_llm.anthropic_settings")}</h5>
</div>
<div class="card-body">
<div class="form-group">
<label>${t("ai_llm.api_key")}</label>
<input type="password" class="anthropic-api-key form-control" autocomplete="off" />
<div class="form-text">${t("ai_llm.anthropic_api_key_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.url")}</label>
<input type="text" class="anthropic-base-url form-control" />
<div class="form-text">${t("ai_llm.anthropic_url_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.model")}</label>
<select class="anthropic-default-model form-control">
<option value="">${t("ai_llm.select_model")}</option>
</select>
<div class="form-text">${t("ai_llm.anthropic_model_description")}</div>
<button class="btn btn-sm btn-outline-secondary refresh-anthropic-models">${t("ai_llm.refresh_models")}</button>
</div>
</div>
</div>
</div>
<!-- Ollama Provider Settings -->
<div class="provider-settings ollama-provider-settings" style="display: none;">
<div class="card mt-3">
<div class="card-header">
<h5>${t("ai_llm.ollama_settings")}</h5>
</div>
<div class="card-body">
<div class="form-group">
<label>${t("ai_llm.url")}</label>
<input type="text" class="ollama-base-url form-control" />
<div class="form-text">${t("ai_llm.ollama_url_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.model")}</label>
<select class="ollama-default-model form-control">
<option value="">${t("ai_llm.select_model")}</option>
</select>
<div class="form-text">${t("ai_llm.ollama_model_description")}</div>
<button class="btn btn-sm btn-outline-secondary refresh-models"><span class="bx bx-refresh"></span></button>
</div>
</div>
</div>
</div>
<div class="form-group">
<label>${t("ai_llm.temperature")}</label>
<input class="ai-temperature form-control" type="number" min="0" max="2" step="0.1">
<div class="form-text">${t("ai_llm.temperature_description")}</div>
</div>
<div class="form-group">
<label>${t("ai_llm.system_prompt")}</label>
<textarea class="ai-system-prompt form-control" rows="3"></textarea>
<div class="form-text">${t("ai_llm.system_prompt_description")}</div>
</div>
</div>
`;

View File

@@ -0,0 +1,270 @@
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
import Column from "../../react/Column";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import server from "../../../services/server";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import { FontFamily, OptionNames } from "@triliumnext/commons";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormText from "../../react/FormText";
import Button from "../../react/Button";
import RelatedSettings from "./components/RelatedSettings";
const MIN_CONTENT_WIDTH = 640;
interface Theme {
val: string;
title: string;
noteId?: string;
}
const BUILTIN_THEMES: Theme[] = [
{ val: "next", title: t("theme.triliumnext") },
{ val: "next-light", title: t("theme.triliumnext-light") },
{ val: "next-dark", title: t("theme.triliumnext-dark") },
{ val: "auto", title: t("theme.auto_theme") },
{ val: "light", title: t("theme.light_theme") },
{ val: "dark", title: t("theme.dark_theme") }
]
interface FontFamilyEntry {
value: FontFamily;
label?: string;
}
interface FontGroup {
title: string;
items: FontFamilyEntry[];
}
const FONT_FAMILIES: FontGroup[] = [
{
title: t("fonts.generic-fonts"),
items: [
{ value: "theme", label: t("fonts.theme_defined") },
{ value: "system", label: t("fonts.system-default") },
{ value: "serif", label: t("fonts.serif") },
{ value: "sans-serif", label: t("fonts.sans-serif") },
{ value: "monospace", label: t("fonts.monospace") }
]
},
{
title: t("fonts.sans-serif-system-fonts"),
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
},
{
title: t("fonts.serif-system-fonts"),
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
},
{
title: t("fonts.monospace-system-fonts"),
items: [
{ value: "Courier New" },
{ value: "Brush Script MT" },
{ value: "Impact" },
{ value: "American Typewriter" },
{ value: "Andalé Mono" },
{ value: "Lucida Console" },
{ value: "Monaco" }
]
},
{
title: t("fonts.handwriting-system-fonts"),
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
}
];
export default function AppearanceSettings() {
const [ overrideThemeFonts ] = useTriliumOption("overrideThemeFonts");
return (
<div>
{!isMobile() && <LayoutOrientation />}
<ApplicationTheme />
{overrideThemeFonts === "true" && <Fonts />}
{isElectron() && <ElectronIntegration /> }
<MaxContentWidth />
<RelatedSettings items={[
{
title: t("settings_appearance.related_code_blocks"),
targetPage: "_optionsTextNotes"
},
{
title: t("settings_appearance.related_code_notes"),
targetPage: "_optionsCodeNotes"
}
]} />
</div>
)
}
function LayoutOrientation() {
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
return (
<OptionsSection title={t("theme.layout")}>
<FormRadioGroup
name="layout-orientation"
values={[
{
label: t("theme.layout-vertical-title"),
inlineDescription: t("theme.layout-vertical-description"),
value: "vertical"
},
{
label: t("theme.layout-horizontal-title"),
inlineDescription: t("theme.layout-horizontal-description"),
value: "horizontal"
}
]}
currentValue={layoutOrientation} onChange={setLayoutOrientation}
/>
</OptionsSection>
);
}
function ApplicationTheme() {
const [ theme, setTheme ] = useTriliumOption("theme", true);
const [ overrideThemeFonts, setOverrideThemeFonts ] = useTriliumOptionBool("overrideThemeFonts");
const [ themes, setThemes ] = useState<Theme[]>([]);
useEffect(() => {
server.get<Theme[]>("options/user-themes").then((userThemes) => {
setThemes([
...BUILTIN_THEMES,
...userThemes
])
});
}, []);
return (
<OptionsSection title={t("theme.title")}>
<div className="row">
<FormGroup name="theme" label={t("theme.theme_label")} className="col-md-6" style={{ marginBottom: 0 }}>
<FormSelect
values={themes} currentValue={theme} onChange={setTheme}
keyProperty="val" titleProperty="title"
/>
</FormGroup>
<FormGroup className="side-checkbox col-md-6" name="override-theme-fonts">
<FormCheckbox
label={t("theme.override_theme_fonts_label")}
currentValue={overrideThemeFonts} onChange={setOverrideThemeFonts} />
</FormGroup>
</div>
</OptionsSection>
)
}
function Fonts() {
return (
<OptionsSection title={t("fonts.fonts")}>
<Font title={t("fonts.main_font")} fontFamilyOption="mainFontFamily" fontSizeOption="mainFontSize" />
<Font title={t("fonts.note_tree_font")} fontFamilyOption="treeFontFamily" fontSizeOption="treeFontSize" />
<Font title={t("fonts.note_detail_font")} fontFamilyOption="detailFontFamily" fontSizeOption="detailFontSize" />
<Font title={t("fonts.monospace_font")} fontFamilyOption="monospaceFontFamily" fontSizeOption="monospaceFontSize" />
<FormText>{t("fonts.note_tree_and_detail_font_sizing")}</FormText>
<FormText>{t("fonts.not_all_fonts_available")}</FormText>
<p>
{t("fonts.apply_font_changes")} <Button text={t("fonts.reload_frontend")} size="micro" onClick={reloadFrontendApp} />
</p>
</OptionsSection>
);
}
function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, fontFamilyOption: OptionNames, fontSizeOption: OptionNames }) {
const [ fontFamily, setFontFamily ] = useTriliumOption(fontFamilyOption);
const [ fontSize, setFontSize ] = useTriliumOption(fontSizeOption);
return (
<>
<h5>{title}</h5>
<div className="row">
<FormGroup name="font-family" className="col-md-4" label={t("fonts.font_family")}>
<FormSelectWithGroups
values={FONT_FAMILIES}
currentValue={fontFamily} onChange={setFontFamily}
keyProperty="value" titleProperty="label"
/>
</FormGroup>
<FormGroup name="font-size" className="col-md-6" label={t("fonts.size")}>
<FormTextBoxWithUnit
name="tree-font-size"
type="number" min={50} max={200} step={10}
currentValue={fontSize} onChange={setFontSize}
unit={t("units.percentage")}
/>
</FormGroup>
</div>
</>
);
}
function ElectronIntegration() {
const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor");
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects");
return (
<OptionsSection title={t("electron_integration.desktop-application")}>
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBox
type="number"
min="0.3" max="2.0" step="0.1"
currentValue={zoomFactor} onChange={setZoomFactor}
/>
</FormGroup>
<hr/>
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
<FormCheckbox
label={t("electron_integration.native-title-bar")}
currentValue={nativeTitleBarVisible} onChange={setNativeTitleBarVisible}
/>
</FormGroup>
<FormGroup name="background-effects" description={t("electron_integration.background-effects-description")}>
<FormCheckbox
label={t("electron_integration.background-effects")}
currentValue={backgroundEffects} onChange={setBackgroundEffects}
/>
</FormGroup>
<Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} />
</OptionsSection>
)
}
function MaxContentWidth() {
const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth");
return (
<OptionsSection title={t("max_content_width.title")}>
<FormText>{t("max_content_width.default_description")}</FormText>
<Column md={6}>
<FormGroup name="max-content-width" label={t("max_content_width.max_width_label")}>
<FormTextBoxWithUnit
type="number" min={MIN_CONTENT_WIDTH} step="10"
currentValue={maxContentWidth} onChange={setMaxContentWidth}
unit={t("max_content_width.max_width_unit")}
/>
</FormGroup>
</Column>
<p>
{t("max_content_width.apply_changes_description")} <Button text={t("max_content_width.reload_button")} size="micro" onClick={reloadFrontendApp} />
</p>
</OptionsSection>
)
}

View File

@@ -1,76 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import utils from "../../../../services/utils.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("electron_integration.desktop-application")}</h4>
<div class="form-group row">
<div class="col-12">
<label for="zoom-factor-select">${t("electron_integration.zoom-factor")}</label>
<input id="zoom-factor-select" type="number" class="zoom-factor-select form-control options-number-input" min="0.3" max="2.0" step="0.1"/>
<p class="form-text">${t("zoom_factor.description")}</p>
</div>
</div>
<hr />
<div>
<label class="form-check tn-checkbox">
<input type="checkbox" class="native-title-bar form-check-input" />
${t("electron_integration.native-title-bar")}
</label>
<p class="form-text">
${t("electron_integration.native-title-bar-description")}
</p>
</div>
<div>
<label class="form-check tn-checkbox">
<input type="checkbox" class="background-effects form-check-input" />
${t("electron_integration.background-effects")}
</label>
<p class="form-text">
${t("electron_integration.background-effects-description")}
</p>
</div>
<button class="btn btn-secondary btn-micro restart-app-button">${t("electron_integration.restart-app-button")}</button>
</div>
`;
export default class ElectronIntegrationOptions extends OptionsWidget {
private $zoomFactorSelect!: JQuery<HTMLElement>;
private $nativeTitleBar!: JQuery<HTMLElement>;
private $backgroundEffects!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$zoomFactorSelect = this.$widget.find(".zoom-factor-select");
this.$zoomFactorSelect.on("change", () => {
this.triggerCommand("setZoomFactorAndSave", { zoomFactor: String(this.$zoomFactorSelect.val()) });
});
this.$nativeTitleBar = this.$widget.find("input.native-title-bar");
this.$nativeTitleBar.on("change", () => this.updateCheckboxOption("nativeTitleBarVisible", this.$nativeTitleBar));
this.$backgroundEffects = this.$widget.find("input.background-effects");
this.$backgroundEffects.on("change", () => this.updateCheckboxOption("backgroundEffects", this.$backgroundEffects));
const restartAppButton = this.$widget.find(".restart-app-button");
restartAppButton.on("click", utils.restartDesktopApp);
}
isEnabled() {
return utils.isElectron();
}
async optionsLoaded(options: OptionMap) {
this.$zoomFactorSelect.val(options.zoomFactor);
this.setCheckboxState(this.$nativeTitleBar, options.nativeTitleBarVisible);
this.setCheckboxState(this.$backgroundEffects, options.backgroundEffects);
}
}

View File

@@ -1,218 +0,0 @@
import OptionsWidget from "../options_widget.js";
import utils from "../../../../services/utils.js";
import { t } from "../../../../services/i18n.js";
import type { FontFamily, OptionMap, OptionNames } from "@triliumnext/commons";
interface FontFamilyEntry {
value: FontFamily;
label?: string;
}
interface FontGroup {
title: string;
items: FontFamilyEntry[];
}
const FONT_FAMILIES: FontGroup[] = [
{
title: t("fonts.generic-fonts"),
items: [
{ value: "theme", label: t("fonts.theme_defined") },
{ value: "system", label: t("fonts.system-default") },
{ value: "serif", label: t("fonts.serif") },
{ value: "sans-serif", label: t("fonts.sans-serif") },
{ value: "monospace", label: t("fonts.monospace") }
]
},
{
title: t("fonts.sans-serif-system-fonts"),
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
},
{
title: t("fonts.serif-system-fonts"),
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
},
{
title: t("fonts.monospace-system-fonts"),
items: [
{ value: "Courier New" },
{ value: "Brush Script MT" },
{ value: "Impact" },
{ value: "American Typewriter" },
{ value: "Andalé Mono" },
{ value: "Lucida Console" },
{ value: "Monaco" }
]
},
{
title: t("fonts.handwriting-system-fonts"),
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
}
];
const TPL = /*html*/`
<div class="options-section">
<h4>${t("fonts.fonts")}</h4>
<h5>${t("fonts.main_font")}</h5>
<div class="form-group row">
<div class="col-4">
<label for="main-font-family">${t("fonts.font_family")}</label>
<select id="main-font-family" class="main-font-family form-select"></select>
</div>
<div class="col-6">
<label for="main-font-size">${t("fonts.size")}</label>
<label class="input-group tn-number-unit-pair main-font-size-input-group">
<input id="main-font-size" type="number" class="main-font-size form-control options-number-input" min="50" max="200" step="10"/>
<span class="input-group-text">%</span>
</label>
</div>
</div>
<h5>${t("fonts.note_tree_font")}</h5>
<div class="form-group row">
<div class="col-4">
<label for="tree-font-family">${t("fonts.font_family")}</label>
<select id="tree-font-family" class="tree-font-family form-select"></select>
</div>
<div class="col-6">
<label for="tree-font-size">${t("fonts.size")}</label>
<label class="input-group tn-number-unit-pair tree-font-size-input-group">
<input id="tree-font-size" type="number" class="tree-font-size form-control options-number-input" min="50" max="200" step="10"/>
<span class="input-group-text">%</span>
</label>
</div>
</div>
<h5>${t("fonts.note_detail_font")}</h5>
<div class="form-group row">
<div class="col-4">
<label for="detail-font-family">${t("fonts.font_family")}</label>
<select id="detail-font-family" class="detail-font-family form-select"></select>
</div>
<div class="col-6">
<label for="detail-font-size">${t("fonts.size")}</label>
<label class="input-group tn-number-unit-pair detail-font-size-input-group">
<input id="detail-font-size" type="number" class="detail-font-size form-control options-number-input" min="50" max="200" step="10"/>
<span class="input-group-text">%</span>
</label>
</div>
</div>
<h5>${t("fonts.monospace_font")}</h5>
<div class="form-group row">
<div class="col-4">
<label for="monospace-font-family">${t("fonts.font_family")}</label>
<select id="monospace-font-family" class="monospace-font-family form-select"></select>
</div>
<div class="col-6">
<label for="monospace-font-size">${t("fonts.size")}</label>
<label class="input-group tn-number-unit-pair monospace-font-size-input-group">
<input id="monospace-font-size" type="number" class="monospace-font-size form-control options-number-input" min="50" max="200" step="10"/>
<span class="input-group-text">%</span>
</label>
</div>
</div>
<p class="form-text">${t("fonts.note_tree_and_detail_font_sizing")}</p>
<p class="form-text">${t("fonts.not_all_fonts_available")}</p>
<p>
${t("fonts.apply_font_changes")}
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("fonts.reload_frontend")}</button>
</p>
</div>`;
export default class FontsOptions extends OptionsWidget {
private $mainFontSize!: JQuery<HTMLElement>;
private $mainFontFamily!: JQuery<HTMLElement>;
private $treeFontSize!: JQuery<HTMLElement>;
private $treeFontFamily!: JQuery<HTMLElement>;
private $detailFontSize!: JQuery<HTMLElement>;
private $detailFontFamily!: JQuery<HTMLElement>;
private $monospaceFontSize!: JQuery<HTMLElement>;
private $monospaceFontFamily!: JQuery<HTMLElement>;
private _isEnabled?: boolean;
doRender() {
this.$widget = $(TPL);
this.$mainFontSize = this.$widget.find(".main-font-size");
this.$mainFontFamily = this.$widget.find(".main-font-family");
this.$treeFontSize = this.$widget.find(".tree-font-size");
this.$treeFontFamily = this.$widget.find(".tree-font-family");
this.$detailFontSize = this.$widget.find(".detail-font-size");
this.$detailFontFamily = this.$widget.find(".detail-font-family");
this.$monospaceFontSize = this.$widget.find(".monospace-font-size");
this.$monospaceFontFamily = this.$widget.find(".monospace-font-family");
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp("changes from appearance options"));
}
isEnabled() {
return !!this._isEnabled;
}
async optionsLoaded(options: OptionMap) {
this._isEnabled = options.overrideThemeFonts === "true";
this.toggleInt(this._isEnabled);
if (!this._isEnabled) {
return;
}
this.$mainFontSize.val(options.mainFontSize);
this.fillFontFamilyOptions(this.$mainFontFamily, options.mainFontFamily);
this.$treeFontSize.val(options.treeFontSize);
this.fillFontFamilyOptions(this.$treeFontFamily, options.treeFontFamily);
this.$detailFontSize.val(options.detailFontSize);
this.fillFontFamilyOptions(this.$detailFontFamily, options.detailFontFamily);
this.$monospaceFontSize.val(options.monospaceFontSize);
this.fillFontFamilyOptions(this.$monospaceFontFamily, options.monospaceFontFamily);
const optionsToSave: OptionNames[] = ["mainFontFamily", "mainFontSize", "treeFontFamily", "treeFontSize", "detailFontFamily", "detailFontSize", "monospaceFontFamily", "monospaceFontSize"];
for (const optionName of optionsToSave) {
const $el = (this as any)[`$${optionName}`];
$el.on("change", () => this.updateOption(optionName, $el.val()));
}
}
fillFontFamilyOptions($select: JQuery<HTMLElement>, currentValue: string) {
$select.empty();
for (const { title, items } of Object.values(FONT_FAMILIES)) {
const $group = $("<optgroup>").attr("label", title);
for (const { value, label } of items) {
$group.append(
$("<option>")
.attr("value", value)
.prop("selected", value === currentValue)
.text(label ?? value)
);
}
$select.append($group);
}
}
}

View File

@@ -1,47 +0,0 @@
import OptionsWidget from "../options_widget.js";
import utils from "../../../../services/utils.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const MIN_VALUE = 640;
const TPL = /*html*/`
<div class="options-section">
<h4>${t("max_content_width.title")}</h4>
<p class="form-text">${t("max_content_width.default_description")}</p>
<div class="form-group row">
<div class="col-md-6">
<label for="max-content-width">${t("max_content_width.max_width_label")}</label>
<label class="input-group tn-number-unit-pair">
<input id="max-content-width" type="number" min="${MIN_VALUE}" step="10" class="max-content-width form-control options-number-input">
<span class="input-group-text">${t("max_content_width.max_width_unit")}</span>
</label>
</div>
</div>
<p>
${t("max_content_width.apply_changes_description")}
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("max_content_width.reload_button")}</button>
</p>
</div>`;
export default class MaxContentWidthOptions extends OptionsWidget {
private $maxContentWidth!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$maxContentWidth = this.$widget.find(".max-content-width");
this.$maxContentWidth.on("change", async () => this.updateOption("maxContentWidth", String(this.$maxContentWidth.val())));
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp(t("max_content_width.reload_description")));
}
async optionsLoaded(options: OptionMap) {
this.$maxContentWidth.val(Math.max(MIN_VALUE, parseInt(options.maxContentWidth, 10)));
}
}

View File

@@ -1,71 +0,0 @@
import type { OptionPages } from "../../content_widget";
import OptionsWidget from "../options_widget";
const TPL = `\
<div class="options-section">
<h4>Related settings</h4>
<nav class="related-settings use-tn-links">
<li>Color scheme for code blocks in text notes</li>
<li>Color scheme for code notes</li>
</nav>
<style>
.related-settings {
padding: 0;
margin: 0;
list-style-type: none;
}
</style>
</div>
`;
interface RelatedSettingsConfig {
items: {
title: string;
targetPage: OptionPages;
}[];
}
const RELATED_SETTINGS: Record<string, RelatedSettingsConfig> = {
"_optionsAppearance": {
items: [
{
title: "Color scheme for code blocks in text notes",
targetPage: "_optionsTextNotes"
},
{
title: "Color scheme for code notes",
targetPage: "_optionsCodeNotes"
}
]
}
};
export default class RelatedSettings extends OptionsWidget {
doRender() {
this.$widget = $(TPL);
const config = this.noteId && RELATED_SETTINGS[this.noteId];
if (!config) {
return;
}
const $relatedSettings = this.$widget.find(".related-settings");
$relatedSettings.empty();
for (const item of config.items) {
const $item = $("<li>");
const $link = $("<a>").text(item.title);
$item.append($link);
$link.attr("href", `#root/_hidden/_options/${item.targetPage}`);
$relatedSettings.append($item);
}
}
isEnabled() {
return (!!this.noteId && this.noteId in RELATED_SETTINGS);
}
}

View File

@@ -1,42 +0,0 @@
import type { OptionMap } from "@triliumnext/commons";
import { t } from "../../../../services/i18n.js";
import OptionsWidget from "../options_widget.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("ribbon.widgets")}</h4>
<div>
<label class="tn-checkbox">
<input type="checkbox" class="promoted-attributes-open-in-ribbon form-check-input">
${t("ribbon.promoted_attributes_message")}
</label>
</div>
<div>
<label class="tn-checkbox">
<input type="checkbox" class="edited-notes-open-in-ribbon form-check-input">
${t("ribbon.edited_notes_message")}
</label>
</div>
</div>`;
export default class RibbonOptions extends OptionsWidget {
private $promotedAttributesOpenInRibbon!: JQuery<HTMLElement>;
private $editedNotesOpenInRibbon!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$promotedAttributesOpenInRibbon = this.$widget.find(".promoted-attributes-open-in-ribbon");
this.$promotedAttributesOpenInRibbon.on("change", () => this.updateCheckboxOption("promotedAttributesOpenInRibbon", this.$promotedAttributesOpenInRibbon));
this.$editedNotesOpenInRibbon = this.$widget.find(".edited-notes-open-in-ribbon");
this.$editedNotesOpenInRibbon.on("change", () => this.updateCheckboxOption("editedNotesOpenInRibbon", this.$editedNotesOpenInRibbon));
}
async optionsLoaded(options: OptionMap) {
this.setCheckboxState(this.$promotedAttributesOpenInRibbon, options.promotedAttributesOpenInRibbon);
this.setCheckboxState(this.$editedNotesOpenInRibbon, options.editedNotesOpenInRibbon);
}
}

View File

@@ -1,112 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import utils from "../../../../services/utils.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("theme.layout")}</h4>
<div class="form-group row">
<div>
<label class="tn-radio">
<input type="radio" name="layout-orientation" value="vertical" />
<strong>${t("theme.layout-vertical-title")}</strong>
- ${t("theme.layout-vertical-description")}
</label>
</div>
<div>
<label class="tn-radio">
<input type="radio" name="layout-orientation" value="horizontal" />
<strong>${t("theme.layout-horizontal-title")}</strong>
- ${t("theme.layout-horizontal-description")}
</label>
</div>
</div>
</div>
<div class="options-section">
<h4>${t("theme.title")}</h4>
<div class="form-group row">
<div class="col-md-6">
<label for="theme-select">${t("theme.theme_label")}</label>
<select id="theme-select" class="theme-select form-select"></select>
</div>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="override-theme-fonts form-check-input">
${t("theme.override_theme_fonts_label")}
</label>
</div>
</div>
</div>
`;
interface Theme {
val: string;
title: string;
noteId?: string;
}
export default class ThemeOptions extends OptionsWidget {
private $themeSelect!: JQuery<HTMLElement>;
private $overrideThemeFonts!: JQuery<HTMLElement>;
private $layoutOrientation!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$themeSelect = this.$widget.find(".theme-select");
this.$overrideThemeFonts = this.$widget.find(".override-theme-fonts");
this.$layoutOrientation = this.$widget.find(`input[name="layout-orientation"]`).on("change", async () => {
const newLayoutOrientation = String(this.$widget.find(`input[name="layout-orientation"]:checked`).val());
await this.updateOption("layoutOrientation", newLayoutOrientation);
utils.reloadFrontendApp("layout orientation change");
});
const $layoutOrientationSection = $(this.$widget[0]);
$layoutOrientationSection.toggleClass("hidden-ext", utils.isMobile());
this.$themeSelect.on("change", async () => {
const newTheme = this.$themeSelect.val();
await server.put(`options/theme/${newTheme}`);
utils.reloadFrontendApp("theme change");
});
this.$overrideThemeFonts.on("change", () => this.updateCheckboxOption("overrideThemeFonts", this.$overrideThemeFonts));
}
async optionsLoaded(options: OptionMap) {
const themes: Theme[] = [
{ val: "next", title: t("theme.triliumnext") },
{ val: "next-light", title: t("theme.triliumnext-light") },
{ val: "next-dark", title: t("theme.triliumnext-dark") },
{ val: "auto", title: t("theme.auto_theme") },
{ val: "light", title: t("theme.light_theme") },
{ val: "dark", title: t("theme.dark_theme") }
].concat(await server.get<Theme[]>("options/user-themes"));
this.$themeSelect.empty();
for (const theme of themes) {
this.$themeSelect.append(
$("<option>")
.attr("value", theme.val)
.attr("data-note-id", theme.noteId || "")
.text(theme.title)
);
}
this.$themeSelect.val(options.theme);
this.setCheckboxState(this.$overrideThemeFonts, options.overrideThemeFonts);
this.$widget.find(`input[name="layout-orientation"][value="${options.layoutOrientation}"]`).prop("checked", "true");
}
}

View File

@@ -1,149 +0,0 @@
import { formatDateTime } from "../../../utils/formatters.js";
import { t } from "../../../services/i18n.js";
import OptionsWidget from "./options_widget.js";
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("backup.automatic_backup")}</h4>
<p>${t("backup.automatic_backup_description")}</p>
<ul style="list-style: none">
<li>
<label class="tn-checkbox">
<input type="checkbox" class="daily-backup-enabled form-check-input">
${t("backup.enable_daily_backup")}
</label>
</li>
<li>
<label class="tn-checkbox">
<input type="checkbox" class="weekly-backup-enabled form-check-input">
${t("backup.enable_weekly_backup")}
</label>
</li>
<li>
<label class="tn-checkbox">
<input type="checkbox" class="monthly-backup-enabled form-check-input">
${t("backup.enable_monthly_backup")}
</label>
</li>
</ul>
<p class="form-text">${t("backup.backup_recommendation")}</p>
</div>
<div class="options-section">
<h4>${t("backup.backup_now")}</h4>
<button class="backup-database-button btn btn-secondary">${t("backup.backup_database_now")}</button>
</div>
<div class="options-section">
<h4>${t("backup.existing_backups")}</h4>
<table class="table table-stripped">
<colgroup>
<col width="33%" />
<col />
</colgroup>
<thead>
<tr>
<th>${t("backup.date-and-time")}</th>
<th>${t("backup.path")}</th>
</tr>
</thead>
<tbody class="existing-backup-list-items">
</tbody>
</table>
</div>
`;
// TODO: Deduplicate.
interface PostDatabaseResponse {
backupFile: string;
}
// TODO: Deduplicate
interface Backup {
filePath: string;
mtime: number;
}
export default class BackupOptions extends OptionsWidget {
private $backupDatabaseButton!: JQuery<HTMLElement>;
private $dailyBackupEnabled!: JQuery<HTMLElement>;
private $weeklyBackupEnabled!: JQuery<HTMLElement>;
private $monthlyBackupEnabled!: JQuery<HTMLElement>;
private $existingBackupList!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$backupDatabaseButton = this.$widget.find(".backup-database-button");
this.$backupDatabaseButton.on("click", async () => {
const { backupFile } = await server.post<PostDatabaseResponse>("database/backup-database");
toastService.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
this.refresh();
});
this.$dailyBackupEnabled = this.$widget.find(".daily-backup-enabled");
this.$weeklyBackupEnabled = this.$widget.find(".weekly-backup-enabled");
this.$monthlyBackupEnabled = this.$widget.find(".monthly-backup-enabled");
this.$dailyBackupEnabled.on("change", () => this.updateCheckboxOption("dailyBackupEnabled", this.$dailyBackupEnabled));
this.$weeklyBackupEnabled.on("change", () => this.updateCheckboxOption("weeklyBackupEnabled", this.$weeklyBackupEnabled));
this.$monthlyBackupEnabled.on("change", () => this.updateCheckboxOption("monthlyBackupEnabled", this.$monthlyBackupEnabled));
this.$existingBackupList = this.$widget.find(".existing-backup-list-items");
}
optionsLoaded(options: OptionMap) {
this.setCheckboxState(this.$dailyBackupEnabled, options.dailyBackupEnabled);
this.setCheckboxState(this.$weeklyBackupEnabled, options.weeklyBackupEnabled);
this.setCheckboxState(this.$monthlyBackupEnabled, options.monthlyBackupEnabled);
server.get<Backup[]>("database/backups").then((backupFiles) => {
this.$existingBackupList.empty();
if (!backupFiles.length) {
this.$existingBackupList.append(
$(`
<tr>
<td class="empty-table-placeholder" colspan="2">${t("backup.no_backup_yet")}</td>
</tr>
`)
);
return;
}
// Sort the backup files by modification date & time in a desceding order
backupFiles.sort((a, b) => {
if (a.mtime < b.mtime) return 1;
if (a.mtime > b.mtime) return -1;
return 0;
});
for (const { filePath, mtime } of backupFiles) {
this.$existingBackupList.append(
$(`
<tr>
<td>${mtime ? formatDateTime(mtime) : "-"}</td>
<td>${filePath}</td>
</tr>
`)
);
}
});
}
}

View File

@@ -0,0 +1,119 @@
import { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup, { FormMultiGroup } from "../../react/FormGroup";
import FormText from "../../react/FormText";
import { useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import { useCallback, useEffect, useState } from "preact/hooks";
import { formatDateTime } from "../../../utils/formatters";
export default function BackupSettings() {
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
const refreshBackups = useCallback(() => {
server.get<DatabaseBackup[]>("database/backups").then((backupFiles) => {
// Sort the backup files by modification date & time in a desceding order
backupFiles.sort((a, b) => {
if (a.mtime < b.mtime) return 1;
if (a.mtime > b.mtime) return -1;
return 0;
});
setBackups(backupFiles);
});
}, [ setBackups ]);
useEffect(refreshBackups, []);
return (
<>
<AutomaticBackup />
<BackupNow refreshCallback={refreshBackups} />
<BackupList backups={backups} />
</>
)
}
export function AutomaticBackup() {
const [ dailyBackupEnabled, setDailyBackupEnabled ] = useTriliumOptionBool("dailyBackupEnabled");
const [ weeklyBackupEnabled, setWeeklyBackupEnabled ] = useTriliumOptionBool("weeklyBackupEnabled");
const [ monthlyBackupEnabled, setMonthlyBackupEnabled ] = useTriliumOptionBool("monthlyBackupEnabled");
return (
<OptionsSection title={t("backup.automatic_backup")}>
<FormMultiGroup label={t("backup.automatic_backup_description")}>
<FormCheckbox
name="daily-backup-enabled"
label={t("backup.enable_daily_backup")}
currentValue={dailyBackupEnabled} onChange={setDailyBackupEnabled}
/>
<FormCheckbox
name="weekly-backup-enabled"
label={t("backup.enable_weekly_backup")}
currentValue={weeklyBackupEnabled} onChange={setWeeklyBackupEnabled}
/>
<FormCheckbox
name="monthly-backup-enabled"
label={t("backup.enable_monthly_backup")}
currentValue={monthlyBackupEnabled} onChange={setMonthlyBackupEnabled}
/>
</FormMultiGroup>
<FormText>{t("backup.backup_recommendation")}</FormText>
</OptionsSection>
)
}
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
return (
<OptionsSection title={t("backup.backup_now")}>
<Button
text={t("backup.backup_database_now")}
onClick={async () => {
const { backupFile } = await server.post<BackupDatabaseNowResponse>("database/backup-database");
toast.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
refreshCallback();
}}
/>
</OptionsSection>
)
}
export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
return (
<OptionsSection title={t("backup.existing_backups")}>
<table class="table table-stripped">
<colgroup>
<col width="33%" />
<col />
</colgroup>
<thead>
<tr>
<th>{t("backup.date-and-time")}</th>
<th>{t("backup.path")}</th>
</tr>
</thead>
<tbody>
{ backups.length > 0 ? (
backups.map(({ mtime, filePath }) => (
<tr>
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
<td>{filePath}</td>
</tr>
))
) : (
<tr>
<td className="empty-table-placeholder" colspan={2}>{t("backup.no_backup_yet")}</td>
</tr>
)}
</tbody>
</table>
</OptionsSection>
);
}

View File

@@ -0,0 +1,164 @@
import CodeMirror, { ColorThemes, getThemeById } from "@triliumnext/codemirror";
import { t } from "../../../services/i18n";
import Column from "../../react/Column";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import FormSelect from "../../react/FormSelect";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import codeNoteSample from "./samples/code_note.txt?raw";
import { DEFAULT_PREFIX } from "../abstract_code_type_widget";
import { MimeType } from "@triliumnext/commons";
import mime_types from "../../../services/mime_types";
import CheckboxList from "./components/CheckboxList";
import AutoReadOnlySize from "./components/AutoReadOnlySize";
const SAMPLE_MIME = "application/typescript";
export default function CodeNoteSettings() {
return (
<>
<Editor />
<Appearance />
<CodeMimeTypes />
<AutoReadOnlySize option="autoReadonlySizeCode" label={t("code_auto_read_only_size.label")} />
</>
)
}
function Editor() {
const [ vimKeymapEnabled, setVimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
return (
<OptionsSection title={t("code-editor-options.title")}>
<FormGroup name="vim-keymap-enabled" description={t("vim_key_bindings.enable_vim_keybindings")}>
<FormCheckbox
label={t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
currentValue={vimKeymapEnabled} onChange={setVimKeymapEnabled}
/>
</FormGroup>
</OptionsSection>
)
}
function Appearance() {
const [ codeNoteTheme, setCodeNoteTheme ] = useTriliumOption("codeNoteTheme");
const [ codeLineWrapEnabled, setCodeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
const themes = useMemo(() => {
return ColorThemes.map(({ id, name }) => ({
id: "default:" + id,
name
}));
}, []);
return (
<OptionsSection title={t("code_theme.title")}>
<div className="row" style={{ marginBottom: "15px" }}>
<FormGroup name="color-scheme" label={t("code_theme.color-scheme")} className="col-md-6" style={{ marginBottom: 0 }}>
<FormSelect
values={themes}
keyProperty="id" titleProperty="name"
currentValue={codeNoteTheme} onChange={setCodeNoteTheme}
/>
</FormGroup>
<Column className="side-checkbox">
<FormCheckbox
name="word-wrap"
label={t("code_theme.word_wrapping")}
currentValue={codeLineWrapEnabled} onChange={setCodeLineWrapEnabled}
/>
</Column>
</div>
<CodeNotePreview wordWrapping={codeLineWrapEnabled} themeName={codeNoteTheme} />
</OptionsSection>
);
}
function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordWrapping: boolean }) {
const editorRef = useRef<CodeMirror>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
return;
}
// Clean up previous instance.
editorRef.current?.destroy();
containerRef.current.innerHTML = "";
// Set up a new instance.
const editor = new CodeMirror({
parent: containerRef.current
});
editor.setText(codeNoteSample);
editor.setMimeType(SAMPLE_MIME);
editorRef.current = editor;
}, []);
useEffect(() => {
editorRef.current?.setLineWrapping(wordWrapping);
}, [ wordWrapping ]);
useEffect(() => {
if (themeName?.startsWith(DEFAULT_PREFIX)) {
const theme = getThemeById(themeName.substring(DEFAULT_PREFIX.length));
if (theme) {
editorRef.current?.setTheme(theme);
}
}
}, [ themeName ]);
return (
<div
ref={containerRef}
class="note-detail-readonly-code-content"
style={{ margin: 0, height: "200px" }}
/>
);
}
function CodeMimeTypes() {
const [ codeNotesMimeTypes, setCodeNotesMimeTypes ] = useTriliumOptionJson<string[]>("codeNotesMimeTypes");
const sectionStyle = useMemo(() => ({ marginBottom: "1em", breakInside: "avoid-column" }), []);
const groupedMimeTypes: Record<string, MimeType[]> = useMemo(() => {
mime_types.loadMimeTypes();
const ungroupedMimeTypes = Array.from(mime_types.getMimeTypes());
const plainTextMimeType = ungroupedMimeTypes.shift();
const result: Record<string, MimeType[]> = {};
ungroupedMimeTypes.sort((a, b) => a.title.localeCompare(b.title));
result[""] = [ plainTextMimeType! ];
for (const mimeType of ungroupedMimeTypes) {
const initial = mimeType.title.charAt(0).toUpperCase();
if (!result[initial]) {
result[initial] = [];
}
result[initial].push(mimeType);
}
return result;
}, []);
return (
<OptionsSection title={t("code_mime_types.title")}>
<ul class="options-mime-types" style={{ listStyleType: "none", columnWidth: "250px" }}>
{Object.entries(groupedMimeTypes).map(([ initial, mimeTypes ]) => (
<section style={sectionStyle}>
{ initial && <h5>{initial}</h5> }
<CheckboxList
values={mimeTypes}
keyProperty="mime" titleProperty="title"
currentValue={codeNotesMimeTypes} onChange={setCodeNotesMimeTypes}
columnWidth="inherit"
/>
</section>
))}
</ul>
</OptionsSection>
)
}

View File

@@ -1,33 +0,0 @@
import type { OptionMap } from "@triliumnext/commons";
import { t } from "../../../../services/i18n.js";
import OptionsWidget from "../options_widget.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("code_auto_read_only_size.title")}</h4>
<p class="form-text">${t("code_auto_read_only_size.description")}</p>
<div class="form-group">
<label for="auto-readonly-size-code">${t("code_auto_read_only_size.label")}</label>
<label class="input-group tn-number-unit-pair">
<input id="auto-readonly-size-code" class="auto-readonly-size-code form-control options-number-input" type="number" min="0">
<span class="input-group-text">${t("code_auto_read_only_size.unit")}</span>
</label>
</div>
</div>`;
export default class CodeAutoReadOnlySizeOptions extends OptionsWidget {
private $autoReadonlySizeCode!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$autoReadonlySizeCode = this.$widget.find(".auto-readonly-size-code");
this.$autoReadonlySizeCode.on("change", () => this.updateOption("autoReadonlySizeCode", this.$autoReadonlySizeCode.val()));
}
async optionsLoaded(options: OptionMap) {
this.$autoReadonlySizeCode.val(options.autoReadonlySizeCode);
}
}

View File

@@ -1,28 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("code-editor-options.title")}</h4>
<label class="tn-checkbox">
<input type="checkbox" class="vim-keymap-enabled form-check-input">
${t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
</label>
<p class="form-text">${t("vim_key_bindings.enable_vim_keybindings")}</p>
</div>`;
export default class CodeEditorOptions extends OptionsWidget {
private $vimKeymapEnabled!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$vimKeymapEnabled = this.$widget.find(".vim-keymap-enabled");
this.$vimKeymapEnabled.on("change", () => this.updateCheckboxOption("vimKeymapEnabled", this.$vimKeymapEnabled));
}
async optionsLoaded(options: OptionMap) {
this.setCheckboxState(this.$vimKeymapEnabled, options.vimKeymapEnabled);
}
}

View File

@@ -1,104 +0,0 @@
import { t } from "../../../../services/i18n.js";
import OptionsWidget from "../options_widget.js";
import mimeTypesService from "../../../../services/mime_types.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("code_mime_types.title")}</h4>
<ul class="options-mime-types" style="list-style-type: none;"></ul>
</div>
<style>
.options-mime-types section,
.options-mime-types > li:first-of-type {
margin-bottom: 1em;
}
</style>
`;
let idCtr = 1; // global, since this can be shown in multiple dialogs
interface MimeType {
title: string;
mime: string;
enabled: boolean;
}
type GroupedMimes = Record<string, MimeType[]>;
function groupMimeTypesAlphabetically(ungroupedMimeTypes: MimeType[]) {
const result: GroupedMimes = {};
ungroupedMimeTypes = ungroupedMimeTypes.toSorted((a, b) => a.title.localeCompare(b.title));
for (const mimeType of ungroupedMimeTypes) {
const initial = mimeType.title.charAt(0).toUpperCase();
if (!result[initial]) {
result[initial] = [];
}
result[initial].push(mimeType);
}
return result;
}
export default class CodeMimeTypesOptions extends OptionsWidget {
private $mimeTypes!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$mimeTypes = this.$widget.find(".options-mime-types");
}
async optionsLoaded(options: OptionMap) {
this.$mimeTypes.empty();
mimeTypesService.loadMimeTypes();
const ungroupedMimeTypes = Array.from(mimeTypesService.getMimeTypes());
const plainTextMimeType = ungroupedMimeTypes.shift();
const groupedMimeTypes = groupMimeTypesAlphabetically(ungroupedMimeTypes);
// Plain text is displayed at the top intentionally.
if (plainTextMimeType) {
const $plainEl = this.#buildSelectionForMimeType(plainTextMimeType);
$plainEl.find("input").attr("disabled", "");
this.$mimeTypes.append($plainEl);
}
for (const [initial, mimeTypes] of Object.entries(groupedMimeTypes)) {
const $section = $("<section>");
$section.append($("<h5>").text(initial));
for (const mimeType of mimeTypes) {
$section.append(this.#buildSelectionForMimeType(mimeType));
}
this.$mimeTypes.append($section);
}
}
async save() {
const enabledMimeTypes: string[] = [];
this.$mimeTypes.find("input:checked").each((i, el) => {
const mimeType = this.$widget.find(el).attr("data-mime-type");
if (mimeType) {
enabledMimeTypes.push(mimeType);
}
});
await this.updateOption("codeNotesMimeTypes", JSON.stringify(enabledMimeTypes));
}
#buildSelectionForMimeType(mimeType: MimeType) {
const id = "code-mime-type-" + idCtr++;
const checkbox = $(`<label class="tn-checkbox">`)
.append($('<input type="checkbox" class="form-check-input">').attr("id", id).attr("data-mime-type", mimeType.mime).prop("checked", mimeType.enabled))
.on("change", () => this.save())
.append(mimeType.title);
return $("<li>").append(checkbox);
}
}

View File

@@ -1,173 +0,0 @@
import type { OptionMap } from "@triliumnext/commons";
import OptionsWidget from "../options_widget";
import server from "../../../../services/server";
import CodeMirror, { getThemeById } from "@triliumnext/codemirror";
import { DEFAULT_PREFIX } from "../../abstract_code_type_widget";
import { t } from "../../../../services/i18n";
import { ColorThemes } from "@triliumnext/codemirror";
// TODO: Deduplicate
interface Theme {
title: string;
val: string;
}
type Response = Theme[];
const SAMPLE_MIME = "application/typescript";
const SAMPLE_CODE = `\
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter } from "@codemirror/language";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import { highlightSelectionMatches } from "@codemirror/search";
import { vim } from "@replit/codemirror-vim";
import byMimeType from "./syntax_highlighting.js";
import smartIndentWithTab from "./extensions/custom_tab.js";
import type { ThemeDefinition } from "./color_themes.js";
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
type ContentChangedListener = () => void;
export interface EditorConfig {
parent: HTMLElement;
placeholder?: string;
lineWrapping?: boolean;
vimKeybindings?: boolean;
readOnly?: boolean;
onContentChanged?: ContentChangedListener;
}
export default class CodeMirror extends EditorView {
private config: EditorConfig;
private languageCompartment: Compartment;
private historyCompartment: Compartment;
private themeCompartment: Compartment;
constructor(config: EditorConfig) {
const languageCompartment = new Compartment();
const historyCompartment = new Compartment();
const themeCompartment = new Compartment();
let extensions: Extension[] = [];
if (config.vimKeybindings) {
extensions.push(vim());
}
extensions = [
...extensions,
languageCompartment.of([]),
themeCompartment.of([
syntaxHighlighting(defaultHighlightStyle, { fallback: true })
]),
highlightActiveLine(),
highlightSelectionMatches(),
bracketMatching(),
lineNumbers(),
foldGutter(),
indentUnit.of(" ".repeat(4)),
keymap.of([
...defaultKeymap,
...historyKeymap,
...smartIndentWithTab
])
]
super({
parent: config.parent,
extensions
});
}
}`;
const TPL = /*html*/`\
<div class="options-section">
<h4>${t("code_theme.title")}</h4>
<div class="form-group row">
<div class="col-md-6">
<label for="color-theme">${t("code_theme.color-scheme")}</label>
<select id="color-theme" class="theme-select form-select"></select>
</div>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="word-wrap form-check-input" />
${t("code_theme.word_wrapping")}
</label>
</div>
</div>
<div class="note-detail-readonly-code-content">
</div>
<style>
.options-section .note-detail-readonly-code-content {
margin: 0;
}
.options-section .note-detail-readonly-code-content .cm-editor {
height: 200px;
}
</style>
</div>
`;
export default class CodeTheme extends OptionsWidget {
private $themeSelect!: JQuery<HTMLElement>;
private $sampleEl!: JQuery<HTMLElement>;
private $lineWrapEnabled!: JQuery<HTMLElement>;
private editor?: CodeMirror;
doRender() {
this.$widget = $(TPL);
this.$themeSelect = this.$widget.find(".theme-select");
this.$themeSelect.on("change", async () => {
const newTheme = String(this.$themeSelect.val());
await server.put(`options/codeNoteTheme/${newTheme}`);
});
// Populate the list of themes.
for (const theme of ColorThemes) {
const option = $("<option>")
.attr("value", `default:${theme.id}`)
.text(theme.name);
this.$themeSelect.append(option);
}
this.$sampleEl = this.$widget.find(".note-detail-readonly-code-content");
this.$lineWrapEnabled = this.$widget.find(".word-wrap");
this.$lineWrapEnabled.on("change", () => this.updateCheckboxOption("codeLineWrapEnabled", this.$lineWrapEnabled));
}
async #setupPreview(options: OptionMap) {
if (!this.editor) {
this.editor = new CodeMirror({
parent: this.$sampleEl[0],
});
}
this.editor.setText(SAMPLE_CODE);
this.editor.setMimeType(SAMPLE_MIME);
this.editor.setLineWrapping(options.codeLineWrapEnabled === "true");
// Load the theme.
const themeId = options.codeNoteTheme;
if (themeId?.startsWith(DEFAULT_PREFIX)) {
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
if (theme) {
await this.editor.setTheme(theme);
}
}
}
async optionsLoaded(options: OptionMap) {
this.$themeSelect.val(options.codeNoteTheme);
this.#setupPreview(options);
this.setCheckboxState(this.$lineWrapEnabled, options.codeLineWrapEnabled);
}
}

View File

@@ -0,0 +1,30 @@
import { OptionNames } from "@triliumnext/commons";
import FormText from "../../../react/FormText";
import { FormTextBoxWithUnit } from "../../../react/FormTextBox";
import OptionsSection from "./OptionsSection";
import { useTriliumOption } from "../../../react/hooks";
import { t } from "../../../../services/i18n";
import FormGroup from "../../../react/FormGroup";
interface AutoReadOnlySizeProps {
label: string;
option: OptionNames;
}
export default function AutoReadOnlySize({ label, option }: AutoReadOnlySizeProps) {
const [ autoReadonlyOpt, setAutoReadonlyOpt ] = useTriliumOption(option);
return (
<OptionsSection title={t("text_auto_read_only_size.title")}>
<FormText>{t("text_auto_read_only_size.description")}</FormText>
<FormGroup name="auto-readonly-size-text" label={label}>
<FormTextBoxWithUnit
type="number" min={0}
unit={t("text_auto_read_only_size.unit")}
currentValue={autoReadonlyOpt} onChange={setAutoReadonlyOpt}
/>
</FormGroup>
</OptionsSection>
)
}

View File

@@ -0,0 +1,39 @@
interface CheckboxListProps<T> {
values: T[];
keyProperty: keyof T;
titleProperty?: keyof T;
currentValue: string[];
onChange: (newValues: string[]) => void;
columnWidth?: string;
}
export default function CheckboxList<T>({ values, keyProperty, titleProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
function toggleValue(value: string) {
if (currentValue.includes(value)) {
// Already there, needs removing.
onChange(currentValue.filter(v => v !== value));
} else {
// Not there, needs adding.
onChange([ ...currentValue, value ]);
}
}
return (
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
{values.map(value => (
<li>
<label className="tn-checkbox">
<input
type="checkbox"
className="form-check-input"
value={String(value[keyProperty])}
checked={currentValue.includes(String(value[keyProperty]))}
onChange={e => toggleValue((e.target as HTMLInputElement).value)}
/>
{String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
</label>
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,23 @@
.option-row {
border-bottom: 1px solid var(--main-border-color);
display: flex;
align-items: center;
padding: 0.5em 0;
}
.option-row > label {
width: 40%;
margin-bottom: 0 !important;
}
.option-row > select {
width: 60%;
}
.option-row:last-of-type {
border-bottom: unset;
}
.option-row.centered {
justify-content: center;
}

View File

@@ -0,0 +1,22 @@
import { cloneElement, VNode } from "preact";
import "./OptionsRow.css";
import { useUniqueName } from "../../../react/hooks";
interface OptionsRowProps {
name: string;
label?: string;
children: VNode;
centered?: boolean;
}
export default function OptionsRow({ name, label, children, centered }: OptionsRowProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
return (
<div className={`option-row ${centered ? "centered" : ""}`}>
{label && <label for={id}>{label}</label>}
{childWithId}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
interface OptionsSectionProps {
title?: string;
children: ComponentChildren;
noCard?: boolean;
style?: CSSProperties;
className?: string;
}
export default function OptionsSection({ title, children, noCard, className, ...rest }: OptionsSectionProps) {
return (
<div className={`options-section ${noCard && "tn-no-card"} ${className ?? ""}`} {...rest}>
{title && <h4>{title}</h4>}
{children}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import OptionsSection from "./OptionsSection";
import type { OptionPages } from "../../content_widget";
import { t } from "../../../../services/i18n";
interface RelatedSettingsProps {
items: {
title: string;
targetPage: OptionPages;
}[];
}
export default function RelatedSettings({ items }: RelatedSettingsProps) {
return (
<OptionsSection title={t("settings.related_settings")}>
<nav className="use-tn-links" style={{ padding: 0, margin: 0, listStyleType: "none" }}>
{items.map(item => (
<li>
<a href={`#root/_hidden/_options/${item.targetPage}`}>{item.title}</a>
</li>
))}
</nav>
</OptionsSection>
);
}

View File

@@ -0,0 +1,99 @@
import { OptionDefinitions } from "@triliumnext/commons";
import FormGroup from "../../../react/FormGroup";
import FormTextBox from "../../../react/FormTextBox";
import FormSelect from "../../../react/FormSelect";
import { useEffect, useMemo, useState } from "preact/hooks";
import { t } from "../../../../services/i18n";
import { useTriliumOption } from "../../../react/hooks";
import toast from "../../../../services/toast";
type TimeSelectorScale = "seconds" | "minutes" | "hours" | "days";
interface TimeSelectorProps {
id?: string;
name: string;
optionValueId: keyof OptionDefinitions;
optionTimeScaleId: keyof OptionDefinitions;
includedTimeScales?: Set<TimeSelectorScale>;
minimumSeconds?: number;
}
interface TimeScaleInfo {
value: string;
unit: string;
}
export default function TimeSelector({ id, name, includedTimeScales, optionValueId, optionTimeScaleId, minimumSeconds }: TimeSelectorProps) {
const values = useMemo(() => {
const values: TimeScaleInfo[] = [];
const timeScalesWithDefault = includedTimeScales ?? new Set(["seconds", "minutes", "hours", "days"]);
if (timeScalesWithDefault.has("seconds")) {
values.push({ value: "1", unit: t("duration.seconds") });
values.push({ value: "60", unit: t("duration.minutes") });
values.push({ value: "3600", unit: t("duration.hours") });
values.push({ value: "86400", unit: t("duration.days") });
}
return values;
}, [ includedTimeScales ]);
const [ value, setValue ] = useTriliumOption(optionValueId);
const [ scale, setScale ] = useTriliumOption(optionTimeScaleId);
const [ displayedTime, setDisplayedTime ] = useState("");
// React to changes in scale and value.
useEffect(() => {
const newTime = convertTime(parseInt(value, 10), scale).toDisplay();
setDisplayedTime(String(newTime));
}, [ value, scale ]);
return (
<div class="d-flex gap-2">
<FormTextBox
id={id}
name={name}
type="number" min={0} step={1} required
currentValue={displayedTime} onChange={(value, validity) => {
if (!validity.valid) {
toast.showError(t("time_selector.invalid_input"));
return false;
}
let time = parseInt(value, 10);
const minimumSecondsOrDefault = (minimumSeconds ?? 0);
const newTime = convertTime(time, scale).toOption();
if (Number.isNaN(time) || newTime < (minimumSecondsOrDefault)) {
toast.showError(t("time_selector.minimum_input", { minimumSeconds: minimumSecondsOrDefault }));
time = minimumSecondsOrDefault;
}
setValue(newTime);
}}
/>
<FormSelect
values={values}
keyProperty="value" titleProperty="unit"
style={{ width: "auto" }}
currentValue={scale} onChange={setScale}
/>
</div>
)
}
function convertTime(value: number, timeScale: string | number) {
if (Number.isNaN(value)) {
throw new Error(`Time needs to be a valid integer, but received: ${value}`);
}
const operand = typeof timeScale === "number" ? timeScale : parseInt(timeScale);
if (Number.isNaN(operand) || operand < 1) {
throw new Error(`TimeScale needs to be a valid integer >= 1, but received: ${timeScale}`);
}
return {
toOption: () => Math.ceil(value * operand),
toDisplay: () => Math.ceil(value / operand)
};
}

View File

@@ -1,157 +0,0 @@
import { formatDateTime } from "../../../utils/formatters.js";
import { t } from "../../../services/i18n.js";
import dialogService from "../../../services/dialog.js";
import OptionsWidget from "./options_widget.js";
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
const TPL = /*html*/`
<div class="etapi-options-section options-section">
<h4>${t("etapi.title")}</h4>
<p class="form-text">${t("etapi.description")} <br/>
${t("etapi.see_more", {
link_to_wiki: `<a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/etapi.html">${t("etapi.wiki")}</a>`,
// TODO: We use window.open src/public/app/services/link.ts -> prevents regular click behavior on "a" element here because it's a relative path
link_to_openapi_spec: `<a class="tn-link" onclick="window.open('etapi/etapi.openapi.yaml')" href="etapi/etapi.openapi.yaml">${t("etapi.openapi_spec")}</a>`,
link_to_swagger_ui: `<a class="tn-link" href="#_help_f3xpgx6H01PW">${t("etapi.swagger_ui")}</a>`
})}
</p>
<button type="button" class="create-etapi-token btn btn-sm">
<span class="bx bx-plus"></span>
${t("etapi.create_token")}
</button>
<hr />
<h5>${t("etapi.existing_tokens")}</h5>
<div class="no-tokens-yet">${t("etapi.no_tokens_yet")}</div>
<div style="overflow: auto; height: 500px;">
<table class="tokens-table table table-stripped">
<thead>
<tr>
<th>${t("etapi.token_name")}</th>
<th>${t("etapi.created")}</th>
<th>${t("etapi.actions")}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<style>
.token-table-button {
display: inline-block;
cursor: pointer;
padding: 3px;
margin-right: 20px;
font-size: large;
border: 1px solid transparent;
border-radius: var(--button-border-radius);
}
.token-table-button:hover {
border: 1px solid var(--button-border-color);
}
</style>`;
// TODO: Deduplicate
interface PostTokensResponse {
authToken: string;
}
// TODO: Deduplicate
interface Token {
name: string;
utcDateCreated: number;
etapiTokenId: string;
}
export default class EtapiOptions extends OptionsWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.find(".create-etapi-token").on("click", async () => {
const tokenName = await dialogService.prompt({
title: t("etapi.new_token_title"),
message: t("etapi.new_token_message"),
defaultValue: t("etapi.default_token_name")
});
if (!tokenName?.trim()) {
toastService.showError(t("etapi.error_empty_name"));
return;
}
const { authToken } = await server.post<PostTokensResponse>("etapi-tokens", { tokenName });
await dialogService.prompt({
title: t("etapi.token_created_title"),
message: t("etapi.token_created_message"),
defaultValue: authToken
});
this.refreshTokens();
});
this.refreshTokens();
}
async refreshTokens() {
const $noTokensYet = this.$widget.find(".no-tokens-yet");
const $tokensTable = this.$widget.find(".tokens-table");
const tokens = await server.get<Token[]>("etapi-tokens");
$noTokensYet.toggle(tokens.length === 0);
$tokensTable.toggle(tokens.length > 0);
const $tokensTableBody = $tokensTable.find("tbody");
$tokensTableBody.empty();
for (const token of tokens) {
$tokensTableBody.append(
$("<tr>")
.append($("<td>").text(token.name))
.append($("<td>").text(formatDateTime(token.utcDateCreated)))
.append(
$("<td>").append(
$(`<span class="bx bx-edit-alt token-table-button icon-action" title="${t("etapi.rename_token")}"></span>`).on("click", () => this.renameToken(token.etapiTokenId, token.name)),
$(`<span class="bx bx-trash token-table-button icon-action" title="${t("etapi.delete_token")}"></span>`).on("click", () => this.deleteToken(token.etapiTokenId, token.name))
)
)
);
}
}
async renameToken(etapiTokenId: string, oldName: string) {
const tokenName = await dialogService.prompt({
title: t("etapi.rename_token_title"),
message: t("etapi.rename_token_message"),
defaultValue: oldName
});
if (!tokenName?.trim()) {
return;
}
await server.patch(`etapi-tokens/${etapiTokenId}`, { name: tokenName });
this.refreshTokens();
}
async deleteToken(etapiTokenId: string, name: string) {
if (!(await dialogService.confirm(t("etapi.delete_token_confirmation", { name })))) {
return;
}
await server.remove(`etapi-tokens/${etapiTokenId}`);
this.refreshTokens();
}
}

View File

@@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import RawHtml from "../../react/RawHtml";
import OptionsSection from "./components/OptionsSection";
import { EtapiToken, PostTokensResponse } from "@triliumnext/commons";
import server from "../../../services/server";
import toast from "../../../services/toast";
import dialog from "../../../services/dialog";
import { formatDateTime } from "../../../utils/formatters";
import ActionButton from "../../react/ActionButton";
import useTriliumEvent from "../../react/hooks";
type RenameTokenCallback = (tokenId: string, oldName: string) => Promise<void>;
type DeleteTokenCallback = (tokenId: string, name: string ) => Promise<void>;
export default function EtapiSettings() {
const [ tokens, setTokens ] = useState<EtapiToken[]>([]);
function refreshTokens() {
server.get<EtapiToken[]>("etapi-tokens").then(setTokens);
}
useEffect(refreshTokens, []);
useTriliumEvent("entitiesReloaded", ({loadResults}) => {
if (loadResults.hasEtapiTokenChanges) {
refreshTokens();
}
});
const createTokenCallback = useCallback(async () => {
const tokenName = await dialog.prompt({
title: t("etapi.new_token_title"),
message: t("etapi.new_token_message"),
defaultValue: t("etapi.default_token_name")
});
if (!tokenName?.trim()) {
toast.showError(t("etapi.error_empty_name"));
return;
}
const { authToken } = await server.post<PostTokensResponse>("etapi-tokens", { tokenName });
await dialog.prompt({
title: t("etapi.token_created_title"),
message: t("etapi.token_created_message"),
defaultValue: authToken
});
}, []);
return (
<OptionsSection title={t("etapi.title")}>
<FormText>
{t("etapi.description")}<br />
<RawHtml
html={t("etapi.see_more", {
link_to_wiki: `<a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/etapi.html">${t("etapi.wiki")}</a>`,
// TODO: We use window.open src/public/app/services/link.ts -> prevents regular click behavior on "a" element here because it's a relative path
link_to_openapi_spec: `<a class="tn-link" onclick="window.open('etapi/etapi.openapi.yaml')" href="etapi/etapi.openapi.yaml">${t("etapi.openapi_spec")}</a>`,
link_to_swagger_ui: `<a class="tn-link" href="#_help_f3xpgx6H01PW">${t("etapi.swagger_ui")}</a>`
})} />
</FormText>
<Button
size="small" icon="bx bx-plus"
text={t("etapi.create_token")}
onClick={createTokenCallback}
/>
<hr />
<h5>{t("etapi.existing_tokens")}</h5>
<TokenList tokens={tokens} />
</OptionsSection>
)
}
function TokenList({ tokens }: { tokens: EtapiToken[] }) {
if (!tokens.length) {
return <div>{t("etapi.no_tokens_yet")}</div>;
}
const renameCallback = useCallback<RenameTokenCallback>(async (tokenId: string, oldName: string) => {
const tokenName = await dialog.prompt({
title: t("etapi.rename_token_title"),
message: t("etapi.rename_token_message"),
defaultValue: oldName
});
if (!tokenName?.trim()) {
return;
}
await server.patch(`etapi-tokens/${tokenId}`, { name: tokenName });
}, []);
const deleteCallback = useCallback<DeleteTokenCallback>(async (tokenId: string, name: string) => {
if (!(await dialog.confirm(t("etapi.delete_token_confirmation", { name })))) {
return;
}
await server.remove(`etapi-tokens/${tokenId}`);
}, []);
return (
<div style={{ overflow: "auto", height: "500px"}}>
<table className="table table-stripped">
<thead>
<tr>
<th>{t("etapi.token_name")}</th>
<th>{t("etapi.created")}</th>
<th>{t("etapi.actions")}</th>
</tr>
</thead>
<tbody>
{tokens.map(({ etapiTokenId, name, utcDateCreated}) => (
<tr>
<td>{name}</td>
<td>{formatDateTime(utcDateCreated)}</td>
<td>
<ActionButton
icon="bx bx-edit-alt"
text={t("etapi.rename_token")}
onClick={() => renameCallback(etapiTokenId!, name)}
/>
<ActionButton
icon="bx bx-trash"
text={t("etapi.delete_token")}
onClick={() => deleteCallback(etapiTokenId!, name)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,135 @@
import { useMemo } from "preact/hooks";
import { getAvailableLocales, t } from "../../../services/i18n";
import FormSelect from "../../react/FormSelect";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import { useTriliumOption, useTriliumOptionInt, useTriliumOptionJson } from "../../react/hooks";
import type { Locale } from "@triliumnext/commons";
import { isElectron, restartDesktopApp } from "../../../services/utils";
import FormRadioGroup, { FormInlineRadioGroup } from "../../react/FormRadioGroup";
import FormText from "../../react/FormText";
import RawHtml from "../../react/RawHtml";
import Admonition from "../../react/Admonition";
import Button from "../../react/Button";
import CheckboxList from "./components/CheckboxList";
export default function InternationalizationOptions() {
return (
<>
<LocalizationOptions />
<ContentLanguages />
</>
)
}
function LocalizationOptions() {
const { uiLocales, formattingLocales: contentLocales } = useMemo(() => {
const allLocales = getAvailableLocales();
return {
uiLocales: allLocales.filter(locale => !locale.contentOnly),
formattingLocales: allLocales.filter(locale => locale.electronLocale),
}
}, []);
const [ locale, setLocale ] = useTriliumOption("locale");
const [ formattingLocale, setFormattingLocale ] = useTriliumOption("formattingLocale");
return (
<OptionsSection title={t("i18n.title")}>
<OptionsRow name="language" label={t("i18n.language")}>
<LocaleSelector locales={uiLocales} currentValue={locale} onChange={setLocale} />
</OptionsRow>
{isElectron() && <OptionsRow name="formatting-locale" label={t("i18n.formatting-locale")}>
<LocaleSelector locales={contentLocales} currentValue={formattingLocale} onChange={setFormattingLocale} />
</OptionsRow>}
<DateSettings />
</OptionsSection>
)
}
function LocaleSelector({ id, locales, currentValue, onChange }: { id?: string; locales: Locale[], currentValue: string, onChange: (newLocale: string) => void }) {
return <FormSelect
id={id}
values={locales}
keyProperty="id" titleProperty="name"
currentValue={currentValue} onChange={onChange}
/>;
}
function DateSettings() {
const [ firstDayOfWeek, setFirstDayOfWeek ] = useTriliumOption("firstDayOfWeek");
const [ firstWeekOfYear, setFirstWeekOfYear ] = useTriliumOption("firstWeekOfYear");
const [ minDaysInFirstWeek, setMinDaysInFirstWeek ] = useTriliumOption("minDaysInFirstWeek");
return (
<>
<OptionsRow name="first-day-of-week" label={t("i18n.first-day-of-the-week")}>
<FormInlineRadioGroup
name="first-day-of-week"
values={[
{ value: "0", label: t("i18n.sunday") },
{ value: "1", label: t("i18n.monday") }
]}
currentValue={firstDayOfWeek} onChange={setFirstDayOfWeek}
/>
</OptionsRow>
<OptionsRow name="first-week-of-year" label={t("i18n.first-week-of-the-year")}>
<FormRadioGroup
name="first-week-of-year"
currentValue={firstWeekOfYear} onChange={setFirstWeekOfYear}
values={[
{ value: "0", label: t("i18n.first-week-contains-first-day") },
{ value: "1", label: t("i18n.first-week-contains-first-thursday") },
{ value: "2", label: t("i18n.first-week-has-minimum-days") }
]}
/>
</OptionsRow>
{firstWeekOfYear === "2" && <OptionsRow name="min-days-in-first-week" label={t("i18n.min-days-in-first-week")}>
<FormSelect
keyProperty="days"
currentValue={minDaysInFirstWeek} onChange={setMinDaysInFirstWeek}
values={Array.from(
{ length: 7 },
(_, i) => ({ days: String(i + 1) }))} />
</OptionsRow>}
<FormText>
<RawHtml html={t("i18n.first-week-info")} />
</FormText>
<Admonition type="warning">
{t("i18n.first-week-warning")}
</Admonition>
<OptionsRow name="restart" centered>
<Button
name="restart-app-button"
text={t("electron_integration.restart-app-button")}
size="micro"
onClick={restartDesktopApp}
/>
</OptionsRow>
</>
)
}
function ContentLanguages() {
const locales = useMemo(() => getAvailableLocales(), []);
const [ languages, setLanguages ] = useTriliumOptionJson<string[]>("languages");
return (
<OptionsSection title={t("content_language.title")}>
<FormText>{t("content_language.description")}</FormText>
<CheckboxList
values={locales}
keyProperty="id" titleProperty="name"
currentValue={languages} onChange={setLanguages}
/>
</OptionsSection>
);
}

View File

@@ -1,204 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import utils from "../../../../services/utils.js";
import { getAvailableLocales, t } from "../../../../services/i18n.js";
import type { OptionMap, Locale } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("i18n.title")}</h4>
<div class="locale-options-container">
<div class="option-row">
<label for="locale-select">${t("i18n.language")}</label>
<select id="locale-select" class="locale-select form-select"></select>
</div>
<div class="option-row electron-only">
<label for="formatting-locale-select">${t("i18n.formatting-locale")}</label>
<select id="formatting-locale-select" class="formatting-locale-select form-select"></select>
</div>
<div class="option-row">
<label id="first-day-of-week-label">${t("i18n.first-day-of-the-week")}</label>
<div role="group" aria-labelledby="first-day-of-week-label">
<label class="tn-radio">
<input name="first-day-of-week" type="radio" value="0" />
${t("i18n.sunday")}
</label>
<label class="tn-radio">
<input name="first-day-of-week" type="radio" value="1" />
${t("i18n.monday")}
</label>
</div>
</div>
<div class="option-row">
<label id="first-week-of-year-label">${t("i18n.first-week-of-the-year")}</label>
<div role="group" aria-labelledby="first-week-of-year-label">
<label class="tn-radio">
<input name="first-week-of-year" type="radio" value="0" />
${t("i18n.first-week-contains-first-day")}
</label>
<label class="tn-radio">
<input name="first-week-of-year" type="radio" value="1" />
${t("i18n.first-week-contains-first-thursday")}
</label>
<label class="tn-radio">
<input name="first-week-of-year" type="radio" value="2" />
${t("i18n.first-week-has-minimum-days")}
</label>
</div>
</div>
<div class="option-row min-days-row" style="display: none;">
<label for="min-days-in-first-week">${t("i18n.min-days-in-first-week")}</label>
<select id="min-days-in-first-week" class="form-select">
${Array.from({ length: 7 }, (_, i) => i + 1)
.map(num => `<option value="${num}">${num}</option>`)
.join('')}
</select>
</div>
<p class="form-text use-tn-links">${t("i18n.first-week-info")}</p>
<div class="admonition warning" role="alert">
${t("i18n.first-week-warning")}
</div>
<div class="option-row centered">
<button class="btn btn-secondary btn-micro restart-app-button">${t("electron_integration.restart-app-button")}</button>
</div>
</div>
<style>
.locale-options-container .option-row {
border-bottom: 1px solid var(--main-border-color);
display: flex;
align-items: center;
padding: 0.5em 0;
}
.locale-options-container .option-row > label {
width: 40%;
margin-bottom: 0 !important;
}
.locale-options-container .option-row > select {
width: 60%;
}
.locale-options-container .option-row:last-of-type {
border-bottom: unset;
}
.locale-options-container .option-row.centered {
justify-content: center;
}
.locale-options-container .option-row [aria-labelledby="first-week-of-year-label"] {
display: flex;
flex-direction: column;
}
.locale-options-container .option-row [aria-labelledby="first-week-of-year-label"] .tn-radio {
margin-left: 0;
white-space: nowrap;
}
</style>
</div>
`;
export default class LocalizationOptions extends OptionsWidget {
private $localeSelect!: JQuery<HTMLElement>;
private $formattingLocaleSelect!: JQuery<HTMLElement>;
private $minDaysRow!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$minDaysRow = this.$widget.find(".min-days-row");
this.$localeSelect = this.$widget.find(".locale-select");
this.$localeSelect.on("change", async () => {
const newLocale = this.$localeSelect.val();
await server.put(`options/locale/${newLocale}`);
});
this.$formattingLocaleSelect = this.$widget.find(".formatting-locale-select");
this.$formattingLocaleSelect.on("change", async () => {
const newLocale = this.$formattingLocaleSelect.val();
await server.put(`options/formattingLocale/${newLocale}`);
});
this.$widget.find(`input[name="first-day-of-week"]`).on("change", () => {
const firstDayOfWeek = String(this.$widget.find(`input[name="first-day-of-week"]:checked`).val());
this.updateOption("firstDayOfWeek", firstDayOfWeek);
});
this.$widget.find('input[name="first-week-of-year"]').on('change', (e) => {
const target = e.target as HTMLInputElement;
const value = parseInt(target.value);
if (value === 2) {
this.$minDaysRow.show();
} else {
this.$minDaysRow.hide();
}
this.updateOption("firstWeekOfYear", value);
});
const currentValue = this.$widget.find('input[name="first-week-of-year"]:checked').val();
if (currentValue === 2) {
this.$minDaysRow.show();
}
this.$widget.find("#min-days-in-first-week").on("change", () => {
const minDays = this.$widget.find("#min-days-in-first-week").val();
this.updateOption("minDaysInFirstWeek", minDays);
});
this.$widget.find(".restart-app-button").on("click", utils.restartDesktopApp);
}
async optionsLoaded(options: OptionMap) {
const allLocales = getAvailableLocales();
function buildLocaleItem(locale: Locale, value: string) {
return $("<option>")
.attr("value", value)
.text(locale.name)
}
// Build list of UI locales.
this.$localeSelect.empty();
for (const locale of allLocales.filter(l => !l.contentOnly)) {
this.$localeSelect.append(buildLocaleItem(locale, locale.id));
}
this.$localeSelect.val(options.locale);
// Build list of Electron locales.
this.$formattingLocaleSelect.empty();
for (const locale of allLocales.filter(l => l.electronLocale)) {
this.$formattingLocaleSelect.append(buildLocaleItem(locale, locale.electronLocale as string));
}
this.$formattingLocaleSelect.val(options.formattingLocale);
this.$widget.find(`input[name="first-day-of-week"][value="${options.firstDayOfWeek}"]`)
.prop("checked", "true");
this.$widget.find(`input[name="first-week-of-year"][value="${options.firstWeekOfYear}"]`)
.prop("checked", "true");
if (parseInt(options.firstWeekOfYear) === 2) {
this.$minDaysRow.show();
}
this.$widget.find("#min-days-in-first-week").val(options.minDaysInFirstWeek);
}
}

View File

@@ -1,63 +0,0 @@
import OptionsWidget from "../options_widget.js";
import type { OptionMap } from "@triliumnext/commons";
import { getAvailableLocales } from "../../../../services/i18n.js";
import { t } from "../../../../services/i18n.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("content_language.title")}</h4>
<p class="form-text">${t("content_language.description")}</p>
<ul class="options-languages">
</ul>
<style>
ul.options-languages {
list-style-type: none;
margin-bottom: 0;
column-width: 400px;
}
</style>
</div>
`;
export default class LanguageOptions extends OptionsWidget {
private $languagesContainer!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$languagesContainer = this.$widget.find(".options-languages");
}
async save() {
const enabledLanguages: string[] = [];
this.$languagesContainer.find("input:checked").each((i, el) => {
const languageId = $(el).attr("data-language-id");
if (languageId) {
enabledLanguages.push(languageId);
}
});
await this.updateOption("languages", JSON.stringify(enabledLanguages));
}
async optionsLoaded(options: OptionMap) {
const availableLocales = getAvailableLocales();
const enabledLanguages = (JSON.parse(options.languages) as string[]);
this.$languagesContainer.empty();
for (const locale of availableLocales) {
const checkbox = $('<input type="checkbox" class="form-check-input">')
.attr("data-language-id", locale.id)
.prop("checked", enabledLanguages.includes(locale.id));
const wrapper = $(`<label class="tn-checkbox">`)
.append(checkbox)
.on("change", () => this.save())
.append(locale.name);
this.$languagesContainer.append($("<li>").append(wrapper));
}
}
}

View File

@@ -0,0 +1,48 @@
import { t } from "../../../services/i18n";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
export default function ImageSettings() {
const [ downloadImagesAutomatically, setDownloadImagesAutomatically ] = useTriliumOptionBool("downloadImagesAutomatically");
const [ compressImages, setCompressImages ] = useTriliumOptionBool("compressImages");
const [ imageMaxWidthHeight, setImageMaxWidthHeight ] = useTriliumOption("imageMaxWidthHeight");
const [ imageJpegQuality, setImageJpegQuality ] = useTriliumOption("imageJpegQuality");
return (
<OptionsSection title={t("images.images_section_title")}>
<FormGroup name="download-images-automatically" description={t("images.download_images_description")}>
<FormCheckbox
label={t("images.download_images_automatically")}
currentValue={downloadImagesAutomatically} onChange={setDownloadImagesAutomatically}
/>
</FormGroup>
<hr/>
<FormCheckbox
name="image-compression-enabled"
label={t("images.enable_image_compression")}
currentValue={compressImages} onChange={setCompressImages}
/>
<FormGroup name="image-max-width-height" label={t("images.max_image_dimensions")} disabled={!compressImages}>
<FormTextBoxWithUnit
type="number" min="1"
unit={t("images.max_image_dimensions_unit")}
currentValue={imageMaxWidthHeight} onChange={setImageMaxWidthHeight}
/>
</FormGroup>
<FormGroup name="image-jpeg-quality" label={t("images.jpeg_quality_description")} disabled={!compressImages}>
<FormTextBoxWithUnit
min="10" max="100" type="number"
unit={t("units.percentage")}
currentValue={imageJpegQuality} onChange={setImageJpegQuality}
/>
</FormGroup>
</OptionsSection>
);
}

View File

@@ -1,98 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<style>
.options-section .disabled-field {
opacity: 0.5;
pointer-events: none;
}
</style>
<h4>${t("images.images_section_title")}</h4>
<label class="tn-checkbox">
<input class="download-images-automatically" type="checkbox" name="download-images-automatically">
${t("images.download_images_automatically")}
</label>
<p class="form-text">${t("images.download_images_description")}</p>
<hr />
<label class="tn-checkbox">
<input class="image-compresion-enabled" type="checkbox" name="image-compression-enabled">
${t("images.enable_image_compression")}
</label>
<div class="image-compression-enabled-wraper">
<div class="form-group">
<label>${t("images.max_image_dimensions")}</label>
<label class="input-group tn-number-unit-pair">
<input class="image-max-width-height form-control options-number-input" type="number" min="1">
<span class="input-group-text">${t("images.max_image_dimensions_unit")}</span>
</label>
</div>
<div class="form-group">
<label>${t("images.jpeg_quality_description")}</label>
<label class="input-group tn-number-unit-pair">
<input class="image-jpeg-quality form-control options-number-input" min="10" max="100" type="number">
<span class="input-group-text">%</span>
</label>
</div>
</div>
</div>
`;
export default class ImageOptions extends OptionsWidget {
private $imageMaxWidthHeight!: JQuery<HTMLElement>;
private $imageJpegQuality!: JQuery<HTMLElement>;
private $downloadImagesAutomatically!: JQuery<HTMLElement>;
private $enableImageCompression!: JQuery<HTMLElement>;
private $imageCompressionWrapper!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$imageMaxWidthHeight = this.$widget.find(".image-max-width-height");
this.$imageJpegQuality = this.$widget.find(".image-jpeg-quality");
this.$imageMaxWidthHeight.on("change", () => this.updateOption("imageMaxWidthHeight", this.$imageMaxWidthHeight.val()));
this.$imageJpegQuality.on("change", () => this.updateOption("imageJpegQuality", String(this.$imageJpegQuality.val()).trim() || "75"));
this.$downloadImagesAutomatically = this.$widget.find(".download-images-automatically");
this.$downloadImagesAutomatically.on("change", () => this.updateCheckboxOption("downloadImagesAutomatically", this.$downloadImagesAutomatically));
this.$enableImageCompression = this.$widget.find(".image-compresion-enabled");
this.$imageCompressionWrapper = this.$widget.find(".image-compression-enabled-wraper");
this.$enableImageCompression.on("change", () => {
this.updateCheckboxOption("compressImages", this.$enableImageCompression);
this.setImageCompression();
});
}
optionsLoaded(options: OptionMap) {
this.$imageMaxWidthHeight.val(options.imageMaxWidthHeight);
this.$imageJpegQuality.val(options.imageJpegQuality);
this.setCheckboxState(this.$downloadImagesAutomatically, options.downloadImagesAutomatically);
this.setCheckboxState(this.$enableImageCompression, options.compressImages);
this.setImageCompression();
}
setImageCompression() {
if (this.$enableImageCompression.prop("checked")) {
this.$imageCompressionWrapper.removeClass("disabled-field");
} else {
this.$imageCompressionWrapper.addClass("disabled-field");
}
}
}

View File

@@ -1,342 +0,0 @@
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
import OptionsWidget from "./options_widget.js";
import type { OptionMap } from "@triliumnext/commons";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import dialogService from "../../../services/dialog.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.description")}</p>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="mfa-enabled-checkbox form-check-input" />
${t("multi_factor_authentication.mfa_enabled")}
</label>
</div>
<hr />
<div class="mfa-options" style="display: none;">
<label class="form-label"><b>${t("multi_factor_authentication.mfa_method")}</b></label>
<div role="group">
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="totp" />
<b>${t("multi_factor_authentication.totp_title")}</b>
</label>
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="oauth" />
<b>${t("multi_factor_authentication.oauth_title")}</b>
</label>
</div>
<div class="totp-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.totp_description")}</p>
<hr />
<h5>${t("multi_factor_authentication.totp_secret_title")}</h5>
<div class="admonition note no-totp-secret" role="alert">
${t("multi_factor_authentication.no_totp_secret_warning")}
</div>
<div class="admonition warning" role="alert">
${t("multi_factor_authentication.totp_secret_description_warning")}
</div>
<button class="generate-totp btn btn-primary">
${t("multi_factor_authentication.totp_secret_generate")}
</button>
<hr />
<h5>${t("multi_factor_authentication.recovery_keys_title")}</h5>
<p class="form-text">${t("multi_factor_authentication.recovery_keys_description")}</p>
<div class="admonition caution">
${t("multi_factor_authentication.recovery_keys_description_warning")}
</div>
<table style="border: 0px solid white">
<tbody>
<tr>
<td class="key_0"></td>
<td style="width: 20px" />
<td class="key_1"></td>
</tr>
<tr>
<td class="key_2"></td>
<td />
<td class="key_3"></td>
</tr>
<tr>
<td class="key_4"></td>
<td />
<td class="key_5"></td>
</tr>
<tr>
<td class="key_6"></td>
<td />
<td class="key_7"></td>
</tr>
</tbody>
</table>
<br>
<button class="generate-recovery-code btn btn-primary"> ${t("multi_factor_authentication.recovery_keys_generate")} </button>
</div>
<div class="oauth-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.oauth_description")}</p>
<div class="admonition note oauth-warning" role="alert">
${t("multi_factor_authentication.oauth_description_warning")}
</div>
<div class="admonition caution missing-vars" role="alert" style="display: none;"></div>
<hr />
<div class="col-md-6">
<span><b>${t("multi_factor_authentication.oauth_user_account")}</b></span><span class="user-account-name">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
<br>
<span><b>${t("multi_factor_authentication.oauth_user_email")}</b></span><span class="user-account-email">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
</div>
</div>
</div>
</div>
`;
const TPL_ELECTRON = `
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.electron_disabled")}</p>
</div>
`;
interface OAuthStatus {
enabled: boolean;
name?: string;
email?: string;
missingVars?: string[];
}
interface TOTPStatus {
set: boolean;
}
interface RecoveryKeysResponse {
success: boolean;
recoveryCodes?: string[];
keysExist?: boolean;
usedRecoveryCodes?: string[];
}
export default class MultiFactorAuthenticationOptions extends OptionsWidget {
private $mfaEnabledCheckbox!: JQuery<HTMLElement>;
private $mfaOptions!: JQuery<HTMLElement>;
private $mfaMethodRadios!: JQuery<HTMLElement>;
private $totpOptions!: JQuery<HTMLElement>;
private $noTotpSecretWarning!: JQuery<HTMLElement>;
private $generateTotpButton!: JQuery<HTMLElement>;
private $generateRecoveryCodeButton!: JQuery<HTMLElement>;
private $recoveryKeys: JQuery<HTMLElement>[] = [];
private $oauthOptions!: JQuery<HTMLElement>;
private $UserAccountName!: JQuery<HTMLElement>;
private $UserAccountEmail!: JQuery<HTMLElement>;
private $oauthWarning!: JQuery<HTMLElement>;
private $missingVars!: JQuery<HTMLElement>;
doRender() {
const template = utils.isElectron() ? TPL_ELECTRON : TPL;
this.$widget = $(template);
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox = this.$widget.find(".mfa-enabled-checkbox");
this.$mfaOptions = this.$widget.find(".mfa-options");
this.$mfaMethodRadios = this.$widget.find(".mfa-method-radio");
this.$totpOptions = this.$widget.find(".totp-options");
this.$noTotpSecretWarning = this.$widget.find(".no-totp-secret");
this.$generateTotpButton = this.$widget.find(".generate-totp");
this.$generateRecoveryCodeButton = this.$widget.find(".generate-recovery-code");
this.$oauthOptions = this.$widget.find(".oauth-options");
this.$UserAccountName = this.$widget.find(".user-account-name");
this.$UserAccountEmail = this.$widget.find(".user-account-email");
this.$oauthWarning = this.$widget.find(".oauth-warning");
this.$missingVars = this.$widget.find(".missing-vars");
this.$recoveryKeys = [];
for (let i = 0; i < 8; i++) {
this.$recoveryKeys.push(this.$widget.find(".key_" + i));
}
this.$generateRecoveryCodeButton.on("click", async () => {
await this.setRecoveryKeys();
});
this.$generateTotpButton.on("click", async () => {
await this.generateKey();
});
this.displayRecoveryKeys();
this.$mfaEnabledCheckbox.on("change", () => {
const isChecked = this.$mfaEnabledCheckbox.is(":checked");
this.$mfaOptions.toggle(isChecked);
if (!isChecked) {
this.$totpOptions.hide();
this.$oauthOptions.hide();
} else {
this.$mfaMethodRadios.filter('[value="totp"]').prop("checked", true);
this.$totpOptions.show();
this.$oauthOptions.hide();
}
this.updateCheckboxOption("mfaEnabled", this.$mfaEnabledCheckbox);
});
this.$mfaMethodRadios.on("change", () => {
const selectedMethod = this.$mfaMethodRadios.filter(":checked").val();
this.$totpOptions.toggle(selectedMethod === "totp");
this.$oauthOptions.toggle(selectedMethod === "oauth");
this.updateOption("mfaMethod", selectedMethod);
});
}
}
async setRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/generate");
if (!result.success) {
toastService.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (result.recoveryCodes) {
this.keyFiller(result.recoveryCodes);
await server.post("totp_recovery/set", {
recoveryCodes: result.recoveryCodes,
});
}
}
async displayRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/enabled");
if (!result.success) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (!result.keysExist) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_generate"));
return;
}
const usedResult = await server.get<RecoveryKeysResponse>("totp_recovery/used");
if (usedResult.usedRecoveryCodes) {
this.keyFiller(usedResult.usedRecoveryCodes);
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_regenerate"));
} else {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
}
}
private keyFiller(values: string[]) {
this.fillKeys("");
values.forEach((key, index) => {
if (typeof key === 'string') {
const date = new Date(key.replace(/\//g, '-'));
if (isNaN(date.getTime())) {
this.$recoveryKeys[index].text(key);
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_used", { date: key.replace(/\//g, '-') }));
}
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_unused", { index: key }));
}
});
}
private fillKeys(message: string) {
for (let i = 0; i < 8; i++) {
this.$recoveryKeys[i].text(message);
}
}
async generateKey() {
const totpStatus = await server.get<TOTPStatus>("totp/status");
if (totpStatus.set) {
const confirmed = await dialogService.confirm(t("multi_factor_authentication.totp_secret_regenerate_confirm"));
if (!confirmed) {
return;
}
}
const result = await server.get<{ success: boolean; message: string }>("totp/generate");
if (result.success) {
await dialogService.prompt({
title: t("multi_factor_authentication.totp_secret_generated"),
message: t("multi_factor_authentication.totp_secret_warning"),
defaultValue: result.message,
shown: ({ $answer }) => {
if ($answer) {
$answer.prop('readonly', true);
}
}
});
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
await this.setRecoveryKeys();
} else {
toastService.showError(result.message);
}
}
optionsLoaded(options: OptionMap) {
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox.prop("checked", options.mfaEnabled === "true");
this.$mfaOptions.toggle(options.mfaEnabled === "true");
if (options.mfaEnabled === "true") {
const savedMethod = options.mfaMethod || "totp";
this.$mfaMethodRadios.filter(`[value="${savedMethod}"]`).prop("checked", true);
this.$totpOptions.toggle(savedMethod === "totp");
this.$oauthOptions.toggle(savedMethod === "oauth");
} else {
this.$totpOptions.hide();
this.$oauthOptions.hide();
}
server.get<OAuthStatus>("oauth/status").then((result) => {
if (result.enabled) {
if (result.name) this.$UserAccountName.text(result.name);
if (result.email) this.$UserAccountEmail.text(result.email);
this.$oauthWarning.hide();
this.$missingVars.hide();
} else {
this.$UserAccountName.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$UserAccountEmail.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$oauthWarning.show();
if (result.missingVars && result.missingVars.length > 0) {
this.$missingVars.show();
const missingVarsList = result.missingVars.map(v => `"${v}"`);
this.$missingVars.html(t("multi_factor_authentication.oauth_missing_vars", { variables: missingVarsList.join(", ") }));
}
}
});
server.get<TOTPStatus>("totp/status").then((result) => {
if (result.set) {
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
this.$noTotpSecretWarning.hide();
} else {
this.$noTotpSecretWarning.show();
}
});
}
}
}

View File

@@ -0,0 +1,238 @@
import { Trans } from "react-i18next"
import { t } from "../../../services/i18n"
import FormText from "../../react/FormText"
import OptionsSection from "./components/OptionsSection"
import FormCheckbox from "../../react/FormCheckbox"
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks"
import { FormInlineRadioGroup } from "../../react/FormRadioGroup"
import Admonition from "../../react/Admonition"
import { useCallback, useEffect, useMemo, useState } from "preact/hooks"
import { OAuthStatus, TOTPGenerate, TOTPRecoveryKeysResponse, TOTPStatus } from "@triliumnext/commons"
import server from "../../../services/server"
import Button from "../../react/Button"
import dialog from "../../../services/dialog"
import toast from "../../../services/toast"
import RawHtml from "../../react/RawHtml"
import { isElectron } from "../../../services/utils"
export default function MultiFactorAuthenticationSettings() {
const [ mfaEnabled, setMfaEnabled ] = useTriliumOptionBool("mfaEnabled");
return (!isElectron()
? (
<>
<EnableMultiFactor mfaEnabled={mfaEnabled} setMfaEnabled={setMfaEnabled} />
{ mfaEnabled && <MultiFactorMethod /> }
</>
) : (
<FormText>{t("multi_factor_authentication.electron_disabled")}</FormText>
)
)
}
function EnableMultiFactor({ mfaEnabled, setMfaEnabled }: { mfaEnabled: boolean, setMfaEnabled: (newValue: boolean) => Promise<void>}) {
return (
<OptionsSection title={t("multi_factor_authentication.title")}>
<FormText><Trans i18nKey="multi_factor_authentication.description" /></FormText>
<FormCheckbox
name="mfa-enabled"
label={t("multi_factor_authentication.mfa_enabled")}
currentValue={mfaEnabled} onChange={setMfaEnabled}
/>
</OptionsSection>
)
}
function MultiFactorMethod() {
const [ mfaMethod, setMfaMethod ] = useTriliumOption("mfaMethod");
return (
<>
<OptionsSection className="mfa-options" title={t("multi_factor_authentication.mfa_method")}>
<FormInlineRadioGroup
name="mfaMethod"
currentValue={mfaMethod} onChange={setMfaMethod}
values={[
{ value: "totp", label: t("multi_factor_authentication.totp_title") },
{ value: "oauth", label: t("multi_factor_authentication.oauth_title") }
]}
/>
<FormText>
{ mfaMethod === "totp"
? t("multi_factor_authentication.totp_description")
: <RawHtml html={t("multi_factor_authentication.oauth_description")} /> }
</FormText>
</OptionsSection>
{ mfaMethod === "totp"
? <TotpSettings />
: <OAuthSettings /> }
</>
)
}
function TotpSettings() {
const [ totpStatus, setTotpStatus ] = useState<TOTPStatus>();
const [ recoveryKeys, setRecoveryKeys ] = useState<string[]>();
const refreshTotpStatus = useCallback(() => {
server.get<TOTPStatus>("totp/status").then(setTotpStatus);
}, [])
const refreshRecoveryKeys = useCallback(async () => {
const result = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/enabled");
if (!result.success) {
toast.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (!result.keysExist) {
setRecoveryKeys(undefined);
return;
}
const usedResult = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/used");
setRecoveryKeys(usedResult.usedRecoveryCodes);
}, []);
const generateRecoveryKeys = useCallback(async () => {
const result = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/generate");
if (!result.success) {
toast.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (result.recoveryCodes) {
setRecoveryKeys(result.recoveryCodes);
}
await server.post("totp_recovery/set", {
recoveryCodes: result.recoveryCodes,
});
}, []);
useEffect(() => {
refreshTotpStatus();
refreshRecoveryKeys();
}, []);
return (<>
<OptionsSection title={t("multi_factor_authentication.totp_secret_title")}>
{totpStatus?.set
? <Admonition type="warning">{t("multi_factor_authentication.totp_secret_description_warning")}</Admonition>
: <Admonition type="note">{t("multi_factor_authentication.no_totp_secret_warning")}</Admonition>
}
<Button
text={totpStatus?.set
? t("multi_factor_authentication.totp_secret_regenerate")
: t("multi_factor_authentication.totp_secret_generate")}
onClick={async () => {
if (totpStatus?.set && !await dialog.confirm(t("multi_factor_authentication.totp_secret_regenerate_confirm"))) {
return;
}
const result = await server.get<TOTPGenerate>("totp/generate");
if (!result.success) {
toast.showError(result.message);
return;
}
await dialog.prompt({
title: t("multi_factor_authentication.totp_secret_generated"),
message: t("multi_factor_authentication.totp_secret_warning"),
defaultValue: result.message,
readOnly: true
});
refreshTotpStatus();
await generateRecoveryKeys();
}}
/>
</OptionsSection>
<TotpRecoveryKeys values={recoveryKeys} generateRecoveryKeys={generateRecoveryKeys} />
</>)
}
function TotpRecoveryKeys({ values, generateRecoveryKeys }: { values?: string[], generateRecoveryKeys: () => Promise<void> }) {
return (
<OptionsSection title={t("multi_factor_authentication.recovery_keys_title")}>
<FormText>{t("multi_factor_authentication.recovery_keys_description")}</FormText>
{values ? (
<>
<Admonition type="caution">
<Trans i18nKey={t("multi_factor_authentication.recovery_keys_description_warning")} />
</Admonition>
<ol style={{ columnCount: 2 }}>
{values.map(key => {
let text = "";
if (typeof key === 'string') {
const date = new Date(key.replace(/\//g, '-'));
if (isNaN(date.getTime())) {
return <li><code>{key}</code></li>
} else {
text = t("multi_factor_authentication.recovery_keys_used", { date: key.replace(/\//g, '-') });
}
} else {
text = t("multi_factor_authentication.recovery_keys_unused", { index: key });
}
return <li>{text}</li>
})}
</ol>
</>
) : (
<p>{t("multi_factor_authentication.recovery_keys_no_key_set")}</p>
)}
<Button
text={!values
? t("multi_factor_authentication.recovery_keys_generate")
: t("multi_factor_authentication.recovery_keys_regenerate")
}
onClick={generateRecoveryKeys}
/>
</OptionsSection>
);
}
function OAuthSettings() {
const [ status, setStatus ] = useState<OAuthStatus>();
useEffect(() => {
server.get<OAuthStatus>("oauth/status").then((result) => setStatus);
});
return (
<OptionsSection title={t("multi_factor_authentication.oauth_title")}>
{ status?.enabled ? (
<div class="col-md-6">
<span><b>{t("multi_factor_authentication.oauth_user_account")}</b></span>
<span class="user-account-name">{status.name ?? t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
<br />
<span><b>{t("multi_factor_authentication.oauth_user_email")}</b></span>
<span class="user-account-email">{status.email ?? t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
</div>
) : (
<>
<p>{t("multi_factor_authentication.oauth_description_warning")}</p>
{ status?.missingVars && (
<Admonition type="caution">
{t("multi_factor_authentication.oauth_missing_vars", {
variables: status.missingVars.map(v => `"${v}"`).join(", ")
})}
</Admonition>
)}
</>
)}
</OptionsSection>
)
}

View File

@@ -1,77 +0,0 @@
import type { FilterOptionsByType, OptionMap, OptionNames } from "@triliumnext/commons";
import type { EventData, EventListener } from "../../../components/app_context.js";
import type FNote from "../../../entities/fnote.js";
import { t } from "../../../services/i18n.js";
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
import NoteContextAwareWidget from "../../note_context_aware_widget.js";
export default class OptionsWidget extends NoteContextAwareWidget implements EventListener<"entitiesReloaded"> {
constructor() {
super();
this.contentSized();
}
async updateOption<T extends OptionNames>(name: T, value: string | number | string[] | undefined) {
const opts = { [name]: value };
await this.updateMultipleOptions(opts);
}
async updateMultipleOptions(opts: Partial<OptionMap>) {
await server.put("options", opts);
this.showUpdateNotification();
}
showUpdateNotification() {
toastService.showPersistent({
id: "options-change-saved",
title: t("options_widget.options_status"),
message: t("options_widget.options_change_saved"),
icon: "slider",
closeAfter: 2000
});
}
async updateCheckboxOption<T extends FilterOptionsByType<boolean>>(name: T, $checkbox: JQuery<HTMLElement>) {
const isChecked = $checkbox.prop("checked");
return await this.updateOption(name, isChecked ? "true" : "false");
}
setCheckboxState($checkbox: JQuery<HTMLElement>, optionValue: string) {
$checkbox.prop("checked", optionValue === "true");
}
optionsLoaded(options: OptionMap) { }
async refresh() {
this.toggleInt(this.isEnabled());
try {
await this.refreshWithNote(this.note);
} catch (e) {
// Ignore errors when user is refreshing or navigating away.
if (e === "rejected by browser") {
return;
}
throw e;
}
}
async refreshWithNote(note: FNote | null | undefined) {
const options = await server.get<OptionMap>("options");
if (options) {
this.optionsLoaded(options);
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getOptionNames().length > 0) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,292 @@
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import Button from "../../react/Button";
import FormText from "../../react/FormText";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
import { useMemo } from "preact/hooks";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
import search from "../../../services/search";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormSelect from "../../react/FormSelect";
import { isElectron } from "../../../services/utils";
export default function OtherSettings() {
return (
<>
{isElectron() && <>
<SearchEngineSettings />
<TrayOptionsSettings />
</>}
<NoteErasureTimeout />
<AttachmentErasureTimeout />
<RevisionSnapshotInterval />
<RevisionSnapshotLimit />
<HtmlImportTags />
<ShareSettings />
<NetworkSettings />
</>
)
}
function SearchEngineSettings() {
const [ customSearchEngineName, setCustomSearchEngineName ] = useTriliumOption("customSearchEngineName");
const [ customSearchEngineUrl, setCustomSearchEngineUrl ] = useTriliumOption("customSearchEngineUrl");
const searchEngines = useMemo(() => {
return [
{ url: "https://www.bing.com/search?q={keyword}", name: t("search_engine.bing") },
{ url: "https://www.baidu.com/s?wd={keyword}", name: t("search_engine.baidu") },
{ url: "https://duckduckgo.com/?q={keyword}", name: t("search_engine.duckduckgo") },
{ url: "https://www.google.com/search?q={keyword}", name: t("search_engine.google") }
];
}, []);
return (
<OptionsSection title={t("search_engine.title")}>
<FormText>{t("search_engine.custom_search_engine_info")}</FormText>
<FormGroup name="predefined-search-engine" label={t("search_engine.predefined_templates_label")}>
<FormSelect
values={searchEngines}
currentValue={customSearchEngineUrl}
keyProperty="url" titleProperty="name"
onChange={newValue => {
const searchEngine = searchEngines.find(e => e.url === newValue);
if (!searchEngine) {
return;
}
setCustomSearchEngineName(searchEngine.name);
setCustomSearchEngineUrl(searchEngine.url);
}}
/>
</FormGroup>
<FormGroup name="custom-name" label={t("search_engine.custom_name_label")}>
<FormTextBox
currentValue={customSearchEngineName} onChange={setCustomSearchEngineName}
placeholder={t("search_engine.custom_name_placeholder")}
/>
</FormGroup>
<FormGroup name="custom-url" label={t("search_engine.custom_url_label")}>
<FormTextBox
currentValue={customSearchEngineUrl} onChange={setCustomSearchEngineUrl}
placeholder={t("search_engine.custom_url_placeholder")}
/>
</FormGroup>
</OptionsSection>
)
}
function TrayOptionsSettings() {
const [ disableTray, setDisableTray ] = useTriliumOptionBool("disableTray");
return (
<OptionsSection title={t("tray.title")}>
<FormCheckbox
name="tray-enabled"
label={t("tray.enable_tray")}
currentValue={!disableTray}
onChange={trayEnabled => setDisableTray(!trayEnabled)}
/>
</OptionsSection>
)
}
function NoteErasureTimeout() {
return (
<OptionsSection title={t("note_erasure_timeout.note_erasure_timeout_title")}>
<FormText>{t("note_erasure_timeout.note_erasure_description")}</FormText>
<FormGroup name="erase-entities-after" label={t("note_erasure_timeout.erase_notes_after")}>
<TimeSelector
name="erase-entities-after"
optionValueId="eraseEntitiesAfterTimeInSeconds" optionTimeScaleId="eraseEntitiesAfterTimeScale"
/>
</FormGroup>
<FormText>{t("note_erasure_timeout.manual_erasing_description")}</FormText>
<Button
text={t("note_erasure_timeout.erase_deleted_notes_now")}
onClick={() => {
server.post("notes/erase-deleted-notes-now").then(() => {
toast.showMessage(t("note_erasure_timeout.deleted_notes_erased"));
});
}}
/>
</OptionsSection>
)
}
function AttachmentErasureTimeout() {
return (
<OptionsSection title={t("attachment_erasure_timeout.attachment_erasure_timeout")}>
<FormText>{t("attachment_erasure_timeout.attachment_auto_deletion_description")}</FormText>
<FormGroup name="erase-unused-attachments-after" label={t("attachment_erasure_timeout.erase_attachments_after")}>
<TimeSelector
name="erase-unused-attachments-after"
optionValueId="eraseUnusedAttachmentsAfterSeconds" optionTimeScaleId="eraseUnusedAttachmentsAfterTimeScale"
/>
</FormGroup>
<FormText>{t("attachment_erasure_timeout.manual_erasing_description")}</FormText>
<Button
text={t("attachment_erasure_timeout.erase_unused_attachments_now")}
onClick={() => {
server.post("notes/erase-unused-attachments-now").then(() => {
toast.showMessage(t("attachment_erasure_timeout.unused_attachments_erased"));
});
}}
/>
</OptionsSection>
)
}
function RevisionSnapshotInterval() {
return (
<OptionsSection title={t("revisions_snapshot_interval.note_revisions_snapshot_interval_title")}>
<FormText>
<Trans
i18nKey="revisions_snapshot_interval.note_revisions_snapshot_description"
components={{ doc: <a href="https://triliumnext.github.io/Docs/Wiki/note-revisions.html" class="external" /> as React.ReactElement }}
/>
</FormText>
<FormGroup name="revision-snapshot-time-interval" label={t("revisions_snapshot_interval.snapshot_time_interval_label")}>
<TimeSelector
name="revision-snapshot-time-interval"
optionValueId="revisionSnapshotTimeInterval" optionTimeScaleId="revisionSnapshotTimeIntervalTimeScale"
minimumSeconds={10}
/>
</FormGroup>
</OptionsSection>
)
}
function RevisionSnapshotLimit() {
const [ revisionSnapshotNumberLimit, setRevisionSnapshotNumberLimit ] = useTriliumOption("revisionSnapshotNumberLimit");
return (
<OptionsSection title={t("revisions_snapshot_limit.note_revisions_snapshot_limit_title")}>
<FormText>{t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</FormText>
<FormGroup name="revision-snapshot-number-limit">
<FormTextBoxWithUnit
type="number" min={-1}
currentValue={revisionSnapshotNumberLimit}
unit={t("revisions_snapshot_limit.snapshot_number_limit_unit")}
onChange={value => {
const newValue = parseInt(value, 10);
if (!isNaN(newValue) && newValue >= -1) {
setRevisionSnapshotNumberLimit(newValue);
}
}}
/>
</FormGroup>
<Button
text={t("revisions_snapshot_limit.erase_excess_revision_snapshots")}
onClick={async () => {
await server.post("revisions/erase-all-excess-revisions");
toast.showMessage(t("revisions_snapshot_limit.erase_excess_revision_snapshots_prompt"));
}}
/>
</OptionsSection>
)
}
function HtmlImportTags() {
const [ allowedHtmlTags, setAllowedHtmlTags ] = useTriliumOptionJson<readonly string[]>("allowedHtmlTags");
const parsedValue = useMemo(() => {
return allowedHtmlTags.join(" ");
}, allowedHtmlTags);
return (
<OptionsSection title={t("import.html_import_tags.title")}>
<FormText>{t("import.html_import_tags.description")}</FormText>
<textarea
className="allowed-html-tags"
spellcheck={false}
placeholder={t("import.html_import_tags.placeholder")}
style={useMemo(() => ({
width: "100%",
height: "150px",
marginBottom: "12px",
fontFamily: "var(--monospace-font-family)"
}), [])}
value={parsedValue}
onBlur={e => {
const tags = e.currentTarget.value
.split(/[\n,\s]+/) // Split on newlines, commas, or spaces
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
setAllowedHtmlTags(tags);
}}
/>
<Button
text={t("import.html_import_tags.reset_button")}
onClick={() => setAllowedHtmlTags(SANITIZER_DEFAULT_ALLOWED_TAGS)}
/>
</OptionsSection>
)
}
function ShareSettings() {
const [ redirectBareDomain, setRedirectBareDomain ] = useTriliumOptionBool("redirectBareDomain");
const [ showLogInShareTheme, setShowLogInShareTheme ] = useTriliumOptionBool("showLoginInShareTheme");
return (
<OptionsSection title={t("share.title")}>
<FormGroup name="redirectBareDomain" description={t("share.redirect_bare_domain_description")}>
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
currentValue={redirectBareDomain}
onChange={async value => {
if (value) {
const shareRootNotes = await search.searchForNotes("#shareRoot");
const sharedShareRootNote = shareRootNotes.find((note) => note.isShared());
if (sharedShareRootNote) {
toast.showMessage(t("share.share_root_found", { noteTitle: sharedShareRootNote.title }));
} else if (shareRootNotes.length > 0) {
toast.showError(t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }));
} else {
toast.showError(t("share.share_root_not_found"));
}
}
setRedirectBareDomain(value);
}}
/>
</FormGroup>
<FormGroup name="showLoginInShareTheme" description={t("share.show_login_link_description")}>
<FormCheckbox
label={t("share.show_login_link")}
currentValue={showLogInShareTheme} onChange={setShowLogInShareTheme}
/>
</FormGroup>
</OptionsSection>
)
}
function NetworkSettings() {
const [ checkForUpdates, setCheckForUpdates ] = useTriliumOptionBool("checkForUpdates");
return (
<OptionsSection title={t("network_connections.network_connections_title")}>
<FormCheckbox
name="check-for-updates"
label={t("network_connections.check_for_updates")}
currentValue={checkForUpdates} onChange={setCheckForUpdates}
/>
</OptionsSection>
)
}

View File

@@ -1,44 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { t } from "../../../../services/i18n.js";
import TimeSelector from "../time_selector.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("attachment_erasure_timeout.attachment_erasure_timeout")}</h4>
<p class="form-text">${t("attachment_erasure_timeout.attachment_auto_deletion_description")}</p>
<div id="time-selector-placeholder"></div>
<p class="form-text">${t("attachment_erasure_timeout.manual_erasing_description")}</p>
<button class="erase-unused-attachments-now-button btn btn-secondary">${t("attachment_erasure_timeout.erase_unused_attachments_now")}</button>
</div>`;
export default class AttachmentErasureTimeoutOptions extends TimeSelector {
private $eraseUnusedAttachmentsNowButton!: JQuery<HTMLElement>;
constructor() {
super({
widgetId: "erase-unused-attachments-after",
widgetLabelId: "attachment_erasure_timeout.erase_attachments_after",
optionValueId: "eraseUnusedAttachmentsAfterSeconds",
optionTimeScaleId: "eraseUnusedAttachmentsAfterTimeScale"
});
super.doRender();
}
doRender() {
const $timeSelector = this.$widget;
this.$widget = $(TPL);
// inject TimeSelector widget template
this.$widget.find("#time-selector-placeholder").replaceWith($timeSelector);
this.$eraseUnusedAttachmentsNowButton = this.$widget.find(".erase-unused-attachments-now-button");
this.$eraseUnusedAttachmentsNowButton.on("click", () => {
server.post("notes/erase-unused-attachments-now").then(() => {
toastService.showMessage(t("attachment_erasure_timeout.unused_attachments_erased"));
});
});
}
}

View File

@@ -1,176 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
// TODO: Deduplicate with src/services/html_sanitizer once there is a commons project between client and server.
export const DEFAULT_ALLOWED_TAGS = [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"p",
"a",
"ul",
"ol",
"li",
"b",
"i",
"strong",
"em",
"strike",
"s",
"del",
"abbr",
"code",
"hr",
"br",
"div",
"table",
"thead",
"caption",
"tbody",
"tfoot",
"tr",
"th",
"td",
"pre",
"section",
"img",
"figure",
"figcaption",
"span",
"label",
"input",
"details",
"summary",
"address",
"aside",
"footer",
"header",
"hgroup",
"main",
"nav",
"dl",
"dt",
"menu",
"bdi",
"bdo",
"dfn",
"kbd",
"mark",
"q",
"time",
"var",
"wbr",
"area",
"map",
"track",
"video",
"audio",
"picture",
"del",
"ins",
"en-media", // for ENEX import
// Additional tags (https://github.com/TriliumNext/Trilium/issues/567)
"acronym",
"article",
"big",
"button",
"cite",
"col",
"colgroup",
"data",
"dd",
"fieldset",
"form",
"legend",
"meter",
"noscript",
"option",
"progress",
"rp",
"samp",
"small",
"sub",
"sup",
"template",
"textarea",
"tt"
];
const TPL = /*html*/`
<div class="html-import-tags-settings options-section">
<style>
.html-import-tags-settings .allowed-html-tags {
height: 150px;
margin-bottom: 12px;
font-family: monospace;
}
</style>
<h4>${t("import.html_import_tags.title")}</h4>
<p class="form-text">${t("import.html_import_tags.description")}</p>
<textarea class="allowed-html-tags form-control" spellcheck="false"
placeholder="${t("import.html_import_tags.placeholder")}"></textarea>
<div>
<button class="btn btn-sm btn-secondary reset-to-default">
${t("import.html_import_tags.reset_button")}
</button>
</div>
</div>`;
export default class HtmlImportTagsOptions extends OptionsWidget {
private $allowedTags!: JQuery<HTMLElement>;
private $resetButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$allowedTags = this.$widget.find(".allowed-html-tags");
this.$resetButton = this.$widget.find(".reset-to-default");
this.$allowedTags.on("change", () => this.saveTags());
this.$resetButton.on("click", () => this.resetToDefault());
// Load initial tags
this.refresh();
}
async optionsLoaded(options: OptionMap) {
try {
if (options.allowedHtmlTags) {
const tags = JSON.parse(options.allowedHtmlTags);
this.$allowedTags.val(tags.join(" "));
} else {
// If no tags are set, show the defaults
this.$allowedTags.val(DEFAULT_ALLOWED_TAGS.join(" "));
}
} catch (e) {
console.error("Could not load HTML tags:", e);
// On error, show the defaults
this.$allowedTags.val(DEFAULT_ALLOWED_TAGS.join(" "));
}
}
async saveTags() {
const tagsText = String(this.$allowedTags.val()) || "";
const tags = tagsText
.split(/[\n,\s]+/) // Split on newlines, commas, or spaces
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
await this.updateOption("allowedHtmlTags", JSON.stringify(tags));
}
async resetToDefault() {
this.$allowedTags.val(DEFAULT_ALLOWED_TAGS.join("\n")); // Use actual newline
await this.saveTags();
}
}

View File

@@ -1,28 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("network_connections.network_connections_title")}</h4>
<label class="tn-checkbox">
<input class="check-for-updates form-check-input" type="checkbox" name="check-for-updates">
${t("network_connections.check_for_updates")}
</label>
</div>`;
export default class NetworkConnectionsOptions extends OptionsWidget {
private $checkForUpdates!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$checkForUpdates = this.$widget.find(".check-for-updates");
this.$checkForUpdates.on("change", () => this.updateCheckboxOption("checkForUpdates", this.$checkForUpdates));
}
async optionsLoaded(options: OptionMap) {
this.setCheckboxState(this.$checkForUpdates, options.checkForUpdates);
}
}

View File

@@ -1,44 +0,0 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
import TimeSelector from "../time_selector.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("note_erasure_timeout.note_erasure_timeout_title")}</h4>
<p class="form-text">${t("note_erasure_timeout.note_erasure_description")}</p>
<div id="time-selector-placeholder"></div>
<p class="form-text">${t("note_erasure_timeout.manual_erasing_description")}</p>
<button id="erase-deleted-notes-now-button" class="btn btn-secondary">${t("note_erasure_timeout.erase_deleted_notes_now")}</button>
</div>`;
export default class NoteErasureTimeoutOptions extends TimeSelector {
private $eraseDeletedNotesButton!: JQuery<HTMLButtonElement>;
constructor() {
super({
widgetId: "erase-entities-after",
widgetLabelId: "note_erasure_timeout.erase_notes_after",
optionValueId: "eraseEntitiesAfterTimeInSeconds",
optionTimeScaleId: "eraseEntitiesAfterTimeScale"
});
super.doRender();
}
doRender() {
const $timeSelector = this.$widget;
// inject TimeSelector widget template
this.$widget = $(TPL);
this.$widget.find("#time-selector-placeholder").replaceWith($timeSelector);
this.$eraseDeletedNotesButton = this.$widget.find("#erase-deleted-notes-now-button");
this.$eraseDeletedNotesButton.on("click", () => {
server.post("notes/erase-deleted-notes-now").then(() => {
toastService.showMessage(t("note_erasure_timeout.deleted_notes_erased"));
});
});
}
}

View File

@@ -1,50 +0,0 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import server from "../../../../services/server.js";
import toastService from "../../../../services/toast.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("revisions_snapshot_limit.note_revisions_snapshot_limit_title")}</h4>
<p class="form-text">${t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</p>
<div class="form-group">
<label>${t("revisions_snapshot_limit.snapshot_number_limit_label")}</label>
<label class="input-group tn-number-unit-pair">
<input class="revision-snapshot-number-limit form-control options-number-input" type="number" min="-1">
<span class="input-group-text">${t("revisions_snapshot_limit.snapshot_number_limit_unit")}</span>
</label>
</div>
<button class="erase-excess-revision-snapshots-now-button btn btn-sm">
${t("revisions_snapshot_limit.erase_excess_revision_snapshots")}</button>
</div>`;
export default class RevisionSnapshotsLimitOptions extends OptionsWidget {
private $revisionSnapshotsNumberLimit!: JQuery<HTMLElement>;
private $eraseExcessRevisionSnapshotsButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$revisionSnapshotsNumberLimit = this.$widget.find(".revision-snapshot-number-limit");
this.$revisionSnapshotsNumberLimit.on("change", () => {
let revisionSnapshotNumberLimit = parseInt(String(this.$revisionSnapshotsNumberLimit.val()), 10);
if (!isNaN(revisionSnapshotNumberLimit) && revisionSnapshotNumberLimit >= -1) {
this.updateOption("revisionSnapshotNumberLimit", revisionSnapshotNumberLimit);
}
});
this.$eraseExcessRevisionSnapshotsButton = this.$widget.find(".erase-excess-revision-snapshots-now-button");
this.$eraseExcessRevisionSnapshotsButton.on("click", () => {
server.post("revisions/erase-all-excess-revisions").then(() => {
toastService.showMessage(t("revisions_snapshot_limit.erase_excess_revision_snapshots_prompt"));
});
});
}
async optionsLoaded(options: OptionMap) {
this.$revisionSnapshotsNumberLimit.val(options.revisionSnapshotNumberLimit);
}
}

View File

@@ -1,31 +0,0 @@
import { t } from "../../../../services/i18n.js";
import TimeSelector from "../time_selector.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("revisions_snapshot_interval.note_revisions_snapshot_interval_title")}</h4>
<p class="form-text use-tn-links">${t("revisions_snapshot_interval.note_revisions_snapshot_description")}</p>
<div id="time-selector-placeholder"></div>
</div>`;
export default class RevisionsSnapshotIntervalOptions extends TimeSelector {
constructor() {
super({
widgetId: "revision-snapshot-time-interval",
widgetLabelId: "revisions_snapshot_interval.snapshot_time_interval_label",
optionValueId: "revisionSnapshotTimeInterval",
optionTimeScaleId: "revisionSnapshotTimeIntervalTimeScale",
minimumSeconds: 10
});
super.doRender();
}
doRender() {
const $timeSelector = this.$widget;
// inject TimeSelector widget template
this.$widget = $(TPL);
this.$widget.find("#time-selector-placeholder").replaceWith($timeSelector);
}
}

View File

@@ -1,84 +0,0 @@
import OptionsWidget from "../options_widget.js";
import utils from "../../../../services/utils.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("search_engine.title")}</h4>
<p class="form-text">${t("search_engine.custom_search_engine_info")}</p>
<form class="sync-setup-form">
<div class="form-group">
<label for="predefined-search-engine-select">${t("search_engine.predefined_templates_label")}</label>
<select id="predefined-search-engine-select" class="predefined-search-engine-select form-control">
<option value="Bing">${t("search_engine.bing")}</option>
<option value="Baidu">${t("search_engine.baidu")}</option>
<option value="DuckDuckGo">${t("search_engine.duckduckgo")}</option>
<option value="Google">${t("search_engine.google")}</option>
</select>
</div>
<div class="form-group">
<label>${t("search_engine.custom_name_label")}</label>
<input type="text" class="custom-search-engine-name form-control" placeholder="${t("search_engine.custom_name_placeholder")}">
</div>
<div class="form-group">
<label>${t("search_engine.custom_url_label")}</label>
<input type="text" class="custom-search-engine-url form-control" placeholder="${t("search_engine.custom_url_placeholder")}">
</div>
<div style="display: flex; justify-content: space-between;">
<button class="btn btn-primary">${t("search_engine.save_button")}</button>
</div>
</form>
</div>`;
const SEARCH_ENGINES: Record<string, string> = {
Bing: "https://www.bing.com/search?q={keyword}",
Baidu: "https://www.baidu.com/s?wd={keyword}",
DuckDuckGo: "https://duckduckgo.com/?q={keyword}",
Google: "https://www.google.com/search?q={keyword}"
};
export default class SearchEngineOptions extends OptionsWidget {
private $form!: JQuery<HTMLElement>;
private $predefinedSearchEngineSelect!: JQuery<HTMLElement>;
private $customSearchEngineName!: JQuery<HTMLInputElement>;
private $customSearchEngineUrl!: JQuery<HTMLInputElement>;
isEnabled() {
return super.isEnabled() && utils.isElectron();
}
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".sync-setup-form");
this.$predefinedSearchEngineSelect = this.$widget.find(".predefined-search-engine-select");
this.$customSearchEngineName = this.$widget.find(".custom-search-engine-name");
this.$customSearchEngineUrl = this.$widget.find(".custom-search-engine-url");
this.$predefinedSearchEngineSelect.on("change", () => {
const predefinedSearchEngine = String(this.$predefinedSearchEngineSelect.val());
this.$customSearchEngineName[0].value = predefinedSearchEngine;
this.$customSearchEngineUrl[0].value = SEARCH_ENGINES[predefinedSearchEngine];
});
this.$form.on("submit", () => {
this.updateMultipleOptions({
customSearchEngineName: this.$customSearchEngineName.val(),
customSearchEngineUrl: this.$customSearchEngineUrl.val()
});
});
}
async optionsLoaded(options: OptionMap) {
this.$predefinedSearchEngineSelect.val("");
this.$customSearchEngineName[0].value = options.customSearchEngineName;
this.$customSearchEngineUrl[0].value = options.customSearchEngineUrl;
}
}

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