mirror of
https://github.com/zadam/trilium.git
synced 2025-11-03 11:56:01 +01:00
React modals (#6544)
This commit is contained in:
2
.vscode/i18n-ally-custom-framework.yml
vendored
2
.vscode/i18n-ally-custom-framework.yml
vendored
@@ -3,6 +3,7 @@
|
||||
languageIds:
|
||||
- javascript
|
||||
- typescript
|
||||
- typescriptreact
|
||||
- html
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
@@ -25,6 +26,7 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
refactorTemplates:
|
||||
- t("$1")
|
||||
- {t("$1")}
|
||||
- ${t("$1")}
|
||||
- <%= t("$1") %>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { StartupChecks } from "./startup_checks.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
@@ -92,7 +93,9 @@ export type CommandMappings = {
|
||||
closeTocCommand: CommandData;
|
||||
closeHlt: CommandData;
|
||||
showLaunchBarSubtree: CommandData;
|
||||
showRevisions: CommandData;
|
||||
showRevisions: CommandData & {
|
||||
noteId?: string | null;
|
||||
};
|
||||
showLlmChat: CommandData;
|
||||
createAiChat: CommandData;
|
||||
showOptions: CommandData & {
|
||||
@@ -368,6 +371,9 @@ export type CommandMappings = {
|
||||
};
|
||||
refreshTouchBar: CommandData;
|
||||
reloadTextEditor: CommandData;
|
||||
chooseNoteType: CommandData & {
|
||||
callback: ChooseNoteTypeCallback
|
||||
}
|
||||
};
|
||||
|
||||
type EventMappings = {
|
||||
|
||||
@@ -38,8 +38,8 @@ export interface Suggestion {
|
||||
commandShortcut?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
container?: HTMLElement;
|
||||
export interface Options {
|
||||
container?: HTMLElement | null;
|
||||
fastSearch?: boolean;
|
||||
allowCreatingNotes?: boolean;
|
||||
allowJumpToSearchNotes?: boolean;
|
||||
@@ -452,6 +452,21 @@ function init() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function which triggers the display of recent notes in the autocomplete input and focuses it.
|
||||
*
|
||||
* @param inputElement - The input element to trigger recent notes on.
|
||||
*/
|
||||
export function triggerRecentNotes(inputElement: HTMLInputElement | null | undefined) {
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $el = $(inputElement);
|
||||
showRecentNotes($el);
|
||||
$el.trigger("focus").trigger("select");
|
||||
}
|
||||
|
||||
export default {
|
||||
autocompleteSourceForCKEditor,
|
||||
initNoteAutocomplete,
|
||||
|
||||
@@ -109,8 +109,6 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
|
||||
|
||||
async function chooseNoteType() {
|
||||
return new Promise<ChooseNoteTypeResponse>((res) => {
|
||||
// TODO: Remove ignore after callback for chooseNoteType is defined in app_context.ts
|
||||
//@ts-ignore
|
||||
appContext.triggerCommand("chooseNoteType", { callback: res });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -642,6 +642,10 @@ table.promoted-attributes-in-tooltip th {
|
||||
z-index: calc(var(--ck-z-panel) - 1) !important;
|
||||
}
|
||||
|
||||
.tooltip.tooltip-top {
|
||||
z-index: 32767 !important;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -233,16 +233,16 @@ div.tn-tool-dialog {
|
||||
|
||||
/* Item title link */
|
||||
|
||||
.recent-changes-content ul li .note-title a {
|
||||
.recent-changes-content ul li a {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.recent-changes-content ul li .note-title a:hover {
|
||||
.recent-changes-content ul li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Item title for deleted notes */
|
||||
.recent-changes-content ul li.deleted-note .note-title > .note-title {
|
||||
.recent-changes-content ul li.deleted-note .note-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"link_title_mirrors": "链接标题跟随笔记标题变化",
|
||||
"link_title_arbitrary": "链接标题可随意修改",
|
||||
"link_title": "链接标题",
|
||||
"button_add_link": "添加链接 <kbd>回车</kbd>"
|
||||
"button_add_link": "添加链接"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "编辑分支前缀",
|
||||
@@ -68,7 +68,7 @@
|
||||
"search_for_note_by_its_name": "按名称搜索笔记",
|
||||
"cloned_note_prefix_title": "克隆的笔记将在笔记树中显示给定的前缀",
|
||||
"prefix_optional": "前缀(可选)",
|
||||
"clone_to_selected_note": "克隆到选定的笔记 <kbd>回车</kbd>",
|
||||
"clone_to_selected_note": "克隆到选定的笔记",
|
||||
"no_path_to_clone_to": "没有克隆路径。",
|
||||
"note_cloned": "笔记 \"{{clonedTitle}}\" 已克隆到 \"{{targetTitle}}\""
|
||||
},
|
||||
@@ -87,9 +87,9 @@
|
||||
"delete_all_clones_description": "同时删除所有克隆(可以在最近修改中撤消)",
|
||||
"erase_notes_description": "通常(软)删除仅标记笔记为已删除,可以在一段时间内通过最近修改对话框撤消。选中此选项将立即擦除笔记,不可撤销。",
|
||||
"erase_notes_warning": "永久擦除笔记(无法撤销),包括所有克隆。这将强制应用程序重载。",
|
||||
"notes_to_be_deleted": "将删除以下笔记 ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "将删除以下笔记 ({{notesCount}})",
|
||||
"no_note_to_delete": "没有笔记将被删除(仅克隆)。",
|
||||
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{ relationCount}})",
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"deleted_relation_text": "笔记 {{- note}} (将被删除的笔记) 被以下关系 {{- relation}} 引用, 来自 {{- source}}。"
|
||||
@@ -116,17 +116,16 @@
|
||||
"fullDocumentation": "帮助(完整<a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">在线文档</a>)",
|
||||
"close": "关闭",
|
||||
"noteNavigation": "笔记导航",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - 在笔记列表中向上/向下移动",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - 折叠/展开节点",
|
||||
"goUpDown": "在笔记列表中向上/向下移动",
|
||||
"collapseExpand": "折叠/展开节点",
|
||||
"notSet": "未设置",
|
||||
"goBackForwards": "在历史记录中前后移动",
|
||||
"showJumpToNoteDialog": "显示<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"跳转到\" 对话框</a>",
|
||||
"scrollToActiveNote": "滚动到活跃笔记",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - 跳转到父笔记",
|
||||
"jumpToParentNote": "跳转到父笔记",
|
||||
"collapseWholeTree": "折叠整个笔记树",
|
||||
"collapseSubTree": "折叠子树",
|
||||
"tabShortcuts": "标签页快捷键",
|
||||
"newTabNoteLink": "<kbd>CTRL+click</kbd> - 在笔记链接上使用CTRL+点击(或中键点击)在新标签页中打开笔记",
|
||||
"onlyInDesktop": "仅在桌面版(电子构建)中",
|
||||
"openEmptyTab": "打开空白标签页",
|
||||
"closeActiveTab": "关闭活跃标签页",
|
||||
@@ -141,14 +140,14 @@
|
||||
"moveNoteUpHierarchy": "在层级结构中向上移动笔记",
|
||||
"multiSelectNote": "多选上/下笔记",
|
||||
"selectAllNotes": "选择当前级别的所有笔记",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - 选择笔记",
|
||||
"selectNote": "选择笔记",
|
||||
"copyNotes": "将活跃笔记(或当前选择)复制到剪贴板(用于<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">克隆</a>)",
|
||||
"cutNotes": "将当前笔记(或当前选择)剪切到剪贴板(用于移动笔记)",
|
||||
"pasteNotes": "将笔记粘贴为活跃笔记的子笔记(根据是复制还是剪切到剪贴板来决定是移动还是克隆)",
|
||||
"deleteNotes": "删除笔记/子树",
|
||||
"editingNotes": "编辑笔记",
|
||||
"editNoteTitle": "在树形笔记树中,焦点会从笔记树切换到笔记标题。按下 Enter 键会将焦点从笔记标题切换到文本编辑器。按下 <kbd>Ctrl+.</kbd> 会将焦点从编辑器切换回笔记树。",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - 创建/编辑外部链接",
|
||||
"createEditLink": "创建/编辑外部链接",
|
||||
"createInternalLink": "创建内部链接",
|
||||
"followLink": "跟随光标下的链接",
|
||||
"insertDateTime": "在插入点插入当前日期和时间",
|
||||
@@ -203,7 +202,7 @@
|
||||
"box_size_small": "小型 (显示大约10行)",
|
||||
"box_size_medium": "中型 (显示大约30行)",
|
||||
"box_size_full": "完整显示(完整文本框)",
|
||||
"button_include": "包含笔记 <kbd>回车</kbd>"
|
||||
"button_include": "包含笔记"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "信息消息",
|
||||
@@ -212,14 +211,14 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "关闭",
|
||||
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>",
|
||||
"search_button": "全文搜索",
|
||||
"search_placeholder": "按名称或类型搜索笔记 > 查看命令..."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown 导入",
|
||||
"close": "关闭",
|
||||
"modal_body_text": "由于浏览器沙箱的限制,无法直接从 JavaScript 读取剪贴板内容。请将要导入的 Markdown 文本粘贴到下面的文本框中,然后点击导入按钮",
|
||||
"import_button": "导入 Ctrl+回车",
|
||||
"import_button": "导入",
|
||||
"import_success": "Markdown 内容已成功导入文档。"
|
||||
},
|
||||
"move_to": {
|
||||
@@ -228,7 +227,7 @@
|
||||
"notes_to_move": "需要移动的笔记",
|
||||
"target_parent_note": "目标父笔记",
|
||||
"search_placeholder": "通过名称搜索笔记",
|
||||
"move_button": "移动到选定的笔记 <kbd>回车</kbd>",
|
||||
"move_button": "移动到选定的笔记",
|
||||
"error_no_path": "没有可以移动到的路径。",
|
||||
"move_success_message": "所选笔记已移动到"
|
||||
},
|
||||
@@ -236,20 +235,19 @@
|
||||
"modal_title": "选择笔记类型",
|
||||
"close": "关闭",
|
||||
"modal_body": "选择新笔记的类型或模板:",
|
||||
"templates": "模板:",
|
||||
"templates": "模板",
|
||||
"change_path_prompt": "更改创建新笔记的位置:",
|
||||
"search_placeholder": "按名称搜索路径(默认为空)"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密码未设置",
|
||||
"close": "关闭",
|
||||
"body1": "受保护的笔记使用用户密码加密,但密码尚未设置。",
|
||||
"body2": "点击<a class=\"open-password-options-button\" href=\"javascript:\">这里</a>打开选项对话框并设置您的密码。"
|
||||
"body1": "受保护的笔记使用用户密码加密,但密码尚未设置。"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "提示",
|
||||
"close": "关闭",
|
||||
"ok": "确定 <kbd>回车</kbd>",
|
||||
"ok": "确定",
|
||||
"defaultTitle": "提示"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -257,7 +255,7 @@
|
||||
"help_title": "关于保护笔记的帮助",
|
||||
"close_label": "关闭",
|
||||
"form_label": "输入密码进入保护会话以继续:",
|
||||
"start_button": "开始保护会话 <kbd>回车</kbd>"
|
||||
"start_button": "开始保护会话"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "最近修改",
|
||||
@@ -309,13 +307,13 @@
|
||||
"sort_with_respect_to_different_character_sorting": "根据不同语言或地区的字符排序和排序规则排序。",
|
||||
"natural_sort_language": "自然排序语言",
|
||||
"the_language_code_for_natural_sort": "自然排序的语言代码,例如中文的 \"zh-CN\"。",
|
||||
"sort": "排序 <kbd>Enter</kbd>"
|
||||
"sort": "排序"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "上传附件到笔记",
|
||||
"close": "关闭",
|
||||
"choose_files": "选择文件",
|
||||
"files_will_be_uploaded": "文件将作为附件上传到",
|
||||
"files_will_be_uploaded": "文件将作为附件上传到 {{noteTitle}}",
|
||||
"options": "选项",
|
||||
"shrink_images": "缩小图片",
|
||||
"upload": "上传",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"link_title_mirrors": "Der Linktitel spiegelt den aktuellen Titel der Notiz wider",
|
||||
"link_title_arbitrary": "Der Linktitel kann beliebig geändert werden",
|
||||
"link_title": "Linktitel",
|
||||
"button_add_link": "Link hinzufügen <kbd>Eingabetaste</kbd>"
|
||||
"button_add_link": "Link hinzufügen"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Zweigpräfix bearbeiten",
|
||||
@@ -68,7 +68,7 @@
|
||||
"search_for_note_by_its_name": "Suche nach einer Notiz anhand ihres Namens",
|
||||
"cloned_note_prefix_title": "Die geklonte Notiz wird im Notizbaum mit dem angegebenen Präfix angezeigt",
|
||||
"prefix_optional": "Präfix (optional)",
|
||||
"clone_to_selected_note": "Auf ausgewählte Notiz klonen <kbd>Eingabe</kbd>",
|
||||
"clone_to_selected_note": "Auf ausgewählte Notiz klonen",
|
||||
"no_path_to_clone_to": "Kein Pfad zum Klonen.",
|
||||
"note_cloned": "Die Notiz \"{{clonedTitle}}\" wurde in \"{{targetTitle}}\" hinein geklont"
|
||||
},
|
||||
@@ -87,9 +87,9 @@
|
||||
"delete_all_clones_description": "auch alle Klone löschen (kann bei letzte Änderungen rückgängig gemacht werden)",
|
||||
"erase_notes_description": "Beim normalen (vorläufigen) Löschen werden die Notizen nur als gelöscht markiert und sie können innerhalb eines bestimmten Zeitraums (im Dialogfeld „Letzte Änderungen“) wiederhergestellt werden. Wenn du diese Option aktivierst, werden die Notizen sofort gelöscht und es ist nicht möglich, die Notizen wiederherzustellen.",
|
||||
"erase_notes_warning": "Notizen dauerhaft löschen (kann nicht rückgängig gemacht werden), einschließlich aller Klone. Dadurch wird ein Neuladen der Anwendung erzwungen.",
|
||||
"notes_to_be_deleted": "Folgende Notizen werden gelöscht (<span class=\"deleted-notes-count\"></span>)",
|
||||
"notes_to_be_deleted": "Folgende Notizen werden gelöscht ({{notesCount}})",
|
||||
"no_note_to_delete": "Es werden keine Notizen gelöscht (nur Klone).",
|
||||
"broken_relations_to_be_deleted": "Folgende Beziehungen werden gelöst und gelöscht (<span class=\"broke-relations-count\"></span>)",
|
||||
"broken_relations_to_be_deleted": "Folgende Beziehungen werden gelöst und gelöscht ({{ relationCount}})",
|
||||
"cancel": "Abbrechen",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Notiz {{- note}} (soll gelöscht werden) wird von Beziehung {{- relation}} ausgehend von {{- source}} referenziert."
|
||||
@@ -113,20 +113,19 @@
|
||||
"format_pdf": "PDF - für Ausdrucke oder Teilen."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Hilfe (gesamte Dokumentation ist <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a> verfügbar)",
|
||||
"close": "Schließen",
|
||||
"noteNavigation": "Notiz Navigation",
|
||||
"goUpDown": "<kbd>Pfeil Hoch</kbd>, <kbd>Pfeil Runter</kbd> - In der Liste der Notizen nach oben/unten gehen",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - Knoten reduzieren/erweitern",
|
||||
"goUpDown": "In der Liste der Notizen nach oben/unten gehen",
|
||||
"collapseExpand": "Knoten reduzieren/erweitern",
|
||||
"notSet": "nicht eingestellt",
|
||||
"goBackForwards": "in der Historie zurück/vorwärts gehen",
|
||||
"showJumpToNoteDialog": "zeige <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Springe zu\" dialog</a>",
|
||||
"scrollToActiveNote": "Scrolle zur aktiven Notiz",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - Zur übergeordneten Notiz springen",
|
||||
"jumpToParentNote": "Zur übergeordneten Notiz springen",
|
||||
"collapseWholeTree": "Reduziere den gesamten Notizbaum",
|
||||
"collapseSubTree": "Teilbaum einklappen",
|
||||
"tabShortcuts": "Tab-Tastenkürzel",
|
||||
"newTabNoteLink": "<kbd>Strg+Klick</kbd> - (oder <kbd>mittlerer Mausklick</kbd> ) auf den Notizlink öffnet die Notiz in einem neuen Tab",
|
||||
"newTabNoteLink": "auf den Notizlink öffnet die Notiz in einem neuen Tab",
|
||||
"onlyInDesktop": "Nur im Desktop (Electron Build)",
|
||||
"openEmptyTab": "Leeren Tab öffnen",
|
||||
"closeActiveTab": "Aktiven Tab schließen",
|
||||
@@ -141,14 +140,14 @@
|
||||
"moveNoteUpHierarchy": "Verschiebe die Notiz in der Hierarchie nach oben",
|
||||
"multiSelectNote": "Mehrfachauswahl von Notizen oben/unten",
|
||||
"selectAllNotes": "Wähle alle Notizen in der aktuellen Ebene aus",
|
||||
"selectNote": "<kbd>Umschalt+Klick</kbd> - Notiz auswählen",
|
||||
"selectNote": "Notiz auswählen",
|
||||
"copyNotes": "Kopiere aktive Notiz (oder aktuelle Auswahl) in den Zwischenspeicher (wird genutzt für <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">Klonen</a>)",
|
||||
"cutNotes": "Aktuelle Notiz (oder aktuelle Auswahl) in die Zwischenablage ausschneiden (wird zum Verschieben von Notizen verwendet)",
|
||||
"pasteNotes": "Notiz(en) als Unternotiz in die aktive Notiz einfügen (entweder verschieben oder klonen, je nachdem, ob sie kopiert oder in die Zwischenablag e ausgeschnitten wurde)",
|
||||
"deleteNotes": "Notiz / Unterbaum löschen",
|
||||
"editingNotes": "Notizen bearbeiten",
|
||||
"editNoteTitle": "Im Baumbereich wird vom Baumbereich zum Notiztitel gewechselt. Beim Druck auf Eingabe im Notiztitel, wechselt der Fokus zum Texteditor. <kbd>Strg+.</kbd> wechselt vom Editor zurück zum Baumbereich.",
|
||||
"createEditLink": "<kbd>Strg+K</kbd> - Externen Link erstellen/bearbeiten",
|
||||
"createEditLink": "Externen Link erstellen/bearbeiten",
|
||||
"createInternalLink": "Internen Link erstellen",
|
||||
"followLink": "Folge dem Link unter dem Cursor",
|
||||
"insertDateTime": "Gebe das aktuelle Datum und die aktuelle Uhrzeit an der Einfügemarke ein",
|
||||
@@ -203,7 +202,7 @@
|
||||
"box_size_small": "klein (~ 10 Zeilen)",
|
||||
"box_size_medium": "mittel (~ 30 Zeilen)",
|
||||
"box_size_full": "vollständig (Feld zeigt vollständigen Text)",
|
||||
"button_include": "Notiz beifügen <kbd>Eingabetaste</kbd>"
|
||||
"button_include": "Notiz beifügen"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Infonachricht",
|
||||
@@ -212,13 +211,13 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Schließen",
|
||||
"search_button": "Suche im Volltext: <kbd>Strg+Eingabetaste</kbd>"
|
||||
"search_button": "Suche im Volltext"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown-Import",
|
||||
"close": "Schließen",
|
||||
"modal_body_text": "Aufgrund der Browser-Sandbox ist es nicht möglich, die Zwischenablage direkt aus JavaScript zu lesen. Bitte füge den zu importierenden Markdown in den Textbereich unten ein und klicke auf die Schaltfläche „Importieren“.",
|
||||
"import_button": "Importieren Strg+Eingabe",
|
||||
"import_button": "Importieren",
|
||||
"import_success": "Markdown-Inhalt wurde in das Dokument importiert."
|
||||
},
|
||||
"move_to": {
|
||||
@@ -227,7 +226,7 @@
|
||||
"notes_to_move": "Notizen zum Verschieben",
|
||||
"target_parent_note": "Ziel-Elternnotiz",
|
||||
"search_placeholder": "Suche nach einer Notiz anhand ihres Namens",
|
||||
"move_button": "Zur ausgewählten Notiz wechseln <kbd>Eingabetaste</kbd>",
|
||||
"move_button": "Zur ausgewählten Notiz wechseln",
|
||||
"error_no_path": "Kein Weg, auf den man sich bewegen kann.",
|
||||
"move_success_message": "Ausgewählte Notizen wurden verschoben"
|
||||
},
|
||||
@@ -235,18 +234,17 @@
|
||||
"modal_title": "Wähle den Notiztyp aus",
|
||||
"close": "Schließen",
|
||||
"modal_body": "Wähle den Notiztyp / die Vorlage der neuen Notiz:",
|
||||
"templates": "Vorlagen:"
|
||||
"templates": "Vorlagen"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Das Passwort ist nicht festgelegt",
|
||||
"close": "Schließen",
|
||||
"body1": "Geschützte Notizen werden mit einem Benutzerpasswort verschlüsselt, es wurde jedoch noch kein Passwort festgelegt.",
|
||||
"body2": "Um Notizen verschlüsseln zu können, klicke <a class=\"open-password-options-button\" href=\"javascript:\">hier</a> um das Optionsmenu zu öffnen und ein Passwort zu setzen."
|
||||
"body1": "Geschützte Notizen werden mit einem Benutzerpasswort verschlüsselt, es wurde jedoch noch kein Passwort festgelegt."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Schließen",
|
||||
"ok": "OK <kbd>Eingabe</kbd>",
|
||||
"ok": "OK",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -254,7 +252,7 @@
|
||||
"help_title": "Hilfe zu geschützten Notizen",
|
||||
"close_label": "Schließen",
|
||||
"form_label": "Um mit der angeforderten Aktion fortzufahren, musst du eine geschützte Sitzung starten, indem du ein Passwort eingibst:",
|
||||
"start_button": "Geschützte Sitzung starten <kbd>enter</kbd>"
|
||||
"start_button": "Geschützte Sitzung starten"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Aktuelle Änderungen",
|
||||
@@ -304,13 +302,13 @@
|
||||
"sort_with_respect_to_different_character_sorting": "Sortierung im Hinblick auf unterschiedliche Sortier- und Sortierregeln für Zeichen in verschiedenen Sprachen oder Regionen.",
|
||||
"natural_sort_language": "Natürliche Sortiersprache",
|
||||
"the_language_code_for_natural_sort": "Der Sprachcode für die natürliche Sortierung, z. B. \"de-DE\" für Deutsch.",
|
||||
"sort": "Sortieren <kbd>Eingabetaste</kbd>"
|
||||
"sort": "Sortieren"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Lade Anhänge zur Notiz hoch",
|
||||
"close": "Schließen",
|
||||
"choose_files": "Wähle Dateien aus",
|
||||
"files_will_be_uploaded": "Dateien werden als Anhänge in hochgeladen",
|
||||
"files_will_be_uploaded": "Dateien werden als Anhänge in hochgeladen {{noteTitle}}",
|
||||
"options": "Optionen",
|
||||
"shrink_images": "Bilder verkleinern",
|
||||
"upload": "Hochladen",
|
||||
@@ -1651,5 +1649,8 @@
|
||||
},
|
||||
"time_selector": {
|
||||
"invalid_input": "Die eingegebene Zeit ist keine valide Zahl."
|
||||
},
|
||||
"modal": {
|
||||
"close": "Schließen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "About Trilium Notes",
|
||||
"close": "Close",
|
||||
"homepage": "Homepage:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:",
|
||||
@@ -28,25 +27,22 @@
|
||||
"add_link": {
|
||||
"add_link": "Add link",
|
||||
"help_on_links": "Help on links",
|
||||
"close": "Close",
|
||||
"note": "Note",
|
||||
"search_note": "search for note by its name",
|
||||
"link_title_mirrors": "link title mirrors the note's current title",
|
||||
"link_title_arbitrary": "link title can be changed arbitrarily",
|
||||
"link_title": "Link title",
|
||||
"button_add_link": "Add link <kbd>enter</kbd>"
|
||||
"button_add_link": "Add link"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Edit branch prefix",
|
||||
"help_on_tree_prefix": "Help on Tree prefix",
|
||||
"close": "Close",
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Save",
|
||||
"branch_prefix_saved": "Branch prefix has been saved."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Bulk actions",
|
||||
"close": "Close",
|
||||
"affected_notes": "Affected notes",
|
||||
"include_descendants": "Include descendants of the selected notes",
|
||||
"available_actions": "Available actions",
|
||||
@@ -61,20 +57,18 @@
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clone notes to...",
|
||||
"close": "Close",
|
||||
"help_on_links": "Help on links",
|
||||
"notes_to_clone": "Notes to clone",
|
||||
"target_parent_note": "Target parent note",
|
||||
"search_for_note_by_its_name": "search for note by its name",
|
||||
"cloned_note_prefix_title": "Cloned note will be shown in note tree with given prefix",
|
||||
"prefix_optional": "Prefix (optional)",
|
||||
"clone_to_selected_note": "Clone to selected note <kbd>enter</kbd>",
|
||||
"clone_to_selected_note": "Clone to selected note",
|
||||
"no_path_to_clone_to": "No path to clone to.",
|
||||
"note_cloned": "Note \"{{clonedTitle}}\" has been cloned into \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmation",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "Are you sure you want to remove the note \"{{title}}\" from relation map? ",
|
||||
@@ -87,9 +81,9 @@
|
||||
"delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
|
||||
"erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
|
||||
"erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
|
||||
"notes_to_be_deleted": "Following notes will be deleted ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
|
||||
"no_note_to_delete": "No note will be deleted (only clones).",
|
||||
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
|
||||
@@ -113,21 +107,20 @@
|
||||
"format_pdf": "PDF - for printing or sharing purposes."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Help (full documentation is available <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Close",
|
||||
"title": "Cheatsheet",
|
||||
"noteNavigation": "Note navigation",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - go up/down in the list of notes",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - collapse/expand node",
|
||||
"goUpDown": "go up/down in the list of notes",
|
||||
"collapseExpand": "collapse/expand node",
|
||||
"notSet": "not set",
|
||||
"goBackForwards": "go back / forwards in the history",
|
||||
"showJumpToNoteDialog": "show <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Jump to\" dialog</a>",
|
||||
"scrollToActiveNote": "scroll to active note",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - jump to parent note",
|
||||
"jumpToParentNote": "jump to parent note",
|
||||
"collapseWholeTree": "collapse whole note tree",
|
||||
"collapseSubTree": "collapse sub-tree",
|
||||
"tabShortcuts": "Tab shortcuts",
|
||||
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (or <kbd>middle mouse click</kbd>) on note link opens note in a new tab",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (or <kbd>Shift+middle mouse click</kbd>) on note link opens and activates the note in a new tab",
|
||||
"newTabNoteLink": "on note link opens note in a new tab",
|
||||
"newTabWithActivationNoteLink": "on note link opens and activates the note in a new tab",
|
||||
"onlyInDesktop": "Only in desktop (Electron build)",
|
||||
"openEmptyTab": "open empty tab",
|
||||
"closeActiveTab": "close active tab",
|
||||
@@ -142,14 +135,14 @@
|
||||
"moveNoteUpHierarchy": "move note up in the hierarchy",
|
||||
"multiSelectNote": "multi-select note above/below",
|
||||
"selectAllNotes": "select all notes in the current level",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - select note",
|
||||
"selectNote": "select note",
|
||||
"copyNotes": "copy active note (or current selection) into clipboard (used for <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">cloning</a>)",
|
||||
"cutNotes": "cut current note (or current selection) into clipboard (used for moving notes)",
|
||||
"pasteNotes": "paste note(s) as sub-note into active note (which is either move or clone depending on whether it was copied or cut into clipboard)",
|
||||
"deleteNotes": "delete note / sub-tree",
|
||||
"editingNotes": "Editing notes",
|
||||
"editNoteTitle": "in tree pane will switch from tree pane into note title. Enter from note title will switch focus to text editor. <kbd>Ctrl+.</kbd> will switch back from editor to tree pane.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - create / edit external link",
|
||||
"createEditLink": "create / edit external link",
|
||||
"createInternalLink": "create internal link",
|
||||
"followLink": "follow link under cursor",
|
||||
"insertDateTime": "insert current date and time at caret position",
|
||||
@@ -169,7 +162,6 @@
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Import into note",
|
||||
"close": "Close",
|
||||
"chooseImportFile": "Choose import file",
|
||||
"importDescription": "Content of the selected file(s) will be imported as child note(s) into",
|
||||
"options": "Options",
|
||||
@@ -196,14 +188,13 @@
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Include note",
|
||||
"close": "Close",
|
||||
"label_note": "Note",
|
||||
"placeholder_search": "search for note by its name",
|
||||
"box_size_prompt": "Box size of the included note:",
|
||||
"box_size_small": "small (~ 10 lines)",
|
||||
"box_size_medium": "medium (~ 30 lines)",
|
||||
"box_size_full": "full (box shows complete text)",
|
||||
"button_include": "Include note <kbd>enter</kbd>"
|
||||
"button_include": "Include note"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Info message",
|
||||
@@ -212,23 +203,20 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Search for note by its name or type > for commands...",
|
||||
"close": "Close",
|
||||
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
|
||||
"search_button": "Search in full text"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown import",
|
||||
"close": "Close",
|
||||
"modal_body_text": "Because of browser sandbox it's not possible to directly read clipboard from JavaScript. Please paste the Markdown to import to textarea below and click on Import button",
|
||||
"import_button": "Import Ctrl+Enter",
|
||||
"import_button": "Import",
|
||||
"import_success": "Markdown content has been imported into the document."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Move notes to ...",
|
||||
"close": "Close",
|
||||
"notes_to_move": "Notes to move",
|
||||
"target_parent_note": "Target parent note",
|
||||
"search_placeholder": "search for note by its name",
|
||||
"move_button": "Move to selected note <kbd>enter</kbd>",
|
||||
"move_button": "Move to selected note",
|
||||
"error_no_path": "No path to move to.",
|
||||
"move_success_message": "Selected notes have been moved into "
|
||||
},
|
||||
@@ -236,20 +224,19 @@
|
||||
"change_path_prompt": "Change where to create the new note:",
|
||||
"search_placeholder": "search path by name (default if empty)",
|
||||
"modal_title": "Choose note type",
|
||||
"close": "Close",
|
||||
"modal_body": "Choose note type / template of the new note:",
|
||||
"templates": "Templates:"
|
||||
"templates": "Templates",
|
||||
"builtin_templates": "Built-in Templates"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Password is not set",
|
||||
"close": "Close",
|
||||
"body1": "Protected notes are encrypted using a user password, but password has not been set yet.",
|
||||
"body2": "To be able to protect notes, click <a class=\"open-password-options-button\" href=\"javascript:\">here</a> to open the Options dialog and set your password."
|
||||
"body2": "To be able to protect notes, click the button below to open the Options dialog and set your password.",
|
||||
"go_to_password_options": "Go to Password options"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Close",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"ok": "OK",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -257,12 +244,11 @@
|
||||
"help_title": "Help on Protected notes",
|
||||
"close_label": "Close",
|
||||
"form_label": "To proceed with requested action you need to start protected session by entering password:",
|
||||
"start_button": "Start protected session <kbd>enter</kbd>"
|
||||
"start_button": "Start protected session"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Recent changes",
|
||||
"erase_notes_button": "Erase deleted notes now",
|
||||
"close": "Close",
|
||||
"deleted_notes_message": "Deleted notes have been erased.",
|
||||
"no_changes_message": "No changes yet...",
|
||||
"undelete_link": "undelete",
|
||||
@@ -273,7 +259,6 @@
|
||||
"delete_all_revisions": "Delete all revisions of this note",
|
||||
"delete_all_button": "Delete all revisions",
|
||||
"help_title": "Help on Note Revisions",
|
||||
"close": "Close",
|
||||
"revision_last_edited": "This revision was last edited on {{date}}",
|
||||
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
||||
"no_revisions": "No revisions for this note yet...",
|
||||
@@ -295,7 +280,6 @@
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Sort children by...",
|
||||
"close": "Close",
|
||||
"sorting_criteria": "Sorting criteria",
|
||||
"title": "title",
|
||||
"date_created": "date created",
|
||||
@@ -309,13 +293,12 @@
|
||||
"sort_with_respect_to_different_character_sorting": "sort with respect to different character sorting and collation rules in different languages or regions.",
|
||||
"natural_sort_language": "Natural sort language",
|
||||
"the_language_code_for_natural_sort": "The language code for natural sort, e.g. \"zh-CN\" for Chinese.",
|
||||
"sort": "Sort <kbd>enter</kbd>"
|
||||
"sort": "Sort"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Upload attachments to note",
|
||||
"close": "Close",
|
||||
"choose_files": "Choose files",
|
||||
"files_will_be_uploaded": "Files will be uploaded as attachments into",
|
||||
"files_will_be_uploaded": "Files will be uploaded as attachments into {{noteTitle}}",
|
||||
"options": "Options",
|
||||
"shrink_images": "Shrink images",
|
||||
"upload": "Upload",
|
||||
@@ -2007,6 +1990,7 @@
|
||||
"open_externally": "Open externally"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"help_title": "Display more information about this screen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"link_title_mirrors": "el título del enlace replica el título actual de la nota",
|
||||
"link_title_arbitrary": "el título del enlace se puede cambiar arbitrariamente",
|
||||
"link_title": "Título del enlace",
|
||||
"button_add_link": "Agregar enlace <kbd>Enter</kbd>"
|
||||
"button_add_link": "Agregar enlace"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Editar prefijo de rama",
|
||||
@@ -68,7 +68,7 @@
|
||||
"search_for_note_by_its_name": "buscar nota por su nombre",
|
||||
"cloned_note_prefix_title": "La nota clonada se mostrará en el árbol de notas con el prefijo dado",
|
||||
"prefix_optional": "Prefijo (opcional)",
|
||||
"clone_to_selected_note": "Clonar a nota seleccionada <kbd>enter</kbd>",
|
||||
"clone_to_selected_note": "Clonar a nota seleccionada",
|
||||
"no_path_to_clone_to": "No hay ruta para clonar.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" a sido clonada en \"{{targetTitle}}\""
|
||||
},
|
||||
@@ -87,9 +87,9 @@
|
||||
"delete_all_clones_description": "Eliminar también todos los clones (se puede deshacer en cambios recientes)",
|
||||
"erase_notes_description": "La eliminación normal (suave) solo marca las notas como eliminadas y se pueden recuperar (en el cuadro de diálogo de cambios recientes) dentro de un periodo de tiempo. Al marcar esta opción se borrarán las notas inmediatamente y no será posible recuperarlas.",
|
||||
"erase_notes_warning": "Eliminar notas permanentemente (no se puede deshacer), incluidos todos los clones. Esto forzará la recarga de la aplicación.",
|
||||
"notes_to_be_deleted": "Las siguientes notas serán eliminadas ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "Las siguientes notas serán eliminadas ({{notesCount}})",
|
||||
"no_note_to_delete": "No se eliminará ninguna nota (solo clones).",
|
||||
"broken_relations_to_be_deleted": "Las siguientes relaciones se romperán y serán eliminadas ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "Las siguientes relaciones se romperán y serán eliminadas ({{ relationCount}})",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "Aceptar",
|
||||
"deleted_relation_text": "Nota {{- note}} (para ser eliminada) está referenciado por la relación {{- relation}} que se origina en {{- source}}."
|
||||
@@ -113,21 +113,20 @@
|
||||
"format_pdf": "PDF - para propósitos de impresión o compartición."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Ayuda (la documentación completa está disponible <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Cerrar",
|
||||
"noteNavigation": "Navegación de notas",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - subir/bajar en la lista de notas",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - colapsar/expandir nodo",
|
||||
"goUpDown": "subir/bajar en la lista de notas",
|
||||
"collapseExpand": "colapsar/expandir nodo",
|
||||
"notSet": "no establecido",
|
||||
"goBackForwards": "retroceder / avanzar en la historia",
|
||||
"showJumpToNoteDialog": "mostrar <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Saltar a\" diálogo</a>",
|
||||
"scrollToActiveNote": "desplazarse hasta la nota activa",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - saltar a la nota padre",
|
||||
"jumpToParentNote": "saltar a la nota padre",
|
||||
"collapseWholeTree": "colapsar todo el árbol de notas",
|
||||
"collapseSubTree": "colapsar subárbol",
|
||||
"tabShortcuts": "Atajos de pestañas",
|
||||
"newTabNoteLink": "<kbd>CTRL+clic</kbd> - (o <kbd>clic central del mouse</kbd>) en el enlace de la nota abre la nota en una nueva pestaña",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+clic</kbd> - (o <kbd>Shift+clic de rueda de ratón</kbd>) en el enlace de la nota abre y activa la nota en una nueva pestaña",
|
||||
"newTabNoteLink": "en el enlace de la nota abre la nota en una nueva pestaña",
|
||||
"newTabWithActivationNoteLink": "en el enlace de la nota abre y activa la nota en una nueva pestaña",
|
||||
"onlyInDesktop": "Solo en escritorio (compilación con Electron)",
|
||||
"openEmptyTab": "abrir pestaña vacía",
|
||||
"closeActiveTab": "cerrar pestaña activa",
|
||||
@@ -142,14 +141,14 @@
|
||||
"moveNoteUpHierarchy": "mover nota hacia arriba en la jerarquía",
|
||||
"multiSelectNote": "selección múltiple de nota hacia arriba/abajo",
|
||||
"selectAllNotes": "seleccionar todas las notas en el nivel actual",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - seleccionar nota",
|
||||
"selectNote": "seleccionar nota",
|
||||
"copyNotes": "copiar nota activa (o selección actual) al portapapeles (usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonar</a>)",
|
||||
"cutNotes": "cortar la nota actual (o la selección actual) en el portapapeles (usado para mover notas)",
|
||||
"pasteNotes": "pegar notas como subnotas en la nota activa (que se puede mover o clonar dependiendo de si se copió o cortó en el portapapeles)",
|
||||
"deleteNotes": "eliminar nota/subárbol",
|
||||
"editingNotes": "Editando notas",
|
||||
"editNoteTitle": "en el panel de árbol cambiará del panel de árbol al título de la nota. Ingresar desde el título de la nota cambiará el foco al editor de texto. <kbd>Ctrl+.</kbd> cambiará de nuevo del editor al panel de árbol.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - crear/editar enlace externo",
|
||||
"createEditLink": "crear/editar enlace externo",
|
||||
"createInternalLink": "crear enlace interno",
|
||||
"followLink": "siga el enlace debajo del cursor",
|
||||
"insertDateTime": "insertar la fecha y hora actuales en la posición del cursor",
|
||||
@@ -203,7 +202,7 @@
|
||||
"box_size_small": "pequeño (~ 10 líneas)",
|
||||
"box_size_medium": "medio (~ 30 líneas)",
|
||||
"box_size_full": "completo (el cuadro muestra el texto completo)",
|
||||
"button_include": "Incluir nota <kbd>Enter</kbd>"
|
||||
"button_include": "Incluir nota"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Mensaje informativo",
|
||||
@@ -212,14 +211,14 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Cerrar",
|
||||
"search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>",
|
||||
"search_button": "Buscar en texto completo",
|
||||
"search_placeholder": "Busque nota por su nombre o escriba > para comandos..."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importación de Markdown",
|
||||
"close": "Cerrar",
|
||||
"modal_body_text": "Debido al entorno limitado del navegador, no es posible leer directamente el portapapeles desde JavaScript. Por favor, pegue el código Markdown para importar en el área de texto a continuación y haga clic en el botón Importar",
|
||||
"import_button": "Importar Ctrl+Enter",
|
||||
"import_button": "Importar",
|
||||
"import_success": "El contenido de Markdown se ha importado al documento."
|
||||
},
|
||||
"move_to": {
|
||||
@@ -228,7 +227,7 @@
|
||||
"notes_to_move": "Notas a mover",
|
||||
"target_parent_note": "Nota padre de destino",
|
||||
"search_placeholder": "buscar nota por su nombre",
|
||||
"move_button": "Mover a la nota seleccionada <kbd>enter</kbd>",
|
||||
"move_button": "Mover a la nota seleccionada",
|
||||
"error_no_path": "No hay ruta a donde mover.",
|
||||
"move_success_message": "Las notas seleccionadas se han movido a "
|
||||
},
|
||||
@@ -238,18 +237,17 @@
|
||||
"modal_title": "Elija el tipo de nota",
|
||||
"close": "Cerrar",
|
||||
"modal_body": "Elija el tipo de nota/plantilla de la nueva nota:",
|
||||
"templates": "Plantillas:"
|
||||
"templates": "Plantillas"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "La contraseña no está establecida",
|
||||
"close": "Cerrar",
|
||||
"body1": "Las notas protegidas se cifran mediante una contraseña de usuario, pero la contraseña aún no se ha establecido.",
|
||||
"body2": "Para poder proteger notas, dé clic <a class=\"open-password-options-button\" href=\"javascript:\">aquí</a> para abrir el diálogo de Opciones y establecer tu contraseña."
|
||||
"body1": "Las notas protegidas se cifran mediante una contraseña de usuario, pero la contraseña aún no se ha establecido."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Aviso",
|
||||
"close": "Cerrar",
|
||||
"ok": "Aceptar <kbd>enter</kbd>",
|
||||
"ok": "Aceptar",
|
||||
"defaultTitle": "Aviso"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -257,7 +255,7 @@
|
||||
"help_title": "Ayuda sobre notas protegidas",
|
||||
"close_label": "Cerrar",
|
||||
"form_label": "Para continuar con la acción solicitada, debe iniciar en la sesión protegida ingresando la contraseña:",
|
||||
"start_button": "Iniciar sesión protegida <kbd>entrar</kbd>"
|
||||
"start_button": "Iniciar sesión protegida"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Cambios recientes",
|
||||
@@ -309,13 +307,13 @@
|
||||
"sort_with_respect_to_different_character_sorting": "ordenar con respecto a diferentes reglas de ordenamiento y clasificación de caracteres en diferentes idiomas o regiones.",
|
||||
"natural_sort_language": "Idioma de clasificación natural",
|
||||
"the_language_code_for_natural_sort": "El código del idioma para el ordenamiento natural, ej. \"zh-CN\" para Chino.",
|
||||
"sort": "Ordenar <kbd>Enter</kbd>"
|
||||
"sort": "Ordenar"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Cargar archivos adjuntos a nota",
|
||||
"close": "Cerrar",
|
||||
"choose_files": "Elija los archivos",
|
||||
"files_will_be_uploaded": "Los archivos se cargarán como archivos adjuntos en",
|
||||
"files_will_be_uploaded": "Los archivos se cargarán como archivos adjuntos en {{noteTitle}}",
|
||||
"options": "Opciones",
|
||||
"shrink_images": "Reducir imágenes",
|
||||
"upload": "Subir",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"link_title_mirrors": "le titre du lien reflète le titre actuel de la note",
|
||||
"link_title_arbitrary": "le titre du lien peut être modifié arbitrairement",
|
||||
"link_title": "Titre du lien",
|
||||
"button_add_link": "Ajouter un lien <kbd>Entrée</kbd>"
|
||||
"button_add_link": "Ajouter un lien"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifier le préfixe de branche",
|
||||
@@ -68,7 +68,7 @@
|
||||
"search_for_note_by_its_name": "rechercher une note par son nom",
|
||||
"cloned_note_prefix_title": "La note clonée sera affichée dans l'arbre des notes avec le préfixe donné",
|
||||
"prefix_optional": "Préfixe (facultatif)",
|
||||
"clone_to_selected_note": "Cloner vers la note sélectionnée <kbd>entrer</kbd>",
|
||||
"clone_to_selected_note": "Cloner vers la note sélectionnée",
|
||||
"no_path_to_clone_to": "Aucun chemin vers lequel cloner.",
|
||||
"note_cloned": "La note \"{{clonedTitle}}\" a été clonée dans \"{{targetTitle}}\""
|
||||
},
|
||||
@@ -87,9 +87,9 @@
|
||||
"delete_all_clones_description": "Supprimer aussi les clones (peut être annulé dans des modifications récentes)",
|
||||
"erase_notes_description": "La suppression normale (douce) marque uniquement les notes comme supprimées et elles peuvent être restaurées (dans la boîte de dialogue des Modifications récentes) dans un délai donné. Cocher cette option effacera les notes immédiatement et il ne sera pas possible de les restaurer.",
|
||||
"erase_notes_warning": "Efface les notes de manière permanente (ne peut pas être annulée), y compris les clones. L'application va être rechargée.",
|
||||
"notes_to_be_deleted": "Les notes suivantes seront supprimées ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "Les notes suivantes seront supprimées ({{notesCount}})",
|
||||
"no_note_to_delete": "Aucune note ne sera supprimée (uniquement les clones).",
|
||||
"broken_relations_to_be_deleted": "Les relations suivantes seront rompues et supprimées ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "Les relations suivantes seront rompues et supprimées ({{ relationCount}})",
|
||||
"cancel": "Annuler",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Note {{- note}} (à supprimer) est référencée dans la relation {{- relation}} provenant de {{- source}}."
|
||||
@@ -113,20 +113,19 @@
|
||||
"format_pdf": "PDF - pour l'impression ou le partage de documents."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Aide (la documentation complète est disponible <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">en ligne</a>)",
|
||||
"close": "Fermer",
|
||||
"noteNavigation": "Navigation dans les notes",
|
||||
"goUpDown": "<kbd>HAUT</kbd>, <kbd>BAS</kbd> - aller vers le haut/bas dans la liste des notes",
|
||||
"collapseExpand": "<kbd>GAUCHE</kbd>, <kbd>DROITE</kbd> - réduire/développer le nœud",
|
||||
"goUpDown": "aller vers le haut/bas dans la liste des notes",
|
||||
"collapseExpand": "réduire/développer le nœud",
|
||||
"notSet": "non défini",
|
||||
"goBackForwards": "reculer/avancer dans l'historique",
|
||||
"showJumpToNoteDialog": "afficher la <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">boîte de dialogue \"Aller à la note\"</a>",
|
||||
"scrollToActiveNote": "faire défiler jusqu'à la note active",
|
||||
"jumpToParentNote": "<kbd>Retour arrière</kbd> - aller à la note parent",
|
||||
"jumpToParentNote": "aller à la note parent",
|
||||
"collapseWholeTree": "réduire tout l'arbre des notes",
|
||||
"collapseSubTree": "réduire le sous-arbre",
|
||||
"tabShortcuts": "Raccourcis des onglets",
|
||||
"newTabNoteLink": "<kbd>CTRL+clic</kbd> - (ou clic central de la souris) sur le lien de la note ouvre la note dans un nouvel onglet",
|
||||
"newTabNoteLink": "sur le lien de la note ouvre la note dans un nouvel onglet",
|
||||
"onlyInDesktop": "Uniquement sur ordinateur (version Electron)",
|
||||
"openEmptyTab": "ouvrir un onglet vide",
|
||||
"closeActiveTab": "fermer l'onglet actif",
|
||||
@@ -141,14 +140,14 @@
|
||||
"moveNoteUpHierarchy": "déplacer la note vers le haut dans la hiérarchie",
|
||||
"multiSelectNote": "sélectionner plusieurs notes au-dessus/au-dessous",
|
||||
"selectAllNotes": "sélectionner toutes les notes du niveau actuel",
|
||||
"selectNote": "<kbd>Shift+clic</kbd> - sélectionner une note",
|
||||
"selectNote": "sélectionner une note",
|
||||
"copyNotes": "copier la note active (ou la sélection actuelle) dans le presse-papiers (utilisé pour le <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonage</a>)",
|
||||
"cutNotes": "couper la note actuelle (ou la sélection actuelle) dans le presse-papiers (utilisé pour déplacer les notes)",
|
||||
"pasteNotes": "coller la ou les notes en tant que sous-note dans la note active (qui est soit déplacée, soit clonée selon qu'elle a été copiée ou coupée dans le presse-papiers)",
|
||||
"deleteNotes": "supprimer une note / un sous-arbre",
|
||||
"editingNotes": "Édition des notes",
|
||||
"editNoteTitle": "dans le volet de l'arborescence, basculera du volet au titre de la note. Presser Entrer à partir du titre de la note basculera vers l’éditeur de texte. <kbd>Ctrl+.</kbd> bascule de l'éditeur au volet arborescent.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - créer/éditer un lien externe",
|
||||
"createEditLink": "créer/éditer un lien externe",
|
||||
"createInternalLink": "créer un lien interne",
|
||||
"followLink": "suivre le lien sous le curseur",
|
||||
"insertDateTime": "insérer la date et l'heure courante à la position du curseur",
|
||||
@@ -202,7 +201,7 @@
|
||||
"box_size_small": "petit (~ 10 lignes)",
|
||||
"box_size_medium": "moyen (~ 30 lignes)",
|
||||
"box_size_full": "complet (la boîte affiche le texte complet)",
|
||||
"button_include": "Inclure une note <kbd>Entrée</kbd>"
|
||||
"button_include": "Inclure une note"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Message d'information",
|
||||
@@ -211,13 +210,13 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Fermer",
|
||||
"search_button": "Rechercher dans le texte intégral <kbd>Ctrl+Entrée</kbd>"
|
||||
"search_button": "Rechercher dans le texte intégral"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importation Markdown",
|
||||
"close": "Fermer",
|
||||
"modal_body_text": "En raison du bac à sable du navigateur, il n'est pas possible de lire directement le presse-papiers à partir de JavaScript. Veuillez coller le Markdown à importer dans la zone de texte ci-dessous et cliquez sur le bouton Importer",
|
||||
"import_button": "Importer Ctrl+Entrée",
|
||||
"import_button": "Importer",
|
||||
"import_success": "Le contenu Markdown a été importé dans le document."
|
||||
},
|
||||
"move_to": {
|
||||
@@ -226,7 +225,7 @@
|
||||
"notes_to_move": "Notes à déplacer",
|
||||
"target_parent_note": "Note parent cible",
|
||||
"search_placeholder": "rechercher une note par son nom",
|
||||
"move_button": "Déplacer vers la note sélectionnée <kbd>entrer</kbd>",
|
||||
"move_button": "Déplacer vers la note sélectionnée",
|
||||
"error_no_path": "Aucun chemin vers lequel déplacer.",
|
||||
"move_success_message": "Les notes sélectionnées ont été déplacées dans "
|
||||
},
|
||||
@@ -234,18 +233,17 @@
|
||||
"modal_title": "Choisissez le type de note",
|
||||
"close": "Fermer",
|
||||
"modal_body": "Choisissez le type de note/le modèle de la nouvelle note :",
|
||||
"templates": "Modèles :"
|
||||
"templates": "Modèles"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Le mot de passe n'est pas défini",
|
||||
"close": "Fermer",
|
||||
"body1": "Les notes protégées sont cryptées à l'aide d'un mot de passe utilisateur, mais le mot de passe n'a pas encore été défini.",
|
||||
"body2": "Pour pouvoir protéger les notes, cliquez <a class=\"open-password-options-button\" href=\"javascript:\">ici</a> pour ouvrir les Options et définir votre mot de passe."
|
||||
"body1": "Les notes protégées sont cryptées à l'aide d'un mot de passe utilisateur, mais le mot de passe n'a pas encore été défini."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Fermer",
|
||||
"ok": "OK <kbd>entrer</kbd>",
|
||||
"ok": "OK",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -253,7 +251,7 @@
|
||||
"help_title": "Aide sur les notes protégées",
|
||||
"close_label": "Fermer",
|
||||
"form_label": "Pour procéder à l'action demandée, vous devez démarrer une session protégée en saisissant le mot de passe :",
|
||||
"start_button": "Démarrer une session protégée <kbd>entrer</kbd>"
|
||||
"start_button": "Démarrer une session protégée"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Modifications récentes",
|
||||
@@ -303,13 +301,13 @@
|
||||
"sort_with_respect_to_different_character_sorting": "trier en fonction de différentes règles de tri et de classement des caractères dans différentes langues ou régions.",
|
||||
"natural_sort_language": "Langage de tri naturel",
|
||||
"the_language_code_for_natural_sort": "Le code de langue pour le tri naturel, par ex. \"zh-CN\" pour le chinois.",
|
||||
"sort": "Trier <kbd>Entrée</kbd>"
|
||||
"sort": "Trier"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Téléverser des pièces jointes à la note",
|
||||
"close": "Fermer",
|
||||
"choose_files": "Choisir des fichiers",
|
||||
"files_will_be_uploaded": "Les fichiers seront téléversés sous forme de pièces jointes dans",
|
||||
"files_will_be_uploaded": "Les fichiers seront téléversés sous forme de pièces jointes dans {{noteTitle}}",
|
||||
"options": "Options",
|
||||
"shrink_images": "Réduire les images",
|
||||
"upload": "Téléverser",
|
||||
@@ -1669,5 +1667,8 @@
|
||||
},
|
||||
"multi_factor_authentication": {
|
||||
"oauth_user_email": "Courriel de l'utilisateur : "
|
||||
},
|
||||
"modal": {
|
||||
"close": "Fermer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"link_title_mirrors": "titlul legăturii corespunde titlul curent al notiței",
|
||||
"note": "Notiță",
|
||||
"search_note": "căutați notița după nume",
|
||||
"button_add_link": "Adaugă legătură <kbd>Enter</kbd>"
|
||||
"button_add_link": "Adaugă legătură"
|
||||
},
|
||||
"add_relation": {
|
||||
"add_relation": "Adaugă relație",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clonează notițele către...",
|
||||
"clone_to_selected_note": "Clonează notița selectată <kbd>enter</kbd>",
|
||||
"clone_to_selected_note": "Clonează notița selectată",
|
||||
"cloned_note_prefix_title": "Notița clonată va fi afișată în ierarhia notiței utilizând prefixul dat",
|
||||
"help_on_links": "Informații despre legături",
|
||||
"no_path_to_clone_to": "Nicio cale de clonat.",
|
||||
@@ -436,14 +436,14 @@
|
||||
"undelete_notes_instruction": "După ștergere, se pot recupera din ecranul Schimbări recente."
|
||||
},
|
||||
"delete_notes": {
|
||||
"broken_relations_to_be_deleted": "Următoarele relații vor fi întrerupte și șterse ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "Următoarele relații vor fi întrerupte și șterse ({{ relationCount}})",
|
||||
"cancel": "Anulează",
|
||||
"delete_all_clones_description": "Șterge și toate clonele (se pot recupera în ecranul Schimbări recente)",
|
||||
"delete_notes_preview": "Previzualizare ștergerea notițelor",
|
||||
"erase_notes_description": "Ștergerea obișnuită doar marchează notițele ca fiind șterse și pot fi recuperate (în ecranul Schimbări recente) pentru o perioadă de timp. Dacă se bifează această opțiune, notițele vor fi șterse imediat fără posibilitatea de a le recupera.",
|
||||
"erase_notes_warning": "Șterge notițele permanent (nu se mai pot recupera), incluzând toate clonele. Va forța reîncărcarea aplicației.",
|
||||
"no_note_to_delete": "Nicio notiță nu va fi ștearsă (doar clonele).",
|
||||
"notes_to_be_deleted": "Următoarele notițe vor fi șterse ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "Următoarele notițe vor fi șterse ({{notesCount}})",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Notița {{- note}} ce va fi ștearsă este referențiată de relația {{- relation}}, originând din {{- source}}.",
|
||||
"close": "Închide"
|
||||
@@ -617,11 +617,11 @@
|
||||
"bulletList": "<code>*</code> sau <code>-</code> urmat de spațiu pentru o listă punctată",
|
||||
"close": "Închide",
|
||||
"closeActiveTab": "închide tabul activ",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - minimizează/expandează nodul",
|
||||
"collapseExpand": "minimizează/expandează nodul",
|
||||
"collapseSubTree": "minimizează subarborele",
|
||||
"collapseWholeTree": "minimizează întregul arbore de notițe",
|
||||
"copyNotes": "copiază notița activă (sau selecția curentă) în clipboard (utilizat pentru <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonare</a>)",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - crează/editează legătură externă",
|
||||
"createEditLink": "crează/editează legătură externă",
|
||||
"createInternalLink": "crează legătură internă",
|
||||
"createNoteAfter": "crează o nouă notiță după notița activă",
|
||||
"createNoteInto": "crează o subnotiță în notița activă",
|
||||
@@ -632,20 +632,19 @@
|
||||
"editNoteTitle": "va sări de la arborele de notițe către titlul notiței. Enter de la titlul notiței va sări către editorul de text. <kbd>Ctrl+.</kbd> va sări înapoi de la editor către arborele de notițe.",
|
||||
"editingNotes": "Editarea notițelor",
|
||||
"followLink": "urmărește link-ul sub cursor",
|
||||
"fullDocumentation": "Instrucțiuni (documentația completă se regăsește <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"goBackForwards": "mergi înapoi/înainte în istoric",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - mergi sus/jos în lista de notițe",
|
||||
"goUpDown": "mergi sus/jos în lista de notițe",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> etc. urmat de spațiu pentru titluri",
|
||||
"inPageSearch": "caută în interiorul paginii",
|
||||
"insertDateTime": "inserează data și timpul curente la poziția cursorului",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - sari la pagina părinte",
|
||||
"jumpToParentNote": "sari la pagina părinte",
|
||||
"jumpToTreePane": "sari către arborele de notițe și scrolează către notița activă",
|
||||
"markdownAutoformat": "Formatare în stil Markdown",
|
||||
"moveNoteUpDown": "mută notița sus/jos în lista de notițe",
|
||||
"moveNoteUpHierarchy": "mută notița mai sus în ierarhie",
|
||||
"movingCloningNotes": "Mutarea/clonarea notițelor",
|
||||
"multiSelectNote": "selectează multiplu notița de sus/jos",
|
||||
"newTabNoteLink": "<kbd>CTRL+clic</kbd> - (sau clic mijlociu) pe o legătură către o notiță va deschide notița într-un tab nou",
|
||||
"newTabNoteLink": "pe o legătură către o notiță va deschide notița într-un tab nou",
|
||||
"notSet": "nesetat",
|
||||
"noteNavigation": "Navigarea printre notițe",
|
||||
"numberedList": "<kbd>1.</code> sau <code>1)</code> urmat de spațiu pentru o listă numerotată",
|
||||
@@ -657,13 +656,13 @@
|
||||
"reloadFrontend": "reîncarcă interfața Trilium",
|
||||
"scrollToActiveNote": "scrolează la notița activă",
|
||||
"selectAllNotes": "selectează toate notițele din nivelul curent",
|
||||
"selectNote": "<kbd>Shift+Click</kbd> - selectează notița",
|
||||
"selectNote": "selectează notița",
|
||||
"showDevTools": "afișează instrumentele de dezvoltatori",
|
||||
"showJumpToNoteDialog": "afișează <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">ecranul „Sari la”</a>",
|
||||
"showSQLConsole": "afișează consola SQL",
|
||||
"tabShortcuts": "Scurtături pentru tab-uri",
|
||||
"troubleshooting": "Unelte pentru depanare",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (sau <kbd>Shift+click mouse mijlociu</kbd>) pe o legătură către o notiță deschide și activează notița într-un tab nou"
|
||||
"newTabWithActivationNoteLink": "pe o legătură către o notiță deschide și activează notița într-un tab nou"
|
||||
},
|
||||
"hide_floating_buttons_button": {
|
||||
"button_title": "Ascunde butoanele"
|
||||
@@ -751,7 +750,7 @@
|
||||
"box_size_medium": "mediu (~ 30 de rânduri)",
|
||||
"box_size_prompt": "Dimensiunea căsuței notiței incluse:",
|
||||
"box_size_small": "mică (~ 10 rânduri)",
|
||||
"button_include": "Include notița <kbd>Enter</kbd>",
|
||||
"button_include": "Include notița",
|
||||
"dialog_title": "Includere notița",
|
||||
"label_note": "Notiță",
|
||||
"placeholder_search": "căutați notița după denumirea ei",
|
||||
@@ -767,7 +766,7 @@
|
||||
"title": "Atribute moștenite"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Caută în întregul conținut <kbd>Ctrl+Enter</kbd>",
|
||||
"search_button": "Caută în întregul conținut",
|
||||
"close": "Închide",
|
||||
"search_placeholder": "Căutați notițe după nume sau tastați > pentru comenzi..."
|
||||
},
|
||||
@@ -781,7 +780,7 @@
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importă Markdown",
|
||||
"import_button": "Importă Ctrl+Enter",
|
||||
"import_button": "Importă",
|
||||
"import_success": "Conținutul Markdown a fost importat în document.",
|
||||
"modal_body_text": "Din cauza limitărilor la nivel de navigator, nu este posibilă citirea clipboard-ului din JavaScript. Inserați Markdown-ul pentru a-l importa în caseta de mai jos și dați clic pe butonul Import",
|
||||
"close": "Închide"
|
||||
@@ -817,7 +816,7 @@
|
||||
"move_to": {
|
||||
"dialog_title": "Mută notițele în...",
|
||||
"error_no_path": "Nicio cale la care să poată fi mutate.",
|
||||
"move_button": "Mută la notița selectată <kbd>enter</kbd>",
|
||||
"move_button": "Mută la notița selectată",
|
||||
"move_success_message": "Notițele selectate au fost mutate în",
|
||||
"notes_to_move": "Notițe de mutat",
|
||||
"search_placeholder": "căutați notița după denumirea ei",
|
||||
@@ -897,7 +896,7 @@
|
||||
"note_type_chooser": {
|
||||
"modal_body": "Selectați tipul notiței/șablonul pentru noua notiță:",
|
||||
"modal_title": "Selectați tipul notiței",
|
||||
"templates": "Șabloane:",
|
||||
"templates": "Șabloane",
|
||||
"close": "Închide",
|
||||
"change_path_prompt": "Selectați locul unde să se creeze noua notiță:",
|
||||
"search_placeholder": "căutare cale notiță după nume (cea implicită dacă este necompletat)"
|
||||
@@ -954,7 +953,6 @@
|
||||
},
|
||||
"password_not_set": {
|
||||
"body1": "Notițele protejate sunt criptate utilizând parola de utilizator, dar nu a fost setată nicio parolă.",
|
||||
"body2": "Pentru a putea să protejați notițe, clic <a class=\"open-password-options-button\" href=\"javascript:\">aici</a> pentru a deschide ecranul de opțiuni și pentru a seta parola.",
|
||||
"title": "Parola nu este setată",
|
||||
"close": "Închide"
|
||||
},
|
||||
@@ -971,7 +969,7 @@
|
||||
},
|
||||
"prompt": {
|
||||
"defaultTitle": "Aviz",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"ok": "OK",
|
||||
"title": "Aviz",
|
||||
"close": "Închide"
|
||||
},
|
||||
@@ -992,7 +990,7 @@
|
||||
"form_label": "Pentru a putea continua cu acțiunea cerută este nevoie să fie pornită sesiunea protejată prin introducerea parolei:",
|
||||
"help_title": "Informații despre notițe protejate",
|
||||
"modal_title": "Sesiune protejată",
|
||||
"start_button": "Pornește sesiunea protejată <kbd>enter</kbd>"
|
||||
"start_button": "Pornește sesiunea protejată"
|
||||
},
|
||||
"protected_session_status": {
|
||||
"active": "Sesiunea protejată este activă. Clic pentru a închide sesiunea protejată.",
|
||||
@@ -1193,7 +1191,7 @@
|
||||
"folders": "Dosare",
|
||||
"natural_sort": "Ordonare naturală",
|
||||
"natural_sort_language": "Limba pentru ordonare naturală",
|
||||
"sort": "Ordonare <kbd>Enter</kbd>",
|
||||
"sort": "Ordonare",
|
||||
"sort_children_by": "Ordonează subnotițele după...",
|
||||
"sort_folders_at_top": "ordonează dosarele primele",
|
||||
"sort_with_respect_to_different_character_sorting": "ordonează respectând regulile de sortare și clasificare diferite în funcție de limbă și regiune.",
|
||||
@@ -1311,7 +1309,7 @@
|
||||
},
|
||||
"upload_attachments": {
|
||||
"choose_files": "Selectați fișierele",
|
||||
"files_will_be_uploaded": "Fișierele vor fi încărcate ca atașamente în",
|
||||
"files_will_be_uploaded": "Fișierele vor fi încărcate ca atașamente în {{noteTitle}}",
|
||||
"options": "Opțuni",
|
||||
"shrink_images": "Micșorează imaginile",
|
||||
"tooltip": "Dacă această opțiune este bifată, Trilium va încerca micșorarea imaginilor încărcate prin scalarea și optimizarea lor, aspect ce va putea afecta calitatea imaginilor. Dacă nu este bifată, imaginile vor fi încărcate fără nicio schimbare.",
|
||||
@@ -2014,5 +2012,8 @@
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Deschide în afara programului"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Închide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,12 +122,12 @@
|
||||
"goBackForwards": "idi u nazad/napred kroz istoriju",
|
||||
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
|
||||
"scrollToActiveNote": "skroluj do aktivne beleške",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - idi do nadređene beleške",
|
||||
"jumpToParentNote": "idi do nadređene beleške",
|
||||
"collapseWholeTree": "sakupi celo drvo beleški",
|
||||
"collapseSubTree": "sakupi pod-drvo",
|
||||
"tabShortcuts": "Prečice na karticama",
|
||||
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (ili <kbd>middle mouse click</kbd>) na link beleške otvara belešku u novoj kartici",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (ili <kbd>Shift+middle mouse click</kbd>) na link beleške otvara i aktivira belešku u novoj kartici",
|
||||
"newTabNoteLink": "na link beleške otvara belešku u novoj kartici",
|
||||
"newTabWithActivationNoteLink": "na link beleške otvara i aktivira belešku u novoj kartici",
|
||||
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
|
||||
"openEmptyTab": "otvori praznu karticu",
|
||||
"closeActiveTab": "zatvori aktivnu karticu",
|
||||
@@ -142,14 +142,14 @@
|
||||
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
|
||||
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
|
||||
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - izaberi belešku",
|
||||
"selectNote": "izaberi belešku",
|
||||
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
|
||||
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
|
||||
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
|
||||
"deleteNotes": "obriši belešku / podstablo",
|
||||
"editingNotes": "Izmena beleški",
|
||||
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - napravi / izmeni spoljašnji link",
|
||||
"createEditLink": "napravi / izmeni spoljašnji link",
|
||||
"createInternalLink": "napravi unutrašnji link",
|
||||
"followLink": "prati link ispod kursora",
|
||||
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
|
||||
@@ -203,7 +203,7 @@
|
||||
"box_size_small": "mala (~ 10 redova)",
|
||||
"box_size_medium": "srednja (~ 30 redova)",
|
||||
"box_size_full": "puna (kutija prikazuje ceo tekst)",
|
||||
"button_include": "Uključi belešku <kbd>enter</kbd>"
|
||||
"button_include": "Uključi belešku"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Informativna poruka",
|
||||
@@ -219,7 +219,7 @@
|
||||
"dialog_title": "Uvoz za Markdown",
|
||||
"close": "Zatvori",
|
||||
"modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz",
|
||||
"import_button": "Uvoz Ctrl+Enter",
|
||||
"import_button": "Uvoz",
|
||||
"import_success": "Markdown sadržaj je učitan u dokument."
|
||||
},
|
||||
"move_to": {
|
||||
@@ -228,7 +228,7 @@
|
||||
"notes_to_move": "Beleške za premeštanje",
|
||||
"target_parent_note": "Ciljana nadbeleška",
|
||||
"search_placeholder": "potraži belešku po njenom imenu",
|
||||
"move_button": "Pređi na izabranu belešku <kbd>enter</kbd>",
|
||||
"move_button": "Pređi na izabranu belešku",
|
||||
"error_no_path": "Nema putanje za premeštanje.",
|
||||
"move_success_message": "Izabrane beleške su premeštene u "
|
||||
},
|
||||
@@ -238,7 +238,7 @@
|
||||
"modal_title": "Izaberite tip beleške",
|
||||
"close": "Zatvori",
|
||||
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
|
||||
"templates": "Šabloni:"
|
||||
"templates": "Šabloni"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Lozinka nije podešena",
|
||||
@@ -257,7 +257,7 @@
|
||||
"help_title": "Pomoć za Zaštićene beleške",
|
||||
"close_label": "Zatvori",
|
||||
"form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:",
|
||||
"start_button": "Započni zaštićenu sesiju <kbd>enter</kbd>"
|
||||
"start_button": "Započni zaštićenu sesiju"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Nedavne promene",
|
||||
@@ -309,13 +309,13 @@
|
||||
"sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.",
|
||||
"natural_sort_language": "Jezik za prirodno sortiranje",
|
||||
"the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.",
|
||||
"sort": "Sortiraj <kbd>enter</kbd>"
|
||||
"sort": "Sortiraj"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Otpremite priloge uz belešku",
|
||||
"close": "Zatvori",
|
||||
"choose_files": "Izaberite datoteke",
|
||||
"files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u",
|
||||
"files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u {{noteTitle}}",
|
||||
"options": "Opcije",
|
||||
"shrink_images": "Smanji slike",
|
||||
"upload": "Otpremi",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"link_title_mirrors": "鏈接標題跟隨筆記標題變化",
|
||||
"link_title_arbitrary": "鏈接標題可隨意修改",
|
||||
"link_title": "鏈接標題",
|
||||
"button_add_link": "添加鏈接 <kbd>Enter</kbd>"
|
||||
"button_add_link": "添加鏈接"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "編輯分支前綴",
|
||||
@@ -66,7 +66,7 @@
|
||||
"search_for_note_by_its_name": "按名稱搜尋筆記",
|
||||
"cloned_note_prefix_title": "複製的筆記將在筆記樹中顯示給定的前綴",
|
||||
"prefix_optional": "前綴(可選)",
|
||||
"clone_to_selected_note": "複製到選定的筆記 <kbd>Enter</kbd>",
|
||||
"clone_to_selected_note": "複製到選定的筆記",
|
||||
"no_path_to_clone_to": "沒有複製路徑。",
|
||||
"note_cloned": "筆記 \"{{clonedTitle}}\" 已複製到 \"{{targetTitle}}\""
|
||||
},
|
||||
@@ -83,9 +83,9 @@
|
||||
"delete_all_clones_description": "同時刪除所有複製(可以在最近修改中撤消)",
|
||||
"erase_notes_description": "通常(軟)刪除僅標記筆記為已刪除,可以在一段時間內通過最近修改對話框撤消。選中此選項將立即擦除筆記,無法撤銷。",
|
||||
"erase_notes_warning": "永久擦除筆記(無法撤銷),包括所有複製。這將強制應用程式重新加載。",
|
||||
"notes_to_be_deleted": "將刪除以下筆記 ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "將刪除以下筆記 ({{notesCount}})",
|
||||
"no_note_to_delete": "沒有筆記將被刪除(僅複製)。",
|
||||
"broken_relations_to_be_deleted": "將刪除以下關係並斷開連接 ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "將刪除以下關係並斷開連接 ({{ relationCount}})",
|
||||
"cancel": "取消",
|
||||
"ok": "確定",
|
||||
"deleted_relation_text": "筆記 {{- note}} (將被刪除的筆記) 被以下關係 {{- relation}} 引用, 來自 {{- source}}。"
|
||||
@@ -107,20 +107,18 @@
|
||||
"export_finished_successfully": "匯出成功完成。"
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "幫助(完整<a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">在線文檔</a>)",
|
||||
"close": "關閉",
|
||||
"noteNavigation": "筆記導航",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - 在筆記列表中向上/向下移動",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - 折疊/展開節點",
|
||||
"goUpDown": "在筆記列表中向上/向下移動",
|
||||
"collapseExpand": "折疊/展開節點",
|
||||
"notSet": "未設定",
|
||||
"goBackForwards": "在歷史記錄中前後移動",
|
||||
"showJumpToNoteDialog": "顯示<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"跳轉到\" 對話框</a>",
|
||||
"scrollToActiveNote": "滾動到活動筆記",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - 跳轉到上級筆記",
|
||||
"jumpToParentNote": "跳轉到上級筆記",
|
||||
"collapseWholeTree": "折疊整個筆記樹",
|
||||
"collapseSubTree": "折疊子樹",
|
||||
"tabShortcuts": "標籤快捷鍵",
|
||||
"newTabNoteLink": "<kbd>CTRL+click</kbd> - 在筆記鏈接上使用CTRL+點擊(或中鍵點擊)在新標籤中打開筆記",
|
||||
"onlyInDesktop": "僅在桌面版(電子構建)中",
|
||||
"openEmptyTab": "打開空白標籤頁",
|
||||
"closeActiveTab": "關閉活動標籤頁",
|
||||
@@ -135,14 +133,14 @@
|
||||
"moveNoteUpHierarchy": "在層級結構中向上移動筆記",
|
||||
"multiSelectNote": "多選上/下筆記",
|
||||
"selectAllNotes": "選擇當前級別的所有筆記",
|
||||
"selectNote": "<kbd>Shift+Click</kbd> - 選擇筆記",
|
||||
"selectNote": "選擇筆記",
|
||||
"copyNotes": "將活動筆記(或當前選擇)複製到剪貼簿(用於<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">複製</a>)",
|
||||
"cutNotes": "將當前筆記(或當前選擇)剪下到剪貼簿(用於移動筆記)",
|
||||
"pasteNotes": "將筆記貼上為活動筆記的子筆記(根據是複製還是剪下到剪貼簿來決定是移動還是複製)",
|
||||
"deleteNotes": "刪除筆記/子樹",
|
||||
"editingNotes": "編輯筆記",
|
||||
"editNoteTitle": "在樹形筆記樹中,焦點會從筆記樹切換到筆記標題。按下 Enter 鍵會將焦點從筆記標題切換到文字編輯器。按下 <kbd>Ctrl+.</kbd> 會將焦點從編輯器切換回筆記樹。",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - 新增/編輯外部鏈接",
|
||||
"createEditLink": "新增/編輯外部鏈接",
|
||||
"createInternalLink": "新增內部鏈接",
|
||||
"followLink": "跟隨遊標下的鏈接",
|
||||
"insertDateTime": "在插入點插入當前日期和時間",
|
||||
@@ -186,7 +184,7 @@
|
||||
"box_size_small": "小型 (顯示大約10行)",
|
||||
"box_size_medium": "中型 (顯示大約30行)",
|
||||
"box_size_full": "完整顯示(完整文字框)",
|
||||
"button_include": "包含筆記 <kbd>Enter</kbd>"
|
||||
"button_include": "包含筆記"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "資訊消息",
|
||||
@@ -194,12 +192,12 @@
|
||||
"okButton": "確定"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "全文搜尋 <kbd>Ctrl+Enter</kbd>"
|
||||
"search_button": "全文搜尋"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown 匯入",
|
||||
"modal_body_text": "由於瀏覽器沙盒的限制,無法直接從 JavaScript 讀取剪貼簿內容。請將要匯入的 Markdown 文字貼上到下面的文字框中,然後點擊匯入按鈕",
|
||||
"import_button": "匯入 Ctrl+Enter",
|
||||
"import_button": "匯入",
|
||||
"import_success": "已成功匯入 Markdown 內容文檔。"
|
||||
},
|
||||
"move_to": {
|
||||
@@ -207,23 +205,22 @@
|
||||
"notes_to_move": "需要移動的筆記",
|
||||
"target_parent_note": "目標上級筆記",
|
||||
"search_placeholder": "通過名稱搜尋筆記",
|
||||
"move_button": "移動到選定的筆記 <kbd>Enter</kbd>",
|
||||
"move_button": "移動到選定的筆記",
|
||||
"error_no_path": "沒有可以移動到的路徑。",
|
||||
"move_success_message": "已移動所選筆記到 "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"modal_title": "選擇筆記類型",
|
||||
"modal_body": "選擇新筆記的類型或模板:",
|
||||
"templates": "模板:"
|
||||
"templates": "模板"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密碼未設定",
|
||||
"body1": "受保護的筆記使用用戶密碼加密,但密碼尚未設定。",
|
||||
"body2": "點擊<a class=\"open-password-options-button\" href=\"javascript:\">這裡</a>打開選項對話框並設定您的密碼。"
|
||||
"body1": "受保護的筆記使用用戶密碼加密,但密碼尚未設定。"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "提示",
|
||||
"ok": "確定 <kbd>Enter</kbd>",
|
||||
"ok": "確定",
|
||||
"defaultTitle": "提示"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -231,7 +228,7 @@
|
||||
"help_title": "關於保護筆記的幫助",
|
||||
"close_label": "關閉",
|
||||
"form_label": "輸入密碼進入保護會話以繼續:",
|
||||
"start_button": "開始保護會話 <kbd>Enter</kbd>"
|
||||
"start_button": "開始保護會話"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "最近修改",
|
||||
@@ -278,12 +275,12 @@
|
||||
"sort_with_respect_to_different_character_sorting": "根據不同語言或地區的字符排序和排序規則排序。",
|
||||
"natural_sort_language": "自然排序語言",
|
||||
"the_language_code_for_natural_sort": "自然排序的語言程式碼,例如繁體中文的 \"zh-TW\"。",
|
||||
"sort": "排序 <kbd>Enter</kbd>"
|
||||
"sort": "排序"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "上傳附件到筆記",
|
||||
"choose_files": "選擇文件",
|
||||
"files_will_be_uploaded": "文件將作為附件上傳到",
|
||||
"files_will_be_uploaded": "文件將作為附件上傳到 {{noteTitle}}",
|
||||
"options": "選項",
|
||||
"shrink_images": "縮小圖片",
|
||||
"upload": "上傳",
|
||||
|
||||
54
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
54
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import AbstractBulkAction from "./abstract_bulk_action";
|
||||
|
||||
interface BulkActionProps {
|
||||
label: string | ComponentChildren;
|
||||
children?: ComponentChildren;
|
||||
helpText?: ComponentChildren;
|
||||
bulkAction: AbstractBulkAction;
|
||||
}
|
||||
|
||||
// Define styles as constants to prevent recreation
|
||||
const flexContainerStyle = { display: "flex", alignItems: "center" } as const;
|
||||
const labelStyle = { marginRight: "10px" } as const;
|
||||
const textStyle = { marginRight: "10px", marginLeft: "10px" } as const;
|
||||
|
||||
const BulkAction = memo(({ label, children, helpText, bulkAction }: BulkActionProps) => {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<div style={flexContainerStyle}>
|
||||
<div style={labelStyle} className="text-nowrap">{label}</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
<td className="button-column">
|
||||
{helpText && <div className="dropdown help-dropdown">
|
||||
<span className="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div className="dropdown-menu dropdown-menu-right p-4">
|
||||
{helpText}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<span
|
||||
className="bx bx-x icon-action action-conf-del"
|
||||
onClick={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
export default BulkAction;
|
||||
|
||||
export const BulkActionText = memo(({ text }: { text: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={textStyle}
|
||||
className="text-nowrap">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FAttribute from "../../entities/fattribute.js";
|
||||
import { VNode } from "preact";
|
||||
|
||||
interface ActionDefinition {
|
||||
export interface ActionDefinition {
|
||||
script: string;
|
||||
relationName: string;
|
||||
targetNoteId: string;
|
||||
@@ -27,26 +26,9 @@ export default abstract class AbstractBulkAction {
|
||||
this.actionDef = actionDef;
|
||||
}
|
||||
|
||||
render() {
|
||||
try {
|
||||
const $rendered = this.doRender();
|
||||
|
||||
$rendered
|
||||
.find(".action-conf-del")
|
||||
.on("click", () => this.deleteAction())
|
||||
.attr("title", t("abstract_bulk_action.remove_this_search_action"));
|
||||
|
||||
utils.initHelpDropdown($rendered);
|
||||
|
||||
return $rendered;
|
||||
} catch (e: any) {
|
||||
logError(`Failed rendering search action: ${JSON.stringify(this.attribute.dto)} with error: ${e.message} ${e.stack}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// to be overridden
|
||||
abstract doRender(): JQuery<HTMLElement>;
|
||||
abstract doRender(): VNode;
|
||||
|
||||
static get actionName() {
|
||||
return "";
|
||||
}
|
||||
@@ -66,9 +48,6 @@ export default abstract class AbstractBulkAction {
|
||||
|
||||
async deleteAction() {
|
||||
await server.remove(`notes/${this.attribute.noteId}/attributes/${this.attribute.attributeId}`);
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
//await this.triggerCommand('refreshSearchDefinition');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "./abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td>
|
||||
${t("execute_script.execute_script")}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text"
|
||||
class="form-control script"
|
||||
placeholder="note.title = note.title + '- suffix';"/>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
${t("execute_script.help_text")}
|
||||
|
||||
${t("execute_script.example_1")}
|
||||
|
||||
<pre>note.title = note.title + ' - suffix';</pre>
|
||||
|
||||
${t("execute_script.example_2")}
|
||||
|
||||
<pre>for (const attr of note.getOwnedAttributes) { attr.markAsDeleted(); }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class ExecuteScriptBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "executeScript";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("execute_script.execute_script");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
const $script = $action.find(".script");
|
||||
$script.val(this.actionDef.script || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({ script: $script.val() });
|
||||
}, 1000);
|
||||
|
||||
$script.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
50
apps/client/src/widgets/bulk_actions/execute_script.tsx
Normal file
50
apps/client/src/widgets/bulk_actions/execute_script.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import FormTextBox from "../react/FormTextBox.jsx";
|
||||
import AbstractBulkAction, { ActionDefinition } from "./abstract_bulk_action.js";
|
||||
import BulkAction from "./BulkAction.jsx";
|
||||
import { useSpacedUpdate } from "../react/hooks.jsx";
|
||||
|
||||
function ExecuteScriptBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ script, setScript ] = useState(actionDef.script);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ script }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ script ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("execute_script.execute_script")}
|
||||
helpText={<>
|
||||
{t("execute_script.help_text")}
|
||||
|
||||
{t("execute_script.example_1")}
|
||||
|
||||
<pre>note.title = note.title + ' - suffix';</pre>
|
||||
|
||||
{t("execute_script.example_2")}
|
||||
|
||||
<pre>{"for (const attr of note.getOwnedAttributes) { attr.markAsDeleted(); }"}</pre>
|
||||
</>}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder="note.title = note.title + '- suffix';"
|
||||
currentValue={script} onChange={setScript}
|
||||
/>
|
||||
</BulkAction>
|
||||
);
|
||||
}
|
||||
|
||||
export default class ExecuteScriptBulkAction extends AbstractBulkAction {
|
||||
|
||||
static get actionName() {
|
||||
return "executeScript";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("execute_script.execute_script");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <ExecuteScriptBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("add_label.add_label")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control label-name"
|
||||
placeholder="${t("add_label.label_name_placeholder")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("add_label.label_name_title")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("add_label.to_value")}</div>
|
||||
|
||||
<input type="text" class="form-control label-value" placeholder="${t("add_label.new_value_placeholder")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("add_label.help_text")}</p>
|
||||
|
||||
<ul>
|
||||
<li>${t("add_label.help_text_item1")}</li>
|
||||
<li>${t("add_label.help_text_item2")}</li>
|
||||
</ul>
|
||||
|
||||
${t("add_label.help_text_note")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class AddLabelBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "addLabel";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("add_label.add_label");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $labelName = $action.find(".label-name");
|
||||
$labelName.val(this.actionDef.labelName || "");
|
||||
|
||||
const $labelValue = $action.find(".label-value");
|
||||
$labelValue.val(this.actionDef.labelValue || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
labelName: $labelName.val(),
|
||||
labelValue: $labelValue.val()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$labelValue.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
57
apps/client/src/widgets/bulk_actions/label/add_label.tsx
Normal file
57
apps/client/src/widgets/bulk_actions/label/add_label.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction";
|
||||
import { useSpacedUpdate } from "../../react/hooks";
|
||||
|
||||
function AddLabelBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ labelName, setLabelName ] = useState<string>(actionDef.labelName ?? "");
|
||||
const [ labelValue, setLabelValue ] = useState<string>(actionDef.labelValue ?? "");
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ labelName, labelValue }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [labelName, labelValue]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("add_label.add_label")}
|
||||
helpText={<>
|
||||
<p>{t("add_label.help_text")}</p>
|
||||
|
||||
<ul>
|
||||
<li>{t("add_label.help_text_item1")}</li>
|
||||
<li>{t("add_label.help_text_item2")}</li>
|
||||
</ul>
|
||||
|
||||
{t("add_label.help_text_note")}
|
||||
</>}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("add_label.label_name_placeholder")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("add_label.label_name_title")}
|
||||
currentValue={labelName} onChange={setLabelName}
|
||||
/>
|
||||
<BulkActionText text={t("add_label.to_value")} />
|
||||
<FormTextBox
|
||||
placeholder={t("add_label.new_value_placeholder")}
|
||||
currentValue={labelValue} onChange={setLabelValue}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class AddLabelBulkAction extends AbstractBulkAction {
|
||||
|
||||
doRender() {
|
||||
return <AddLabelBulkActionComponent bulkAction={this} actionDef={this.actionDef} />;
|
||||
}
|
||||
|
||||
static get actionName() {
|
||||
return "addLabel";
|
||||
}
|
||||
|
||||
static get actionTitle() {
|
||||
return t("add_label.add_label");
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td>
|
||||
${t("delete_label.delete_label")}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text"
|
||||
class="form-control label-name"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("delete_label.label_name_title")}"
|
||||
placeholder="${t("delete_label.label_name_placeholder")}"/>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class DeleteLabelBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteLabel";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_label.delete_label");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
const $labelName = $action.find(".label-name");
|
||||
$labelName.val(this.actionDef.labelName || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({ labelName: $labelName.val() });
|
||||
}, 1000);
|
||||
|
||||
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
39
apps/client/src/widgets/bulk_actions/label/delete_label.tsx
Normal file
39
apps/client/src/widgets/bulk_actions/label/delete_label.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
|
||||
function DeleteLabelBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition}) {
|
||||
const [ labelName, setLabelName ] = useState<string>(actionDef.labelName ?? "");
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ labelName }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [labelName]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("delete_label.delete_label")}
|
||||
>
|
||||
<FormTextBox
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("delete_label.label_name_title")}
|
||||
placeholder={t("delete_label.label_name_placeholder")}
|
||||
currentValue={labelName} onChange={setLabelName}
|
||||
/>
|
||||
</BulkAction>
|
||||
);
|
||||
}
|
||||
|
||||
export default class DeleteLabelBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteLabel";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_label.delete_label");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <DeleteLabelBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_label.rename_label_from")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control old-label-name"
|
||||
placeholder="${t("rename_label.old_name_placeholder")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("rename_label.name_title")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("rename_label.to")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control new-label-name"
|
||||
placeholder="${t("rename_label.new_name_placeholder")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("rename_label.name_title")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class RenameLabelBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameLabel";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("rename_label.rename_label");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $oldLabelName = $action.find(".old-label-name");
|
||||
$oldLabelName.val(this.actionDef.oldLabelName || "");
|
||||
|
||||
const $newLabelName = $action.find(".new-label-name");
|
||||
$newLabelName.val(this.actionDef.newLabelName || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
oldLabelName: $oldLabelName.val(),
|
||||
newLabelName: $newLabelName.val()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$oldLabelName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$newLabelName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
49
apps/client/src/widgets/bulk_actions/label/rename_label.tsx
Normal file
49
apps/client/src/widgets/bulk_actions/label/rename_label.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function RenameLabelBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition}) {
|
||||
const [ oldLabelName, setOldLabelName ] = useState(actionDef.oldLabelName);
|
||||
const [ newLabelName, setNewLabelName ] = useState(actionDef.newLabelName);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ oldLabelName, newLabelName }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ oldLabelName, newLabelName ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("rename_label.rename_label_from")}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("rename_label.old_name_placeholder")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("rename_label.name_title")}
|
||||
currentValue={oldLabelName} onChange={setOldLabelName}
|
||||
/>
|
||||
|
||||
<BulkActionText text={t("rename_label.to")} />
|
||||
|
||||
<FormTextBox
|
||||
placeholder={t("rename_label.new_name_placeholder")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("rename_label.name_title")}
|
||||
currentValue={newLabelName} onChange={setNewLabelName}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class RenameLabelBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameLabel";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("rename_label.rename_label");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <RenameLabelBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("update_label_value.update_label_value")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control label-name"
|
||||
placeholder="${t("update_label_value.label_name_placeholder")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("update_label_value.label_name_title")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("update_label_value.to_value")}</div>
|
||||
|
||||
<input type="text" class="form-control label-value" placeholder="${t("update_label_value.new_value_placeholder")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("update_label_value.help_text")}</p>
|
||||
|
||||
${t("update_label_value.help_text_note")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class UpdateLabelValueBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "updateLabelValue";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("update_label_value.update_label_value");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $labelName = $action.find(".label-name");
|
||||
$labelName.val(this.actionDef.labelName || "");
|
||||
|
||||
const $labelValue = $action.find(".label-value");
|
||||
$labelValue.val(this.actionDef.labelValue || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
labelName: $labelName.val(),
|
||||
labelValue: $labelValue.val()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$labelValue.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
function UpdateLabelValueComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition}) {
|
||||
const [ labelName, setLabelName ] = useState<string>(actionDef.labelName ?? "");
|
||||
const [ labelValue, setLabelValue ] = useState<string>(actionDef.labelValue ?? "");
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ labelName, labelValue }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [labelName, labelValue]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("update_label_value.update_label_value")}
|
||||
helpText={<>
|
||||
<p>{t("update_label_value.help_text")}</p>
|
||||
|
||||
{t("update_label_value.help_text_note")}
|
||||
</>}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("update_label_value.label_name_placeholder")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("update_label_value.label_name_title")}
|
||||
currentValue={labelName} onChange={setLabelName}
|
||||
/>
|
||||
<BulkActionText text={t("update_label_value.to_value")} />
|
||||
<FormTextBox
|
||||
placeholder={t("update_label_value.new_value_placeholder")}
|
||||
currentValue={labelValue} onChange={setLabelValue}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class UpdateLabelValueBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "updateLabelValue";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("update_label_value.update_label_value");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <UpdateLabelValueComponent bulkAction={this} actionDef={this.actionDef} />;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<span class="bx bx-trash"></span>
|
||||
|
||||
${t("delete_note.delete_matched_notes")}
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("delete_note.delete_matched_notes_description")}</p>
|
||||
|
||||
<p>${t("delete_note.undelete_notes_instruction")}</p>
|
||||
|
||||
${t("delete_note.erase_notes_instruction")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class DeleteNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteNote";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_note.delete_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return $(TPL);
|
||||
}
|
||||
}
|
||||
33
apps/client/src/widgets/bulk_actions/note/delete_note.tsx
Normal file
33
apps/client/src/widgets/bulk_actions/note/delete_note.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
|
||||
function DeleteNoteBulkActionComponent({ bulkAction }: { bulkAction: AbstractBulkAction }) {
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={<><Icon icon="bx bx-trash" /> {t("delete_note.delete_matched_notes")}</>}
|
||||
helpText={<>
|
||||
<p>{t("delete_note.delete_matched_notes_description")}</p>
|
||||
|
||||
<p>{t("delete_note.undelete_notes_instruction")}</p>
|
||||
|
||||
{t("delete_note.erase_notes_instruction")}
|
||||
</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default class DeleteNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteNote";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_note.delete_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <DeleteNoteBulkActionComponent bulkAction={this} />
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<span class="bx bx-trash"></span>
|
||||
${t("delete_revisions.delete_note_revisions")}
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
${t("delete_revisions.all_past_note_revisions")}
|
||||
</div>
|
||||
</div>
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class DeleteRevisionsBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteRevisions";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_revisions.delete_note_revisions");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return $(TPL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
|
||||
function DeleteRevisionsBulkActionComponent({ bulkAction }: { bulkAction: AbstractBulkAction }) {
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={<><Icon icon="bx bx-trash" /> {t("delete_revisions.delete_note_revisions")}</>}
|
||||
helpText={t("delete_revisions.all_past_note_revisions")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default class DeleteRevisionsBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteRevisions";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_revisions.delete_note_revisions");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <DeleteRevisionsBulkActionComponent bulkAction={this} />
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("move_note.move_note")}</div>
|
||||
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("move_note.to")}</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control target-parent-note" placeholder="${t("move_note.target_parent_note")}"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("move_note.on_all_matched_notes")}:</p>
|
||||
|
||||
<ul style="margin-bottom: 0;">
|
||||
<li>${t("move_note.move_note_new_parent")}</li>
|
||||
<li>${t("move_note.clone_note_new_parent")}</li>
|
||||
<li>${t("move_note.nothing_will_happen")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class MoveNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "moveNote";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("move_note.move_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $targetParentNote = $action.find(".target-parent-note");
|
||||
noteAutocompleteService.initNoteAutocomplete($targetParentNote);
|
||||
$targetParentNote.setNote(this.actionDef.targetParentNoteId);
|
||||
|
||||
$targetParentNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
targetParentNoteId: $targetParentNote.getSelectedNoteId()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$targetParentNote.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
50
apps/client/src/widgets/bulk_actions/note/move_note.tsx
Normal file
50
apps/client/src/widgets/bulk_actions/note/move_note.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function MoveNoteBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ targetParentNoteId, setTargetParentNoteId ] = useState<string>();
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
return bulkAction.saveAction({ targetParentNoteId: targetParentNoteId })
|
||||
});
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ targetParentNoteId ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("move_note.move_note")}
|
||||
helpText={<>
|
||||
<p>{t("move_note.on_all_matched_notes")}:</p>
|
||||
|
||||
<ul style="margin-bottom: 0;">
|
||||
<li>{t("move_note.move_note_new_parent")}</li>
|
||||
<li>{t("move_note.clone_note_new_parent")}</li>
|
||||
<li>{t("move_note.nothing_will_happen")}</li>
|
||||
</ul>
|
||||
</>}
|
||||
>
|
||||
<BulkActionText text={t("move_note.to")} />
|
||||
|
||||
<NoteAutocomplete
|
||||
placeholder={t("move_note.target_parent_note")}
|
||||
noteId={targetParentNoteId} noteIdChanged={setTargetParentNoteId}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class MoveNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "moveNote";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("move_note.move_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <MoveNoteBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_note.rename_note_title_to")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control new-title"
|
||||
placeholder="${t("rename_note.new_note_title")}"
|
||||
title="${t("rename_note.click_help_icon")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("rename_note.evaluated_as_js_string")}</p>
|
||||
|
||||
<ul>
|
||||
<li>${t("rename_note.example_note")}</li>
|
||||
<li>${t("rename_note.example_new_title")}</li>
|
||||
<li>${t("rename_note.example_date_prefix")}</li>
|
||||
</ul>
|
||||
|
||||
${t("rename_note.api_docs")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class RenameNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameNote";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("rename_note.rename_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $newTitle = $action.find(".new-title");
|
||||
$newTitle.val(this.actionDef.newTitle || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
newTitle: $newTitle.val()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$newTitle.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
53
apps/client/src/widgets/bulk_actions/note/rename_note.tsx
Normal file
53
apps/client/src/widgets/bulk_actions/note/rename_note.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
import RawHtml from "../../react/RawHtml.jsx";
|
||||
|
||||
function RenameNoteBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition}) {
|
||||
const [ newTitle, setNewTitle ] = useState<string>(actionDef.newTitle ?? "");
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ newTitle }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ newTitle ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("rename_note.rename_note_title_to")}
|
||||
helpText={<>
|
||||
<p>{t("rename_note.evaluated_as_js_string")}</p>
|
||||
|
||||
<ul>
|
||||
<li><RawHtml html={t("rename_note.example_note")} /></li>
|
||||
<li><RawHtml html={t("rename_note.example_new_title")} /></li>
|
||||
<li><RawHtml html={t("rename_note.example_date_prefix")} /></li>
|
||||
</ul>
|
||||
|
||||
<RawHtml html={t("rename_note.api_docs")} />
|
||||
</>}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("rename_note.new_note_title")}
|
||||
title={("rename_note.click_help_icon")}
|
||||
currentValue={newTitle} onChange={setNewTitle}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class RenameNoteBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameNote";
|
||||
}
|
||||
|
||||
static get actionTitle() {
|
||||
return t("rename_note.rename_note");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <RenameNoteBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("add_relation.add_relation")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control relation-name"
|
||||
placeholder="${t("add_relation.relation_name")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
style="flex-shrink: 3"
|
||||
title="${t("add_relation.allowed_characters")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("add_relation.to")}</div>
|
||||
|
||||
<div class="input-group" style="flex-shrink: 2">
|
||||
<input type="text" class="form-control target-note" placeholder="${t("add_relation.target_note")}"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
${t("add_relation.create_relation_on_all_matched_notes")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class AddRelationBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "addRelation";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("add_relation.add_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $relationName = $action.find(".relation-name");
|
||||
$relationName.val(this.actionDef.relationName || "");
|
||||
|
||||
const $targetNote = $action.find(".target-note");
|
||||
noteAutocompleteService.initNoteAutocomplete($targetNote);
|
||||
$targetNote.setNote(this.actionDef.targetNoteId);
|
||||
|
||||
$targetNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
relationName: $relationName.val(),
|
||||
targetNoteId: $targetNote.getSelectedNoteId()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$targetNote.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function AddRelationBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ relationName, setRelationName ] = useState<string>(actionDef.relationName);
|
||||
const [ targetNoteId, setTargetNoteId ] = useState<string>(actionDef.targetNoteId);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ relationName, targetNoteId }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ relationName, targetNoteId ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("add_relation.add_relation")}
|
||||
helpText={t("add_relation.create_relation_on_all_matched_notes")}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("add_relation.relation_name")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
style={{ flexShrink: 3 }}
|
||||
title={t("add_relation.allowed_characters")}
|
||||
currentValue={relationName} onChange={setRelationName}
|
||||
/>
|
||||
|
||||
<BulkActionText text={t("add_relation.to")} />
|
||||
|
||||
<NoteAutocomplete
|
||||
placeholder={t("add_relation.target_note")}
|
||||
noteId={targetNoteId} noteIdChanged={setTargetNoteId}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class AddRelationBulkAction extends AbstractBulkAction {
|
||||
|
||||
static get actionName() {
|
||||
return "addRelation";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("add_relation.add_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <AddRelationBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td>
|
||||
${t("delete_relation.delete_relation")}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center">
|
||||
<input type="text"
|
||||
class="form-control relation-name"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
placeholder="${t("delete_relation.relation_name")}"
|
||||
title="${t("delete_relation.allowed_characters")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class DeleteRelationBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "deleteRelation";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("delete_relation.delete_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
const $relationName = $action.find(".relation-name");
|
||||
$relationName.val(this.actionDef.relationName || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({ relationName: $relationName.val() });
|
||||
}, 1000);
|
||||
|
||||
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function DeleteRelationBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ relationName, setRelationName ] = useState(actionDef.relationName);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ relationName }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ relationName ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("delete_relation.delete_relation")}
|
||||
>
|
||||
<FormTextBox
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
placeholder={t("delete_relation.relation_name")}
|
||||
title={t("delete_relation.allowed_characters")}
|
||||
currentValue={relationName} onChange={setRelationName}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class DeleteRelationBulkAction extends AbstractBulkAction {
|
||||
|
||||
static get actionName() {
|
||||
return "deleteRelation";
|
||||
}
|
||||
|
||||
static get actionTitle() {
|
||||
return t("delete_relation.delete_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <DeleteRelationBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_relation.rename_relation_from")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control old-relation-name"
|
||||
placeholder="${t("rename_relation.old_name")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("rename_relation.allowed_characters")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("rename_relation.to")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control new-relation-name"
|
||||
placeholder="${t("rename_relation.new_name")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title="${t("rename_relation.allowed_characters")}"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class RenameRelationBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameRelation";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("rename_relation.rename_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $oldRelationName = $action.find(".old-relation-name");
|
||||
$oldRelationName.val(this.actionDef.oldRelationName || "");
|
||||
|
||||
const $newRelationName = $action.find(".new-relation-name");
|
||||
$newRelationName.val(this.actionDef.newRelationName || "");
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
oldRelationName: $oldRelationName.val(),
|
||||
newRelationName: $newRelationName.val()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$oldRelationName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$newRelationName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function RenameRelationBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ oldRelationName, setOldRelationName ] = useState(actionDef.oldRelationName);
|
||||
const [ newRelationName, setNewRelationName ] = useState(actionDef.newRelationName);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ oldRelationName, newRelationName }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ oldRelationName, newRelationName ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("rename_relation.rename_relation_from")}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("rename_relation.old_name")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("rename_relation.allowed_characters")}
|
||||
currentValue={oldRelationName} onChange={setOldRelationName}
|
||||
/>
|
||||
|
||||
<BulkActionText text={t("rename_relation.to")} />
|
||||
|
||||
<FormTextBox
|
||||
placeholder={t("rename_relation.new_name")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
title={t("rename_relation.allowed_characters")}
|
||||
currentValue={newRelationName} onChange={setNewRelationName}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class RenameRelationBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "renameRelation";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("rename_relation.rename_relation");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <RenameRelationBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">${t("update_relation_target.update_relation")}</div>
|
||||
|
||||
<input type="text"
|
||||
class="form-control relation-name"
|
||||
placeholder="${t("update_relation_target.relation_name")}"
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
style="flex-shrink: 3"
|
||||
title="${t("update_relation_target.allowed_characters")}"/>
|
||||
|
||||
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("update_relation_target.to")}</div>
|
||||
|
||||
<div class="input-group" style="flex-shrink: 2">
|
||||
<input type="text" class="form-control target-note" placeholder="${t("update_relation_target.target_note")}"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>${t("update_relation_target.on_all_matched_notes")}:</p>
|
||||
|
||||
<ul style="margin-bottom: 0;">
|
||||
<li>${t("update_relation_target.change_target_note")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class UpdateRelationTargetBulkAction extends AbstractBulkAction {
|
||||
static get actionName() {
|
||||
return "updateRelationTarget";
|
||||
}
|
||||
static get actionTitle() {
|
||||
return t("update_relation_target.update_relation_target");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $relationName = $action.find(".relation-name");
|
||||
$relationName.val(this.actionDef.relationName || "");
|
||||
|
||||
const $targetNote = $action.find(".target-note");
|
||||
noteAutocompleteService.initNoteAutocomplete($targetNote);
|
||||
$targetNote.setNote(this.actionDef.targetNoteId);
|
||||
|
||||
$targetNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
relationName: $relationName.val(),
|
||||
targetNoteId: $targetNote.getSelectedNoteId()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
$targetNote.on("input", () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import FormTextBox from "../../react/FormTextBox.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function UpdateRelationTargetComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ relationName, setRelationName ] = useState(actionDef.relationName);
|
||||
const [ targetNoteId, setTargetNoteId ] = useState(actionDef.targetNoteId);
|
||||
const spacedUpdate = useSpacedUpdate(() => bulkAction.saveAction({ relationName, targetNoteId }));
|
||||
useEffect(() => spacedUpdate.scheduleUpdate(), [ relationName, targetNoteId ]);
|
||||
|
||||
return (
|
||||
<BulkAction
|
||||
bulkAction={bulkAction}
|
||||
label={t("update_relation_target.update_relation")}
|
||||
helpText={<>
|
||||
<p>{t("update_relation_target.on_all_matched_notes")}:</p>
|
||||
|
||||
<ul style="margin-bottom: 0;">
|
||||
<li>{t("update_relation_target.change_target_note")}</li>
|
||||
</ul>
|
||||
</>}
|
||||
>
|
||||
<FormTextBox
|
||||
placeholder={t("update_relation_target.relation_name")}
|
||||
pattern="[\\p{L}\\p{N}_:]+"
|
||||
style={{ flexShrink: 3 }}
|
||||
title={t("update_relation_target.allowed_characters")}
|
||||
currentValue={relationName} onChange={setRelationName}
|
||||
/>
|
||||
|
||||
<BulkActionText text={t("update_relation_target.to")} />
|
||||
|
||||
<NoteAutocomplete
|
||||
placeholder={t("update_relation_target.target_note")}
|
||||
containerStyle={{ flexShrink: 2 }}
|
||||
noteId={targetNoteId} noteIdChanged={setTargetNoteId}
|
||||
/>
|
||||
</BulkAction>
|
||||
)
|
||||
}
|
||||
|
||||
export default class UpdateRelationTargetBulkAction extends AbstractBulkAction {
|
||||
|
||||
static get actionName() {
|
||||
return "updateRelationTarget";
|
||||
}
|
||||
|
||||
static get actionTitle() {
|
||||
return t("update_relation_target.update_relation_target");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <UpdateRelationTargetComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
@@ -9,20 +8,26 @@ import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
|
||||
function AboutDialogComponent() {
|
||||
let [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
|
||||
async function onShown() {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
setAppInfo(appInfo);
|
||||
}
|
||||
|
||||
let [shown, setShown] = useState(false);
|
||||
const forceWordBreak: CSSProperties = { wordBreak: "break-all" };
|
||||
|
||||
useTriliumEvent("openAboutDialog", () => setShown(true));
|
||||
|
||||
return (
|
||||
<Modal className="about-dialog" size="lg" title={t("about.title")} onShown={onShown}>
|
||||
{(appInfo !== null) ? (
|
||||
<Modal className="about-dialog"
|
||||
size="lg"
|
||||
title={t("about.title")}
|
||||
show={shown}
|
||||
onShown={async () => {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
setAppInfo(appInfo);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
>
|
||||
<table className="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -31,37 +36,36 @@ function AboutDialogComponent() {
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.app_version")}</th>
|
||||
<td className="app-version">{appInfo.appVersion}</td>
|
||||
<td className="app-version">{appInfo?.appVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.db_version")}</th>
|
||||
<td className="db-version">{appInfo.dbVersion}</td>
|
||||
<td className="db-version">{appInfo?.dbVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.sync_version")}</th>
|
||||
<td className="sync-version">{appInfo.syncVersion}</td>
|
||||
<td className="sync-version">{appInfo?.syncVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_date")}</th>
|
||||
<td className="build-date">{formatDateTime(appInfo.buildDate)}</td>
|
||||
<td className="build-date">
|
||||
{appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td>
|
||||
<a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />
|
||||
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="loading-spinner"></div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +89,4 @@ export default class AboutDialog extends ReactBasicWidget {
|
||||
return <AboutDialogComponent />;
|
||||
}
|
||||
|
||||
async openAboutDialogEvent() {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import type { Suggestion } from "../../services/note_autocomplete.js";
|
||||
import type { default as TextTypeWidget } from "../type_widgets/editable_text.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="add-link-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("add_link.add_link")}</h5>
|
||||
<button type="button" class="help-button" title="${t("add_link.help_on_links")}" data-help-page="links.html">?</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("add_link.close")}"></button>
|
||||
</div>
|
||||
<form class="add-link-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="add-link-note-autocomplete">${t("add_link.note")}</label>
|
||||
|
||||
<div class="input-group">
|
||||
<input class="add-link-note-autocomplete form-control" placeholder="${t("add_link.search_note")}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-link-title-settings">
|
||||
<div class="add-link-title-radios form-check">
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input" type="radio" name="link-type" value="reference-link" checked>
|
||||
${t("add_link.link_title_mirrors")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="add-link-title-radios form-check">
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input" type="radio" name="link-type" value="hyper-link">
|
||||
${t("add_link.link_title_arbitrary")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="add-link-title-form-group form-group">
|
||||
<br/>
|
||||
<label>
|
||||
${t("add_link.link_title")}
|
||||
|
||||
<input class="link-title form-control" style="width: 100%;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">${t("add_link.button_add_link")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class AddLinkDialog extends BasicWidget {
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $linkTitle!: JQuery<HTMLElement>;
|
||||
private $addLinkTitleSettings!: JQuery<HTMLElement>;
|
||||
private $addLinkTitleRadios!: JQuery<HTMLElement>;
|
||||
private $addLinkTitleFormGroup!: JQuery<HTMLElement>;
|
||||
private textTypeWidget: TextTypeWidget | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".add-link-form");
|
||||
this.$autoComplete = this.$widget.find(".add-link-note-autocomplete");
|
||||
this.$linkTitle = this.$widget.find(".link-title");
|
||||
this.$addLinkTitleSettings = this.$widget.find(".add-link-title-settings");
|
||||
this.$addLinkTitleRadios = this.$widget.find(".add-link-title-radios");
|
||||
this.$addLinkTitleFormGroup = this.$widget.find(".add-link-title-form-group");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
if (this.$autoComplete.getSelectedNotePath()) {
|
||||
this.$widget.modal("hide");
|
||||
|
||||
const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val() as string;
|
||||
|
||||
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedNotePath()!, linkTitle);
|
||||
} else if (this.$autoComplete.getSelectedExternalLink()) {
|
||||
this.$widget.modal("hide");
|
||||
|
||||
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val() as string, true);
|
||||
} else {
|
||||
logError("No link to add.");
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async showAddLinkDialogEvent({ textTypeWidget, text = "" }: EventData<"showAddLinkDialog">) {
|
||||
this.textTypeWidget = textTypeWidget;
|
||||
|
||||
this.$addLinkTitleSettings.toggle(!this.textTypeWidget.hasSelection());
|
||||
|
||||
this.$addLinkTitleSettings.find("input[type=radio]").on("change", () => this.updateTitleSettingsVisibility());
|
||||
|
||||
// with selection hyperlink is implied
|
||||
if (this.textTypeWidget.hasSelection()) {
|
||||
this.$addLinkTitleSettings.find("input[value='hyper-link']").prop("checked", true);
|
||||
} else {
|
||||
this.$addLinkTitleSettings.find("input[value='reference-link']").prop("checked", true);
|
||||
}
|
||||
|
||||
this.updateTitleSettingsVisibility();
|
||||
|
||||
await openDialog(this.$widget);
|
||||
|
||||
this.$autoComplete.val("");
|
||||
this.$linkTitle.val("");
|
||||
|
||||
const setDefaultLinkTitle = async (noteId: string) => {
|
||||
const noteTitle = await treeService.getNoteTitle(noteId);
|
||||
this.$linkTitle.val(noteTitle);
|
||||
};
|
||||
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
});
|
||||
|
||||
this.$autoComplete.on("autocomplete:noteselected", (event: JQuery.Event, suggestion: Suggestion) => {
|
||||
if (!suggestion.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateTitleSettingsVisibility();
|
||||
|
||||
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath);
|
||||
|
||||
if (noteId) {
|
||||
setDefaultLinkTitle(noteId);
|
||||
}
|
||||
});
|
||||
|
||||
this.$autoComplete.on("autocomplete:externallinkselected", (event: JQuery.Event, suggestion: Suggestion) => {
|
||||
if (!suggestion.externalLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateTitleSettingsVisibility();
|
||||
|
||||
this.$linkTitle.val(suggestion.externalLink);
|
||||
});
|
||||
|
||||
this.$autoComplete.on("autocomplete:cursorchanged", (event: JQuery.Event, suggestion: Suggestion) => {
|
||||
if (suggestion.externalLink) {
|
||||
this.$linkTitle.val(suggestion.externalLink);
|
||||
} else {
|
||||
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath!);
|
||||
|
||||
if (noteId) {
|
||||
setDefaultLinkTitle(noteId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (text && text.trim()) {
|
||||
noteAutocompleteService.setText(this.$autoComplete, text);
|
||||
} else {
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
}
|
||||
|
||||
this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text
|
||||
}
|
||||
|
||||
private getLinkType() {
|
||||
if (this.$autoComplete.getSelectedExternalLink()) {
|
||||
return "external-link";
|
||||
}
|
||||
|
||||
return this.$addLinkTitleSettings.find("input[type=radio]:checked").val();
|
||||
}
|
||||
|
||||
private updateTitleSettingsVisibility() {
|
||||
const linkType = this.getLinkType();
|
||||
|
||||
this.$addLinkTitleFormGroup.toggle(linkType !== "reference-link");
|
||||
this.$addLinkTitleRadios.toggle(linkType !== "external-link");
|
||||
}
|
||||
}
|
||||
162
apps/client/src/widgets/dialogs/add_link.tsx
Normal file
162
apps/client/src/widgets/dialogs/add_link.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import tree from "../../services/tree";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
|
||||
import { logError } from "../../services/ws";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||
|
||||
function AddLinkDialogComponent() {
|
||||
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
|
||||
const initialText = useRef<string>();
|
||||
const [ linkTitle, setLinkTitle ] = useState("");
|
||||
const hasSelection = textTypeWidget?.hasSelection();
|
||||
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
initialText.current = text;
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
async function setDefaultLinkTitle(noteId: string) {
|
||||
const noteTitle = await tree.getNoteTitle(noteId);
|
||||
setLinkTitle(noteTitle);
|
||||
}
|
||||
|
||||
function resetExternalLink() {
|
||||
if (linkType === "external-link") {
|
||||
setLinkType("reference-link");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!suggestion) {
|
||||
resetExternalLink();
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestion.notePath) {
|
||||
const noteId = tree.getNoteIdFromUrl(suggestion.notePath);
|
||||
if (noteId) {
|
||||
setDefaultLinkTitle(noteId);
|
||||
}
|
||||
resetExternalLink();
|
||||
}
|
||||
|
||||
if (suggestion.externalLink) {
|
||||
setLinkTitle(suggestion.externalLink);
|
||||
setLinkType("external-link");
|
||||
}
|
||||
}, [suggestion]);
|
||||
|
||||
function onShown() {
|
||||
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
|
||||
if (!initialText.current) {
|
||||
note_autocomplete.showRecentNotes($autocompleteEl);
|
||||
} else {
|
||||
note_autocomplete.setText($autocompleteEl, initialText.current);
|
||||
}
|
||||
|
||||
// to be able to quickly remove entered text
|
||||
$autocompleteEl
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (suggestion?.notePath) {
|
||||
// Handle note link
|
||||
setShown(false);
|
||||
textTypeWidget?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestion?.externalLink) {
|
||||
// Handle external link
|
||||
setShown(false);
|
||||
textTypeWidget?.addLink(suggestion.externalLink, linkTitle, true);
|
||||
} else {
|
||||
logError("No link to add.");
|
||||
}
|
||||
}
|
||||
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="add-link-dialog"
|
||||
size="lg"
|
||||
maxWidth={1000}
|
||||
title={t("add_link.add_link")}
|
||||
helpPageId="QEAPj01N5f7w"
|
||||
footer={<Button text={t("add_link.button_add_link")} keyboardShortcut="Enter" />}
|
||||
onSubmit={onSubmit}
|
||||
onShown={onShown}
|
||||
onHidden={() => {
|
||||
setSuggestion(null);
|
||||
setShown(false);
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("add_link.note")}>
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={setSuggestion}
|
||||
opts={{
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{!hasSelection && (
|
||||
<div className="add-link-title-settings">
|
||||
{(linkType !== "external-link") && (
|
||||
<>
|
||||
<FormRadioGroup
|
||||
name="link-type"
|
||||
currentValue={linkType}
|
||||
values={[
|
||||
{ value: "reference-link", label: t("add_link.link_title_mirrors") },
|
||||
{ value: "hyper-link", label: t("add_link.link_title_arbitrary") }
|
||||
]}
|
||||
onChange={(newValue) => setLinkType(newValue as LinkType)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(linkType !== "reference-link" && (
|
||||
<div className="add-link-title-form-group form-group">
|
||||
<br/>
|
||||
<label>
|
||||
{t("add_link.link_title")}
|
||||
|
||||
<input className="link-title form-control" style={{ width: "100%" }}
|
||||
value={linkTitle}
|
||||
onInput={e => setLinkTitle((e.target as HTMLInputElement)?.value ?? "")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class AddLinkDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <AddLinkDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import treeService from "../../services/tree.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`<div class="branch-prefix-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<form class="branch-prefix-form">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("branch_prefix.edit_branch_prefix")}</h5>
|
||||
<button class="help-button" type="button" data-help-page="tree-concepts.html#prefix" title="${t("branch_prefix.help_on_tree_prefix")}">?</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("branch_prefix.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="branch-prefix-input">${t("branch_prefix.prefix")}</label>
|
||||
|
||||
<div class="input-group">
|
||||
<input class="branch-prefix-input form-control">
|
||||
<div class="branch-prefix-note-title input-group-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary btn-sm">${t("branch_prefix.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class BranchPrefixDialog extends BasicWidget {
|
||||
private modal!: Modal;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $treePrefixInput!: JQuery<HTMLElement>;
|
||||
private $noteTitle!: JQuery<HTMLElement>;
|
||||
private branchId: string | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$form = this.$widget.find(".branch-prefix-form");
|
||||
this.$treePrefixInput = this.$widget.find(".branch-prefix-input");
|
||||
this.$noteTitle = this.$widget.find(".branch-prefix-note-title");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
this.savePrefix();
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => this.$treePrefixInput.trigger("focus"));
|
||||
}
|
||||
|
||||
async refresh(notePath: string) {
|
||||
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
|
||||
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newBranchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!newBranchId) {
|
||||
return;
|
||||
}
|
||||
this.branchId = newBranchId;
|
||||
|
||||
const branch = froca.getBranch(this.branchId);
|
||||
if (!branch || branch.noteId === "root") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNote = await froca.getNote(branch.parentNoteId);
|
||||
if (!parentNote || parentNote.type === "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$treePrefixInput.val(branch.prefix || "");
|
||||
|
||||
const noteTitle = await treeService.getNoteTitle(noteId);
|
||||
this.$noteTitle.text(` - ${noteTitle}`);
|
||||
}
|
||||
|
||||
async editBranchPrefixEvent() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refresh(notePath);
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
|
||||
async savePrefix() {
|
||||
const prefix = this.$treePrefixInput.val();
|
||||
|
||||
await server.put(`branches/${this.branchId}/set-prefix`, { prefix: prefix });
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
toastService.showMessage(t("branch_prefix.branch_prefix_saved"));
|
||||
}
|
||||
}
|
||||
89
apps/client/src/widgets/dialogs/branch_prefix.tsx
Normal file
89
apps/client/src/widgets/dialogs/branch_prefix.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import toast from "../../services/toast.js";
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import tree from "../../services/tree.js";
|
||||
import Button from "../react/Button.jsx";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import FBranch from "../../entities/fbranch.js";
|
||||
|
||||
function BranchPrefixDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ branch, setBranch ] = useState<FBranch>();
|
||||
const [ prefix, setPrefix ] = useState(branch?.prefix ?? "");
|
||||
const branchInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useTriliumEvent("editBranchPrefix", async () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newBranchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!newBranchId) {
|
||||
return;
|
||||
}
|
||||
const parentNote = await froca.getNote(parentNoteId);
|
||||
if (!parentNote || parentNote.type === "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
setBranch(froca.getBranch(newBranchId));
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!branch) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePrefix(branch.branchId, prefix);
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="branch-prefix-dialog"
|
||||
title={t("branch_prefix.edit_branch_prefix")}
|
||||
size="lg"
|
||||
onShown={() => branchInput.current?.focus()}
|
||||
onHidden={() => setShown(false)}
|
||||
onSubmit={onSubmit}
|
||||
helpPageId="TBwsyfadTA18"
|
||||
footer={<Button text={t("branch_prefix.save")} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("branch_prefix.prefix")}>
|
||||
<div class="input-group">
|
||||
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
|
||||
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
|
||||
<div class="branch-prefix-note-title input-group-text"> - {branch && branch.getNoteFromCache().title}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class BranchPrefixDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <BranchPrefixDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function savePrefix(branchId: string, prefix: string) {
|
||||
await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix });
|
||||
toast.showMessage(t("branch_prefix.branch_prefix_saved"));
|
||||
}
|
||||
24
apps/client/src/widgets/dialogs/bulk_actions.css
Normal file
24
apps/client/src/widgets/dialogs/bulk_actions.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.bulk-actions-dialog .modal-body h4:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-available-action-list button {
|
||||
padding: 2px 7px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list td {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import bulkActionService from "../../services/bulk_action.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { closeActiveDialog, openDialog } from "../../services/dialog.js";
|
||||
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="bulk-actions-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
.bulk-actions-dialog .modal-body h4:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-available-action-list button {
|
||||
padding: 2px 7px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list td {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("bulk_actions.bulk_actions")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("bulk_actions.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h4>${t("bulk_actions.affected_notes")}: <span class="affected-note-count">0</span></h4>
|
||||
|
||||
<div class="form-check">
|
||||
<label for="include-descendants" class="form-check-label tn-checkbox">
|
||||
<input id="include-descendants" class="include-descendants form-check-input" type="checkbox" value="">
|
||||
${t("bulk_actions.include_descendants")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4>${t("bulk_actions.available_actions")}</h4>
|
||||
|
||||
<table class="bulk-available-action-list"></table>
|
||||
|
||||
<h4>${t("bulk_actions.chosen_actions")}</h4>
|
||||
|
||||
<table class="bulk-existing-action-list"></table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="execute-bulk-actions btn btn-primary">${t("bulk_actions.execute_bulk_actions")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class BulkActionsDialog extends BasicWidget {
|
||||
private $includeDescendants!: JQuery<HTMLElement>;
|
||||
private $affectedNoteCount!: JQuery<HTMLElement>;
|
||||
private $availableActionList!: JQuery<HTMLElement>;
|
||||
private $existingActionList!: JQuery<HTMLElement>;
|
||||
private $executeButton!: JQuery<HTMLElement>;
|
||||
private selectedOrActiveNoteIds: string[] | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$includeDescendants = this.$widget.find(".include-descendants");
|
||||
this.$includeDescendants.on("change", () => this.refresh());
|
||||
|
||||
this.$affectedNoteCount = this.$widget.find(".affected-note-count");
|
||||
|
||||
this.$availableActionList = this.$widget.find(".bulk-available-action-list");
|
||||
this.$existingActionList = this.$widget.find(".bulk-existing-action-list");
|
||||
|
||||
this.$widget.on("click", "[data-action-add]", async (event) => {
|
||||
const actionName = $(event.target).attr("data-action-add");
|
||||
if (!actionName) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bulkActionService.addAction("_bulkAction", actionName);
|
||||
await this.refresh();
|
||||
});
|
||||
|
||||
this.$executeButton = this.$widget.find(".execute-bulk-actions");
|
||||
this.$executeButton.on("click", async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: this.selectedOrActiveNoteIds,
|
||||
includeDescendants: this.$includeDescendants.is(":checked")
|
||||
});
|
||||
|
||||
toastService.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
closeActiveDialog();
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.renderAvailableActions();
|
||||
|
||||
if (!this.selectedOrActiveNoteIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { affectedNoteCount } = await server.post("bulk-action/affected-notes", {
|
||||
noteIds: this.selectedOrActiveNoteIds,
|
||||
includeDescendants: this.$includeDescendants.is(":checked")
|
||||
}) as { affectedNoteCount: number };
|
||||
|
||||
this.$affectedNoteCount.text(affectedNoteCount);
|
||||
|
||||
const bulkActionNote = await froca.getNote("_bulkAction");
|
||||
if (!bulkActionNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = bulkActionService.parseActions(bulkActionNote);
|
||||
|
||||
this.$existingActionList.empty();
|
||||
|
||||
if (actions.length > 0) {
|
||||
this.$existingActionList.append(...actions.map((action) => action.render()).filter((action) => action !== null));
|
||||
} else {
|
||||
this.$existingActionList.append($("<p>").text(t("bulk_actions.none_yet")));
|
||||
}
|
||||
}
|
||||
|
||||
renderAvailableActions() {
|
||||
this.$availableActionList.empty();
|
||||
|
||||
for (const actionGroup of bulkActionService.ACTION_GROUPS) {
|
||||
const $actionGroupList = $("<td>");
|
||||
const $actionGroup = $("<tr>")
|
||||
.append($("<td>").text(`${actionGroup.title}: `))
|
||||
.append($actionGroupList);
|
||||
|
||||
for (const action of actionGroup.actions) {
|
||||
$actionGroupList.append($('<button class="btn btn-sm">').attr("data-action-add", action.actionName).text(action.actionTitle));
|
||||
}
|
||||
|
||||
this.$availableActionList.append($actionGroup);
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// only refreshing deleted attrs, otherwise components update themselves
|
||||
if (loadResults.getAttributeRows().find((row) => row.type === "label" && row.name === "action" && row.noteId === "_bulkAction" && row.isDeleted)) {
|
||||
// this may be triggered from e.g., sync without open widget, then no need to refresh the widget
|
||||
if (this.selectedOrActiveNoteIds && this.$widget.is(":visible")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async openBulkActionsDialogEvent({ selectedOrActiveNoteIds }: EventData<"openBulkActionsDialog">) {
|
||||
this.selectedOrActiveNoteIds = selectedOrActiveNoteIds;
|
||||
this.$includeDescendants.prop("checked", false);
|
||||
|
||||
await this.refresh();
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
126
apps/client/src/widgets/dialogs/bulk_actions.tsx
Normal file
126
apps/client/src/widgets/dialogs/bulk_actions.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState, useCallback } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import "./bulk_actions.css";
|
||||
import { BulkActionAffectedNotes } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import Button from "../react/Button";
|
||||
import bulk_action from "../../services/bulk_action";
|
||||
import toast from "../../services/toast";
|
||||
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function BulkActionComponent() {
|
||||
const [ selectedOrActiveNoteIds, setSelectedOrActiveNoteIds ] = useState<string[]>();
|
||||
const [ bulkActionNote, setBulkActionNote ] = useState<FNote | null>();
|
||||
const [ includeDescendants, setIncludeDescendants ] = useState(false);
|
||||
const [ affectedNoteCount, setAffectedNoteCount ] = useState(0);
|
||||
const [ existingActions, setExistingActions ] = useState<RenameNoteBulkAction[]>([]);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("openBulkActionsDialog", async ({ selectedOrActiveNoteIds }) => {
|
||||
setSelectedOrActiveNoteIds(selectedOrActiveNoteIds);
|
||||
setBulkActionNote(await froca.getNote("_bulkAction"));
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrActiveNoteIds || !bulkActionNote) return;
|
||||
|
||||
server.post<BulkActionAffectedNotes>("bulk-action/affected-notes", {
|
||||
noteIds: selectedOrActiveNoteIds,
|
||||
includeDescendants
|
||||
}).then(({ affectedNoteCount }) => setAffectedNoteCount(affectedNoteCount));
|
||||
}, [ selectedOrActiveNoteIds, includeDescendants, bulkActionNote ]);
|
||||
|
||||
const refreshExistingActions = useCallback(() => {
|
||||
if (!bulkActionNote) return;
|
||||
setExistingActions(bulk_action.parseActions(bulkActionNote));
|
||||
}, [bulkActionNote]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshExistingActions();
|
||||
}, [refreshExistingActions]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttributeRows().find((row) =>
|
||||
row.type === "label" && row.name === "action" && row.noteId === "_bulkAction")) {
|
||||
refreshExistingActions();
|
||||
}
|
||||
}, shown);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="bulk-actions-dialog"
|
||||
size="xl"
|
||||
title={t("bulk_actions.bulk_actions")}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} primary />}
|
||||
show={shown}
|
||||
onSubmit={async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: selectedOrActiveNoteIds,
|
||||
includeDescendants
|
||||
});
|
||||
|
||||
toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
>
|
||||
<h4>{t("bulk_actions.affected_notes")}: <span>{affectedNoteCount}</span></h4>
|
||||
<FormCheckbox
|
||||
name="include-descendants" label={t("bulk_actions.include_descendants")}
|
||||
currentValue={includeDescendants} onChange={setIncludeDescendants}
|
||||
/>
|
||||
|
||||
<h4>{t("bulk_actions.available_actions")}</h4>
|
||||
<AvailableActionsList />
|
||||
|
||||
<h4>{t("bulk_actions.chosen_actions")}</h4>
|
||||
<ExistingActionsList existingActions={existingActions} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AvailableActionsList() {
|
||||
return <table class="bulk-available-action-list">
|
||||
{bulk_action.ACTION_GROUPS.map((actionGroup) => {
|
||||
return (
|
||||
<tr>
|
||||
<td>{ actionGroup.title }:</td>
|
||||
{actionGroup.actions.map(({ actionName, actionTitle }) =>
|
||||
<Button
|
||||
small text={actionTitle}
|
||||
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>;
|
||||
}
|
||||
|
||||
function ExistingActionsList({ existingActions }: { existingActions?: RenameNoteBulkAction[] }) {
|
||||
return (
|
||||
<table class="bulk-existing-action-list">
|
||||
{ existingActions
|
||||
? existingActions
|
||||
.map(action => action.doRender())
|
||||
.filter(renderedAction => renderedAction !== null)
|
||||
: <p>{t("bulk_actions.none_yet")}</p>
|
||||
}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default class BulkActionsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <BulkActionComponent />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="clone-to-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("clone_to.clone_notes_to")}</h5>
|
||||
<button type="button" class="help-button" title="${t("clone_to.help_on_links")}" data-help-page="cloning-notes.html">?</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("clone_to.close")}"></button>
|
||||
</div>
|
||||
<form class="clone-to-form">
|
||||
<div class="modal-body">
|
||||
<h5>${t("clone_to.notes_to_clone")}</h5>
|
||||
|
||||
<ul class="clone-to-note-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="width: 100%">
|
||||
${t("clone_to.target_parent_note")}
|
||||
<div class="input-group">
|
||||
<input class="clone-to-note-autocomplete form-control" placeholder="${t("clone_to.search_for_note_by_its_name")}">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" title="${t("clone_to.cloned_note_prefix_title")}">
|
||||
<label style="width: 100%">
|
||||
${t("clone_to.prefix_optional")}
|
||||
<input class="clone-prefix form-control" style="width: 100%;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">${t("clone_to.clone_to_selected_note")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class CloneToDialog extends BasicWidget {
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $noteAutoComplete!: JQuery<HTMLElement>;
|
||||
private $clonePrefix!: JQuery<HTMLElement>;
|
||||
private $noteList!: JQuery<HTMLElement>;
|
||||
private clonedNoteIds: string[] | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".clone-to-form");
|
||||
this.$noteAutoComplete = this.$widget.find(".clone-to-note-autocomplete");
|
||||
this.$clonePrefix = this.$widget.find(".clone-prefix");
|
||||
this.$noteList = this.$widget.find(".clone-to-note-list");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
const notePath = this.$noteAutoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
this.$widget.modal("hide");
|
||||
this.cloneNotesTo(notePath);
|
||||
} else {
|
||||
logError(t("clone_to.no_path_to_clone_to"));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async cloneNoteIdsToEvent({ noteIds }: EventData<"cloneNoteIdsTo">) {
|
||||
if (!noteIds || noteIds.length === 0) {
|
||||
noteIds = [appContext.tabManager.getActiveContextNoteId() ?? ""];
|
||||
}
|
||||
|
||||
this.clonedNoteIds = [];
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
if (!this.clonedNoteIds.includes(noteId)) {
|
||||
this.clonedNoteIds.push(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(this.$widget);
|
||||
this.$noteAutoComplete.val("").trigger("focus");
|
||||
this.$noteList.empty();
|
||||
|
||||
for (const noteId of this.clonedNoteIds) {
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
this.$noteList.append($("<li>").text(note.title));
|
||||
}
|
||||
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete);
|
||||
noteAutocompleteService.showRecentNotes(this.$noteAutoComplete);
|
||||
}
|
||||
|
||||
async cloneNotesTo(notePath: string) {
|
||||
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetBranchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!targetBranchId || !this.clonedNoteIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cloneNoteId of this.clonedNoteIds) {
|
||||
await branchService.cloneNoteToBranch(cloneNoteId, targetBranchId, this.$clonePrefix.val() as string);
|
||||
|
||||
const clonedNote = await froca.getNote(cloneNoteId);
|
||||
const targetBranch = froca.getBranch(targetBranchId);
|
||||
if (!clonedNote || !targetBranch) {
|
||||
continue;
|
||||
}
|
||||
const targetNote = await targetBranch.getNote();
|
||||
if (!targetNote) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toastService.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title }));
|
||||
}
|
||||
}
|
||||
}
|
||||
120
apps/client/src/widgets/dialogs/clone_to.tsx
Normal file
120
apps/client/src/widgets/dialogs/clone_to.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import froca from "../../services/froca";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import Button from "../react/Button";
|
||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||
import { logError } from "../../services/ws";
|
||||
import tree from "../../services/tree";
|
||||
import branches from "../../services/branches";
|
||||
import toast from "../../services/toast";
|
||||
import NoteList from "../react/NoteList";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function CloneToDialogComponent() {
|
||||
const [ clonedNoteIds, setClonedNoteIds ] = useState<string[]>();
|
||||
const [ prefix, setPrefix ] = useState("");
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const autoCompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useTriliumEvent("cloneNoteIdsTo", ({ noteIds }) => {
|
||||
if (!noteIds || noteIds.length === 0) {
|
||||
noteIds = [appContext.tabManager.getActiveContextNoteId() ?? ""];
|
||||
}
|
||||
|
||||
const clonedNoteIds: string[] = [];
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
if (!clonedNoteIds.includes(noteId)) {
|
||||
clonedNoteIds.push(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
setClonedNoteIds(clonedNoteIds);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
if (!clonedNoteIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notePath = suggestion?.notePath;
|
||||
if (!notePath) {
|
||||
logError(t("clone_to.no_path_to_clone_to"));
|
||||
return;
|
||||
}
|
||||
|
||||
setShown(false);
|
||||
cloneNotesTo(notePath, clonedNoteIds, prefix);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="clone-to-dialog"
|
||||
title={t("clone_to.clone_notes_to")}
|
||||
helpPageId="IakOLONlIfGI"
|
||||
size="lg"
|
||||
footer={<Button text={t("clone_to.clone_to_selected_note")} keyboardShortcut="Enter" />}
|
||||
onSubmit={onSubmit}
|
||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<h5>{t("clone_to.notes_to_clone")}</h5>
|
||||
<NoteList style={{ maxHeight: "200px", overflow: "auto" }} noteIds={clonedNoteIds} />
|
||||
<FormGroup 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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class CloneToDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <CloneToDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function cloneNotesTo(notePath: string, clonedNoteIds: string[], prefix?: string) {
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetBranchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!targetBranchId || !clonedNoteIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cloneNoteId of clonedNoteIds) {
|
||||
await branches.cloneNoteToBranch(cloneNoteId, targetBranchId, prefix);
|
||||
|
||||
const clonedNote = await froca.getNote(cloneNoteId);
|
||||
const targetBranch = froca.getBranch(targetBranchId);
|
||||
if (!clonedNote || !targetBranch) {
|
||||
continue;
|
||||
}
|
||||
const targetNote = await targetBranch.getNote();
|
||||
if (!targetNote) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toast.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title }));
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
const DELETE_NOTE_BUTTON_CLASS = "confirm-dialog-delete-note";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="confirm-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
|
||||
<div class="modal-dialog modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("confirm.confirmation")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("confirm.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="confirm-dialog-content"></div>
|
||||
|
||||
<div class="confirm-dialog-custom"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="confirm-dialog-cancel-button btn btn-sm">${t("confirm.cancel")}</button>
|
||||
|
||||
|
||||
|
||||
<button class="confirm-dialog-ok-button btn btn-primary btn-sm">${t("confirm.ok")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export type ConfirmDialogResult = false | ConfirmDialogOptions;
|
||||
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
confirmed: boolean;
|
||||
isDeleteNoteChecked: boolean;
|
||||
}
|
||||
|
||||
// For "showConfirmDialog"
|
||||
|
||||
export interface ConfirmWithMessageOptions {
|
||||
message: string | HTMLElement | JQuery<HTMLElement>;
|
||||
callback: ConfirmDialogCallback;
|
||||
}
|
||||
|
||||
export interface ConfirmWithTitleOptions {
|
||||
title: string;
|
||||
callback: ConfirmDialogCallback;
|
||||
}
|
||||
|
||||
export default class ConfirmDialog extends BasicWidget {
|
||||
private resolve: ConfirmDialogCallback | null;
|
||||
|
||||
private modal!: Modal;
|
||||
private $originallyFocused!: JQuery<HTMLElement> | null;
|
||||
private $confirmContent!: JQuery<HTMLElement>;
|
||||
private $okButton!: JQuery<HTMLElement>;
|
||||
private $cancelButton!: JQuery<HTMLElement>;
|
||||
private $custom!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.resolve = null;
|
||||
this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$confirmContent = this.$widget.find(".confirm-dialog-content");
|
||||
this.$okButton = this.$widget.find(".confirm-dialog-ok-button");
|
||||
this.$cancelButton = this.$widget.find(".confirm-dialog-cancel-button");
|
||||
this.$custom = this.$widget.find(".confirm-dialog-custom");
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
|
||||
|
||||
this.$widget.on("hidden.bs.modal", () => {
|
||||
if (this.resolve) {
|
||||
this.resolve(false);
|
||||
}
|
||||
|
||||
if (this.$originallyFocused) {
|
||||
this.$originallyFocused.trigger("focus");
|
||||
this.$originallyFocused = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.$cancelButton.on("click", () => this.doResolve(false));
|
||||
this.$okButton.on("click", () => this.doResolve(true));
|
||||
}
|
||||
|
||||
showConfirmDialogEvent({ message, callback }: ConfirmWithMessageOptions) {
|
||||
this.$originallyFocused = $(":focus");
|
||||
|
||||
this.$custom.hide();
|
||||
|
||||
glob.activeDialog = this.$widget;
|
||||
|
||||
if (typeof message === "string") {
|
||||
message = $("<div>").text(message);
|
||||
}
|
||||
|
||||
this.$confirmContent.empty().append(message);
|
||||
|
||||
this.modal.show();
|
||||
|
||||
this.resolve = callback;
|
||||
}
|
||||
|
||||
showConfirmDeleteNoteBoxWithNoteDialogEvent({ title, callback }: ConfirmWithTitleOptions) {
|
||||
glob.activeDialog = this.$widget;
|
||||
|
||||
this.$confirmContent.text(`${t("confirm.are_you_sure_remove_note", { title: title })}`);
|
||||
|
||||
this.$custom
|
||||
.empty()
|
||||
.append("<br/>")
|
||||
.append(
|
||||
$("<div>")
|
||||
.addClass("form-check")
|
||||
.append(
|
||||
$("<label>")
|
||||
.addClass("form-check-label")
|
||||
.attr("style", "text-decoration: underline dotted var(--main-text-color)")
|
||||
.attr("title", `${t("confirm.if_you_dont_check")}`)
|
||||
.append($("<input>").attr("type", "checkbox").addClass(`form-check-input ${DELETE_NOTE_BUTTON_CLASS}`))
|
||||
.append(`${t("confirm.also_delete_note")}`)
|
||||
)
|
||||
);
|
||||
|
||||
this.$custom.show();
|
||||
|
||||
this.modal.show();
|
||||
|
||||
this.resolve = callback;
|
||||
}
|
||||
|
||||
doResolve(ret: boolean) {
|
||||
if (this.resolve) {
|
||||
this.resolve({
|
||||
confirmed: ret,
|
||||
isDeleteNoteChecked: this.$widget.find(`.${DELETE_NOTE_BUTTON_CLASS}:checked`).length > 0
|
||||
});
|
||||
}
|
||||
|
||||
this.resolve = null;
|
||||
|
||||
this.modal.hide();
|
||||
}
|
||||
}
|
||||
102
apps/client/src/widgets/dialogs/confirm.tsx
Normal file
102
apps/client/src/widgets/dialogs/confirm.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useState } from "preact/hooks";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title?: string;
|
||||
message?: string | HTMLElement;
|
||||
callback?: ConfirmDialogCallback;
|
||||
isConfirmDeleteNoteBox?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmDialogComponent() {
|
||||
const [ opts, setOpts ] = useState<ConfirmDialogProps>();
|
||||
const [ isDeleteNoteChecked, setIsDeleteNoteChecked ] = useState(false);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
function showDialog(title: string | null, message: MessageType, callback: ConfirmDialogCallback, isConfirmDeleteNoteBox: boolean) {
|
||||
setOpts({
|
||||
title: title ?? undefined,
|
||||
message: (typeof message === "object" && "length" in message ? message[0] : message),
|
||||
callback,
|
||||
isConfirmDeleteNoteBox
|
||||
});
|
||||
setShown(true);
|
||||
}
|
||||
|
||||
useTriliumEvent("showConfirmDialog", ({ message, callback }) => showDialog(null, message, callback, false));
|
||||
useTriliumEvent("showConfirmDeleteNoteBoxWithNoteDialog", ({ title, callback }) => showDialog(title, t("confirm.are_you_sure_remove_note", { title: title }), callback, true));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="confirm-dialog"
|
||||
title={opts?.title ?? t("confirm.confirmation")}
|
||||
size="md"
|
||||
zIndex={2000}
|
||||
scrollable={true}
|
||||
onHidden={() => {
|
||||
opts?.callback?.({
|
||||
confirmed: false,
|
||||
isDeleteNoteChecked
|
||||
});
|
||||
setShown(false);
|
||||
}}
|
||||
footer={<>
|
||||
<Button text={t("confirm.cancel")} onClick={() => setShown(false)} />
|
||||
<Button text={t("confirm.ok")} onClick={() => {
|
||||
opts?.callback?.({
|
||||
confirmed: true,
|
||||
isDeleteNoteChecked
|
||||
});
|
||||
setShown(false);
|
||||
}} />
|
||||
</>}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
{!opts?.message || typeof opts?.message === "string"
|
||||
? <div>{(opts?.message as string) ?? ""}</div>
|
||||
: <div dangerouslySetInnerHTML={{ __html: opts?.message.outerHTML ?? "" }} />}
|
||||
|
||||
{opts?.isConfirmDeleteNoteBox && (
|
||||
<FormCheckbox
|
||||
name="confirm-dialog-delete-note"
|
||||
label={t("confirm.also_delete_note")}
|
||||
hint={t("confirm.if_you_dont_check")}
|
||||
currentValue={isDeleteNoteChecked} onChange={setIsDeleteNoteChecked} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export type ConfirmDialogResult = false | ConfirmDialogOptions;
|
||||
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
|
||||
type MessageType = string | HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
confirmed: boolean;
|
||||
isDeleteNoteChecked: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmWithMessageOptions {
|
||||
message: MessageType;
|
||||
callback: ConfirmDialogCallback;
|
||||
}
|
||||
|
||||
// For "showConfirmDialog"
|
||||
export interface ConfirmWithTitleOptions {
|
||||
title: string;
|
||||
callback: ConfirmDialogCallback;
|
||||
}
|
||||
|
||||
export default class ConfirmDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ConfirmDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { FAttributeRow } from "../../entities/fattribute.js";
|
||||
import { closeActiveDialog, openDialog } from "../../services/dialog.js";
|
||||
|
||||
// TODO: Use common with server.
|
||||
interface Response {
|
||||
noteIdsToBeDeleted: string[];
|
||||
brokenRelations: FAttributeRow[];
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
proceed: boolean;
|
||||
deleteAllClones?: boolean;
|
||||
eraseNotes?: boolean;
|
||||
}
|
||||
|
||||
interface ShowDeleteNotesDialogOpts {
|
||||
branchIdsToDelete: string[];
|
||||
callback: (opts: ResolveOptions) => void;
|
||||
forceDeleteAllClones: boolean;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="delete-notes-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">${t("delete_notes.delete_notes_preview")}</h4>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("delete_notes.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-checkbox">
|
||||
<label for="delete-all-clones" class="form-check-label tn-checkbox">
|
||||
<input id="delete-all-clones" class="delete-all-clones form-check-input" value="1" type="checkbox">
|
||||
${t("delete_notes.delete_all_clones_description")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-checkbox" style="margin-bottom: 1rem">
|
||||
<label for="erase-notes" class="form-check-label tn-checkbox">
|
||||
<input id="erase-notes" class="erase-notes form-check-input" value="1" type="checkbox">
|
||||
${t("delete_notes.erase_notes_warning")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="delete-notes-list-wrapper">
|
||||
<h4>${t("delete_notes.notes_to_be_deleted", { noteCount: '<span class="deleted-notes-count"></span>' })}</h4>
|
||||
|
||||
<ul class="delete-notes-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="no-note-to-delete-wrapper alert alert-info">
|
||||
${t("delete_notes.no_note_to_delete")}
|
||||
</div>
|
||||
|
||||
<div class="broken-relations-wrapper">
|
||||
<div class="alert alert-danger">
|
||||
<h4>${t("delete_notes.broken_relations_to_be_deleted", { relationCount: '<span class="broke-relations-count"></span>' })}</h4>
|
||||
|
||||
<ul class="broken-relations-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="delete-notes-dialog-cancel-button btn btn-sm">${t("delete_notes.cancel")}</button>
|
||||
|
||||
|
||||
|
||||
<button class="delete-notes-dialog-ok-button btn btn-primary btn-sm">${t("delete_notes.ok")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class DeleteNotesDialog extends BasicWidget {
|
||||
private branchIds: string[] | null;
|
||||
private resolve!: (options: ResolveOptions) => void;
|
||||
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private $okButton!: JQuery<HTMLElement>;
|
||||
private $cancelButton!: JQuery<HTMLElement>;
|
||||
private $deleteNotesList!: JQuery<HTMLElement>;
|
||||
private $brokenRelationsList!: JQuery<HTMLElement>;
|
||||
private $deletedNotesCount!: JQuery<HTMLElement>;
|
||||
private $noNoteToDeleteWrapper!: JQuery<HTMLElement>;
|
||||
private $deleteNotesListWrapper!: JQuery<HTMLElement>;
|
||||
private $brokenRelationsListWrapper!: JQuery<HTMLElement>;
|
||||
private $brokenRelationsCount!: JQuery<HTMLElement>;
|
||||
private $deleteAllClones!: JQuery<HTMLElement>;
|
||||
private $eraseNotes!: JQuery<HTMLElement>;
|
||||
|
||||
private forceDeleteAllClones?: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.branchIds = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$content = this.$widget.find(".recent-changes-content");
|
||||
this.$okButton = this.$widget.find(".delete-notes-dialog-ok-button");
|
||||
this.$cancelButton = this.$widget.find(".delete-notes-dialog-cancel-button");
|
||||
this.$deleteNotesList = this.$widget.find(".delete-notes-list");
|
||||
this.$brokenRelationsList = this.$widget.find(".broken-relations-list");
|
||||
this.$deletedNotesCount = this.$widget.find(".deleted-notes-count");
|
||||
this.$noNoteToDeleteWrapper = this.$widget.find(".no-note-to-delete-wrapper");
|
||||
this.$deleteNotesListWrapper = this.$widget.find(".delete-notes-list-wrapper");
|
||||
this.$brokenRelationsListWrapper = this.$widget.find(".broken-relations-wrapper");
|
||||
this.$brokenRelationsCount = this.$widget.find(".broke-relations-count");
|
||||
this.$deleteAllClones = this.$widget.find(".delete-all-clones");
|
||||
this.$eraseNotes = this.$widget.find(".erase-notes");
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
|
||||
|
||||
this.$cancelButton.on("click", () => {
|
||||
closeActiveDialog();
|
||||
|
||||
this.resolve({ proceed: false });
|
||||
});
|
||||
|
||||
this.$okButton.on("click", () => {
|
||||
closeActiveDialog();
|
||||
|
||||
this.resolve({
|
||||
proceed: true,
|
||||
deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked(),
|
||||
eraseNotes: this.isEraseNotesChecked()
|
||||
});
|
||||
});
|
||||
|
||||
this.$deleteAllClones.on("click", () => this.renderDeletePreview());
|
||||
}
|
||||
|
||||
async renderDeletePreview() {
|
||||
const response = await server.post<Response>("delete-notes-preview", {
|
||||
branchIdsToDelete: this.branchIds,
|
||||
deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked()
|
||||
});
|
||||
|
||||
this.$deleteNotesList.empty();
|
||||
this.$brokenRelationsList.empty();
|
||||
|
||||
this.$deleteNotesListWrapper.toggle(response.noteIdsToBeDeleted.length > 0);
|
||||
this.$noNoteToDeleteWrapper.toggle(response.noteIdsToBeDeleted.length === 0);
|
||||
|
||||
for (const note of await froca.getNotes(response.noteIdsToBeDeleted)) {
|
||||
this.$deleteNotesList.append($("<li>").append(await linkService.createLink(note.noteId, { showNotePath: true })));
|
||||
}
|
||||
|
||||
this.$deletedNotesCount.text(response.noteIdsToBeDeleted.length);
|
||||
|
||||
this.$brokenRelationsListWrapper.toggle(response.brokenRelations.length > 0);
|
||||
this.$brokenRelationsCount.text(response.brokenRelations.length);
|
||||
|
||||
await froca.getNotes(response.brokenRelations.map((br) => br.noteId));
|
||||
|
||||
for (const attr of response.brokenRelations) {
|
||||
this.$brokenRelationsList.append(
|
||||
$("<li>").html(
|
||||
t("delete_notes.deleted_relation_text", {
|
||||
note: (await linkService.createLink(attr.value)).html(),
|
||||
relation: `<code>${attr.name}</code>`,
|
||||
source: (await linkService.createLink(attr.noteId)).html()
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async showDeleteNotesDialogEvent({ branchIdsToDelete, callback, forceDeleteAllClones }: ShowDeleteNotesDialogOpts) {
|
||||
this.branchIds = branchIdsToDelete;
|
||||
this.forceDeleteAllClones = forceDeleteAllClones;
|
||||
|
||||
await this.renderDeletePreview();
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.$deleteAllClones.prop("checked", !!forceDeleteAllClones).prop("disabled", !!forceDeleteAllClones);
|
||||
|
||||
this.$eraseNotes.prop("checked", false);
|
||||
|
||||
this.resolve = callback;
|
||||
}
|
||||
|
||||
isDeleteAllClonesChecked() {
|
||||
return this.$deleteAllClones.is(":checked");
|
||||
}
|
||||
|
||||
isEraseNotesChecked() {
|
||||
return this.$eraseNotes.is(":checked");
|
||||
}
|
||||
}
|
||||
181
apps/client/src/widgets/dialogs/delete_notes.tsx
Normal file
181
apps/client/src/widgets/dialogs/delete_notes.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import FormCheckbox from "../react/FormCheckbox.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import type { DeleteNotesPreview } from "@triliumnext/commons";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import FNote from "../../entities/fnote.js";
|
||||
import link from "../../services/link.js";
|
||||
import Button from "../react/Button.jsx";
|
||||
import Alert from "../react/Alert.jsx";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
|
||||
export interface ResolveOptions {
|
||||
proceed: boolean;
|
||||
deleteAllClones?: boolean;
|
||||
eraseNotes?: boolean;
|
||||
}
|
||||
|
||||
interface ShowDeleteNotesDialogOpts {
|
||||
branchIdsToDelete?: string[];
|
||||
callback?: (opts: ResolveOptions) => void;
|
||||
forceDeleteAllClones?: boolean;
|
||||
}
|
||||
|
||||
interface BrokenRelationData {
|
||||
note: string;
|
||||
relation: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function DeleteNotesDialogComponent() {
|
||||
const [ opts, setOpts ] = useState<ShowDeleteNotesDialogOpts>({});
|
||||
const [ deleteAllClones, setDeleteAllClones ] = useState(false);
|
||||
const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
|
||||
const [ brokenRelations, setBrokenRelations ] = useState<DeleteNotesPreview["brokenRelations"]>([]);
|
||||
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const okButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useTriliumEvent("showDeleteNotesDialog", (opts) => {
|
||||
setOpts(opts);
|
||||
setShown(true);
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const { branchIdsToDelete, forceDeleteAllClones } = opts;
|
||||
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.post<DeleteNotesPreview>("delete-notes-preview", {
|
||||
branchIdsToDelete,
|
||||
deleteAllClones: forceDeleteAllClones || deleteAllClones
|
||||
}).then(response => {
|
||||
setBrokenRelations(response.brokenRelations);
|
||||
setNoteIdsToBeDeleted(response.noteIdsToBeDeleted);
|
||||
});
|
||||
}, [ opts, deleteAllClones ]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="delete-notes-dialog"
|
||||
size="xl"
|
||||
scrollable
|
||||
title={t("delete_notes.delete_notes_preview")}
|
||||
onShown={() => okButtonRef.current?.focus()}
|
||||
onHidden={() => {
|
||||
opts.callback?.({ proceed: false })
|
||||
setShown(false);
|
||||
}}
|
||||
footer={<>
|
||||
<Button text={t("delete_notes.cancel")}
|
||||
onClick={() => setShown(false)} />
|
||||
<Button text={t("delete_notes.ok")} primary
|
||||
buttonRef={okButtonRef}
|
||||
onClick={() => {
|
||||
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
|
||||
setShown(false);
|
||||
}} />
|
||||
</>}
|
||||
show={shown}
|
||||
>
|
||||
<FormCheckbox name="delete-all-clones" label={t("delete_notes.delete_all_clones_description")}
|
||||
currentValue={deleteAllClones} onChange={setDeleteAllClones}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="erase-notes" label={t("delete_notes.erase_notes_warning")}
|
||||
disabled={opts.forceDeleteAllClones}
|
||||
currentValue={eraseNotes} onChange={setEraseNotes}
|
||||
/>
|
||||
|
||||
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
|
||||
<BrokenRelations brokenRelations={brokenRelations} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
|
||||
const [ noteLinks, setNoteLinks ] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
froca.getNotes(noteIdsToBeDeleted).then(async (notes: FNote[]) => {
|
||||
const noteLinks: string[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
noteLinks.push((await link.createLink(note.noteId, { showNotePath: true })).html());
|
||||
}
|
||||
|
||||
setNoteLinks(noteLinks);
|
||||
});
|
||||
}, [noteIdsToBeDeleted]);
|
||||
|
||||
if (noteIdsToBeDeleted.length) {
|
||||
return (
|
||||
<div className="delete-notes-list-wrapper">
|
||||
<h4>{t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}</h4>
|
||||
|
||||
<ul className="delete-notes-list" style={{ maxHeight: "200px", overflow: "auto" }}>
|
||||
{noteLinks.map((link, index) => (
|
||||
<li key={index} dangerouslySetInnerHTML={{ __html: link }} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Alert type="info">
|
||||
{t("delete_notes.no_note_to_delete")}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPreview["brokenRelations"] }) {
|
||||
const [ notesWithBrokenRelations, setNotesWithBrokenRelations ] = useState<BrokenRelationData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const noteIds = brokenRelations
|
||||
.map(relation => relation.noteId)
|
||||
.filter(noteId => noteId) as string[];
|
||||
froca.getNotes(noteIds).then(async (notes) => {
|
||||
const notesWithBrokenRelations: BrokenRelationData[] = [];
|
||||
for (const attr of brokenRelations) {
|
||||
notesWithBrokenRelations.push({
|
||||
note: (await link.createLink(attr.value)).html(),
|
||||
relation: `<code>${attr.name}</code>`,
|
||||
source: (await link.createLink(attr.noteId)).html()
|
||||
});
|
||||
}
|
||||
setNotesWithBrokenRelations(notesWithBrokenRelations);
|
||||
});
|
||||
}, [brokenRelations]);
|
||||
|
||||
if (brokenRelations.length) {
|
||||
return (
|
||||
<Alert type="danger" title={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
|
||||
<ul className="broken-relations-list" style={{ maxHeight: "200px", overflow: "auto" }}>
|
||||
{brokenRelations.map((_, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<span dangerouslySetInnerHTML={{ __html: t("delete_notes.deleted_relation_text", notesWithBrokenRelations[index] as unknown as Record<string, string>) }} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Alert>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class DeleteNotesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <DeleteNotesDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
16
apps/client/src/widgets/dialogs/export.css
Normal file
16
apps/client/src/widgets/dialogs/export.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.export-dialog form .form-check {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.export-dialog form .format-choice {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.export-dialog form .opml-versions {
|
||||
padding-left: 60px;
|
||||
}
|
||||
|
||||
.export-dialog form .form-check-label {
|
||||
padding: 2px;
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import toastService, { type ToastOptions } from "../../services/toast.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import openService from "../../services/open.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="export-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
.export-dialog .export-form .form-check {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.export-dialog .export-form .format-choice {
|
||||
padding-left: 40px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.export-dialog .export-form .opml-versions {
|
||||
padding-left: 60px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.export-dialog .export-form .form-check-label {
|
||||
padding: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("export.export_note_title")} <span class="export-note-title"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("export.close")}"></button>
|
||||
</div>
|
||||
<form class="export-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="export-type-subtree form-check-input" type="radio" name="export-type" value="subtree">
|
||||
${t("export.export_type_subtree")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="export-subtree-formats format-choice">
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" value="html">
|
||||
${t("export.format_html_zip")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" value="markdown">
|
||||
${t("export.format_markdown")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" value="opml">
|
||||
${t("export.format_opml")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="opml-versions">
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="opml-version" value="1.0">
|
||||
${t("export.opml_version_1")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="opml-version" value="2.0">
|
||||
${t("export.opml_version_2")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-type" value="single">
|
||||
${t("export.export_type_single")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="export-single-formats format-choice">
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-single-format" value="html">
|
||||
${t("export.format_html")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="export-single-format" value="markdown">
|
||||
${t("export.format_markdown")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="export-button btn btn-primary">${t("export.export")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class ExportDialog extends BasicWidget {
|
||||
|
||||
private taskId: string;
|
||||
private branchId: string | null;
|
||||
private modal?: Modal;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $noteTitle!: JQuery<HTMLElement>;
|
||||
private $subtreeFormats!: JQuery<HTMLElement>;
|
||||
private $singleFormats!: JQuery<HTMLElement>;
|
||||
private $subtreeType!: JQuery<HTMLElement>;
|
||||
private $singleType!: JQuery<HTMLElement>;
|
||||
private $exportButton!: JQuery<HTMLElement>;
|
||||
private $opmlVersions!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.taskId = "";
|
||||
this.branchId = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$form = this.$widget.find(".export-form");
|
||||
this.$noteTitle = this.$widget.find(".export-note-title");
|
||||
this.$subtreeFormats = this.$widget.find(".export-subtree-formats");
|
||||
this.$singleFormats = this.$widget.find(".export-single-formats");
|
||||
this.$subtreeType = this.$widget.find(".export-type-subtree");
|
||||
this.$singleType = this.$widget.find(".export-type-single");
|
||||
this.$exportButton = this.$widget.find(".export-button");
|
||||
this.$opmlVersions = this.$widget.find(".opml-versions");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
this.modal?.hide();
|
||||
|
||||
const exportType = this.$widget.find("input[name='export-type']:checked").val();
|
||||
|
||||
if (!exportType) {
|
||||
toastService.showError(t("export.choose_export_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
const exportFormat = exportType === "subtree" ? this.$widget.find("input[name=export-subtree-format]:checked").val() : this.$widget.find("input[name=export-single-format]:checked").val();
|
||||
|
||||
const exportVersion = exportFormat === "opml" ? this.$widget.find("input[name='opml-version']:checked").val() : "1.0";
|
||||
|
||||
if (this.branchId) {
|
||||
this.exportBranch(this.branchId, String(exportType), String(exportFormat), String(exportVersion));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$widget.find("input[name=export-type]").on("change", (e) => {
|
||||
if ((e.currentTarget as HTMLInputElement).value === "subtree") {
|
||||
if (this.$widget.find("input[name=export-subtree-format]:checked").length === 0) {
|
||||
this.$widget.find("input[name=export-subtree-format]:first").prop("checked", true);
|
||||
}
|
||||
|
||||
this.$subtreeFormats.slideDown();
|
||||
this.$singleFormats.slideUp();
|
||||
} else {
|
||||
if (this.$widget.find("input[name=export-single-format]:checked").length === 0) {
|
||||
this.$widget.find("input[name=export-single-format]:first").prop("checked", true);
|
||||
}
|
||||
|
||||
this.$subtreeFormats.slideUp();
|
||||
this.$singleFormats.slideDown();
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find("input[name=export-subtree-format]").on("change", (e) => {
|
||||
if ((e.currentTarget as HTMLInputElement).value === "opml") {
|
||||
this.$opmlVersions.slideDown();
|
||||
} else {
|
||||
this.$opmlVersions.slideUp();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async showExportDialogEvent({ notePath, defaultType }: EventData<"showExportDialog">) {
|
||||
this.taskId = "";
|
||||
this.$exportButton.removeAttr("disabled");
|
||||
|
||||
if (defaultType === "subtree") {
|
||||
this.$subtreeType.prop("checked", true).trigger("change");
|
||||
|
||||
this.$widget.find("input[name=export-subtree-format]:checked").trigger("change");
|
||||
} else if (defaultType === "single") {
|
||||
this.$singleType.prop("checked", true).trigger("change");
|
||||
} else {
|
||||
throw new Error(`Unrecognized type '${defaultType}'`);
|
||||
}
|
||||
|
||||
this.$widget.find(".opml-v2").prop("checked", true); // setting default
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
|
||||
|
||||
if (parentNoteId) {
|
||||
this.branchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
}
|
||||
if (noteId) {
|
||||
this.$noteTitle.text(await treeService.getNoteTitle(noteId));
|
||||
}
|
||||
}
|
||||
|
||||
exportBranch(branchId: string, type: string, format: string, version: string) {
|
||||
this.taskId = utils.randomString(10);
|
||||
|
||||
const url = openService.getUrlForDownload(`api/branches/${branchId}/export/${type}/${format}/${version}/${this.taskId}`);
|
||||
|
||||
openService.download(url);
|
||||
}
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
return {
|
||||
id: id,
|
||||
title: t("export.export_status"),
|
||||
message: message,
|
||||
icon: "arrow-square-up-right"
|
||||
};
|
||||
}
|
||||
|
||||
if (message.taskType !== "export") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("export.export_finished_successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
});
|
||||
167
apps/client/src/widgets/dialogs/export.tsx
Normal file
167
apps/client/src/widgets/dialogs/export.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import tree from "../../services/tree";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import "./export.css";
|
||||
import ws from "../../services/ws";
|
||||
import toastService, { ToastOptions } from "../../services/toast";
|
||||
import utils from "../../services/utils";
|
||||
import open from "../../services/open";
|
||||
import froca from "../../services/froca";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
interface ExportDialogProps {
|
||||
branchId?: string | null;
|
||||
noteTitle?: string;
|
||||
defaultType?: "subtree" | "single";
|
||||
}
|
||||
|
||||
function ExportDialogComponent() {
|
||||
const [ opts, setOpts ] = useState<ExportDialogProps>();
|
||||
const [ exportType, setExportType ] = useState<string>(opts?.defaultType ?? "subtree");
|
||||
const [ subtreeFormat, setSubtreeFormat ] = useState("html");
|
||||
const [ singleFormat, setSingleFormat ] = useState("html");
|
||||
const [ opmlVersion, setOpmlVersion ] = useState("2.0");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showExportDialog", async ({ notePath, defaultType }) => {
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (!parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
|
||||
setOpts({
|
||||
noteTitle: noteId && await tree.getNoteTitle(noteId),
|
||||
defaultType,
|
||||
branchId
|
||||
});
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="export-dialog"
|
||||
title={`${t("export.export_note_title")} ${opts?.noteTitle ?? ""}`}
|
||||
size="lg"
|
||||
onSubmit={() => {
|
||||
if (!opts || !opts.branchId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const format = (exportType === "subtree" ? subtreeFormat : singleFormat);
|
||||
const version = (format === "opml" ? opmlVersion : "1.0");
|
||||
exportBranch(opts.branchId, exportType, format, version);
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={<Button className="export-button" text={t("export.export")} primary />}
|
||||
show={shown}
|
||||
>
|
||||
|
||||
<FormRadioGroup
|
||||
name="export-type"
|
||||
currentValue={exportType} onChange={setExportType}
|
||||
values={[{
|
||||
value: "subtree",
|
||||
label: t("export.export_type_subtree")
|
||||
}]}
|
||||
/>
|
||||
|
||||
{ exportType === "subtree" &&
|
||||
<div className="export-subtree-formats format-choice">
|
||||
<FormRadioGroup
|
||||
name="export-subtree-format"
|
||||
currentValue={subtreeFormat} onChange={setSubtreeFormat}
|
||||
values={[
|
||||
{ value: "html", label: t("export.format_html_zip") },
|
||||
{ value: "markdown", label: t("export.format_markdown") },
|
||||
{ value: "opml", label: t("export.format_opml") }
|
||||
]}
|
||||
/>
|
||||
|
||||
{ subtreeFormat === "opml" &&
|
||||
<div className="opml-versions">
|
||||
<FormRadioGroup
|
||||
name="opml-version"
|
||||
currentValue={opmlVersion} onChange={setOpmlVersion}
|
||||
values={[
|
||||
{ value: "1.0", label: t("export.opml_version_1") },
|
||||
{ value: "2.0", label: t("export.opml_version_2") }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<FormRadioGroup
|
||||
name="export-type"
|
||||
currentValue={exportType} onChange={setExportType}
|
||||
values={[{
|
||||
value: "single",
|
||||
label: t("export.export_type_single")
|
||||
}]}
|
||||
/>
|
||||
|
||||
{ exportType === "single" &&
|
||||
<div class="export-single-formats format-choice">
|
||||
<FormRadioGroup
|
||||
name="export-single-format"
|
||||
currentValue={singleFormat} onChange={setSingleFormat}
|
||||
values={[
|
||||
{ value: "html", label: t("export.format_html") },
|
||||
{ value: "markdown", label: t("export.format_markdown") }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class ExportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ExportDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function exportBranch(branchId: string, type: string, format: string, version: string) {
|
||||
const taskId = utils.randomString(10);
|
||||
const url = open.getUrlForDownload(`api/branches/${branchId}/export/${type}/${format}/${version}/${taskId}`);
|
||||
open.download(url);
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
return {
|
||||
id: id,
|
||||
title: t("export.export_status"),
|
||||
message: message,
|
||||
icon: "arrow-square-up-right"
|
||||
};
|
||||
}
|
||||
|
||||
if (message.taskType !== "export") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("export.export_finished_successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="help-dialog modal use-tn-links" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document" style="min-width: 90%;">
|
||||
<div class="modal-content" style="height: auto;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("help.fullDocumentation")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("help.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="overflow: auto;">
|
||||
<div class="help-cards row row-cols-md-3 g-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.noteNavigation")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li>${t("help.goUpDown")}</li>
|
||||
<li>${t("help.collapseExpand")}</li>
|
||||
<li><kbd data-command="backInNoteHistory">${t("help.notSet")}</kbd>, <kbd data-command="forwardInNoteHistory">${t("help.notSet")}</kbd> - ${t("help.goBackForwards")}</li>
|
||||
<li><kbd data-command="jumpToNote">${t("help.notSet")}</kbd> - ${t("help.showJumpToNoteDialog")}</li>
|
||||
<li><kbd data-command="scrollToActiveNote">${t("help.notSet")}</kbd> - ${t("help.scrollToActiveNote")}</li>
|
||||
<li>${t("help.jumpToParentNote")}</li>
|
||||
<li><kbd data-command="collapseTree">${t("help.notSet")}</kbd> - ${t("help.collapseWholeTree")}</li>
|
||||
<li><kbd data-command="collapseSubtree">${t("help.notSet")}</kbd> - ${t("help.collapseSubTree")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.tabShortcuts")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li>${t("help.newTabNoteLink")}</li>
|
||||
<li>${t("help.newTabWithActivationNoteLink")}</li>
|
||||
</ul>
|
||||
<h6>${t("help.onlyInDesktop")}:</h6>
|
||||
<ul>
|
||||
<li><kbd data-command="openNewTab">${t("help.notSet")}</kbd> ${t("help.openEmptyTab")}</li>
|
||||
<li><kbd data-command="closeActiveTab">${t("help.notSet")}</kbd> ${t("help.closeActiveTab")}</li>
|
||||
<li><kbd data-command="activateNextTab">${t("help.notSet")}</kbd> ${t("help.activateNextTab")}</li>
|
||||
<li><kbd data-command="activatePreviousTab">${t("help.notSet")}</kbd> ${t("help.activatePreviousTab")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.creatingNotes")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li><kbd data-command="createNoteAfter">${t("help.notSet")}</kbd> - ${t("help.createNoteAfter")}</li>
|
||||
<li><kbd data-command="createNoteInto">${t("help.notSet")}</kbd> - ${t("help.createNoteInto")}</li>
|
||||
<li><kbd data-command="editBranchPrefix">${t("help.notSet")}</kbd> - ${t("help.editBranchPrefix")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.movingCloningNotes")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li><kbd data-command="moveNoteUp">${t("help.notSet")}</kbd>, <kbd data-command="moveNoteDown">${t("help.notSet")}</kbd> - ${t("help.moveNoteUpDown")}</li>
|
||||
<li><kbd data-command="moveNoteUpInHierarchy">${t("help.notSet")}</kbd>, <kbd data-command="moveNoteDownInHierarchy">${t("help.notSet")}</kbd> - ${t("help.moveNoteUpHierarchy")}</li>
|
||||
<li><kbd data-command="addNoteAboveToSelection">${t("help.notSet")}</kbd>, <kbd data-command="addNoteBelowToSelection">${t("help.notSet")}</kbd> - ${t("help.multiSelectNote")}</li>
|
||||
<li><kbd data-command="selectAllNotesInParent">${t("help.notSet")}</kbd> - ${t("help.selectAllNotes")}</li>
|
||||
<li>${t("help.selectNote")}</li>
|
||||
<li><kbd data-command="copyNotesToClipboard">${t("help.notSet")}</kbd> - ${t("help.copyNotes")}</li>
|
||||
<li><kbd data-command="cutNotesToClipboard">${t("help.notSet")}</kbd> - ${t("help.cutNotes")}</li>
|
||||
<li><kbd data-command="pasteNotesFromClipboard">${t("help.notSet")}</kbd> - ${t("help.pasteNotes")}</li>
|
||||
<li><kbd data-command="deleteNotes">${t("help.notSet")}</kbd> - ${t("help.deleteNotes")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.editingNotes")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li><kbd data-command="editNoteTitle">${t("help.notSet")}</kbd> ${t("help.editNoteTitle")}</li>
|
||||
<li>${t("help.createEditLink")}</li>
|
||||
<li><kbd data-command="addLinkToText">${t("help.notSet")}</kbd> - ${t("help.createInternalLink")}</li>
|
||||
<li><kbd data-command="followLinkUnderCursor">${t("help.notSet")}</kbd> - ${t("help.followLink")}</li>
|
||||
<li><kbd data-command="insertDateTimeToText">${t("help.notSet")}</kbd> - ${t("help.insertDateTime")}</li>
|
||||
<li><kbd data-command="scrollToActiveNote">${t("help.notSet")}</kbd> - ${t("help.jumpToTreePane")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><a class="external" href="https://triliumnext.github.io/Docs/Wiki/text-notes.html#markdown--autoformat">${t("help.markdownAutoformat")}</a></h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li>${t("help.headings")}</li>
|
||||
<li>${t("help.bulletList")}</li>
|
||||
<li>${t("help.numberedList")}</li>
|
||||
<li>${t("help.blockQuote")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.troubleshooting")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li><kbd data-command="reloadFrontendApp">${t("help.notSet")}</kbd> - ${t("help.reloadFrontend")}</li>
|
||||
<li><kbd data-command="openDevTools">${t("help.notSet")}</kbd> - ${t("help.showDevTools")}</li>
|
||||
<li><kbd data-command="showSQLConsole">${t("help.notSet")}</kbd> - ${t("help.showSQLConsole")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${t("help.other")}</h5>
|
||||
|
||||
<p class="card-text">
|
||||
<ul>
|
||||
<li><kbd data-command="quickSearch">${t("help.notSet")}</kbd> - ${t("help.quickSearch")}</li>
|
||||
<li><kbd data-command="findInText">${t("help.notSet")}</kbd> - ${t("help.inPageSearch")}</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class HelpDialog extends BasicWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
}
|
||||
|
||||
showCheatsheetEvent() {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
171
apps/client/src/widgets/dialogs/help.tsx
Normal file
171
apps/client/src/widgets/dialogs/help.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { CommandNames } from "../../components/app_context.js";
|
||||
import RawHtml from "../react/RawHtml.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import keyboard_actions from "../../services/keyboard_actions.js";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
|
||||
function HelpDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
useTriliumEvent("showCheatsheet", () => setShown(true));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("help.title")} className="help-dialog use-tn-links" minWidth="90%" size="lg" scrollable
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<div className="help-cards row row-cols-md-3 g-3">
|
||||
<Card title={t("help.noteNavigation")}>
|
||||
<ul>
|
||||
<FixedKeyboardShortcut keys={["Up", "Down"]} description={t("help.goUpDown")} />
|
||||
<FixedKeyboardShortcut keys={["Left", "Right"]} description={t("help.collapseExpand")} />
|
||||
<KeyboardShortcut commands="backInNoteHistory" description={t("help.goBackForwards")} />
|
||||
<KeyboardShortcut commands="jumpToNote" description={t("help.showJumpToNoteDialog")} />
|
||||
<KeyboardShortcut commands="scrollToActiveNote" description={t("help.scrollToActiveNote")} />
|
||||
<FixedKeyboardShortcut keys={["Backspace"]} description={t("help.jumpToParentNote")} />
|
||||
<KeyboardShortcut commands="collapseTree" description={t("help.collapseWholeTree")} />
|
||||
<KeyboardShortcut commands="collapseSubtree" description={t("help.collapseSubTree")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.tabShortcuts")}>
|
||||
<ul>
|
||||
<FixedKeyboardShortcut keys={["Ctrl+Click", "Ctrl+middle click"]} description={t("help.newTabNoteLink")} />
|
||||
<FixedKeyboardShortcut keys={["Ctrl+Shift+Click", "Shift+middle click"]} description={t("help.newTabWithActivationNoteLink")} />
|
||||
</ul>
|
||||
|
||||
<h6>{t("help.onlyInDesktop")}</h6>
|
||||
<ul>
|
||||
<KeyboardShortcut commands="openNewTab" description={t("help.openEmptyTab")} />
|
||||
<KeyboardShortcut commands="closeActiveTab" description={t("help.closeActiveTab")} />
|
||||
<KeyboardShortcut commands="activateNextTab" description={t("help.activateNextTab")} />
|
||||
<KeyboardShortcut commands="activatePreviousTab" description={t("help.activatePreviousTab")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.creatingNotes")}>
|
||||
<ul>
|
||||
<KeyboardShortcut commands="createNoteAfter" description={t("help.createNoteAfter")} />
|
||||
<KeyboardShortcut commands="createNoteInto" description={t("help.createNoteInto")} />
|
||||
<KeyboardShortcut commands="editBranchPrefix" description={t("help.editBranchPrefix")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.movingCloningNotes")}>
|
||||
<ul>
|
||||
<KeyboardShortcut commands={["moveNoteUp", "moveNoteDown"]} description={t("help.moveNoteUpDown")} />
|
||||
<KeyboardShortcut commands={["moveNoteUpInHierarchy", "moveNoteDownInHierarchy"]} description={t("help.moveNoteUpHierarchy")} />
|
||||
<KeyboardShortcut commands={["addNoteAboveToSelection", "addNoteBelowToSelection"]} description={t("help.multiSelectNote")} />
|
||||
<KeyboardShortcut commands="selectAllNotesInParent" description={t("help.selectAllNotes")} />
|
||||
<FixedKeyboardShortcut keys={["Shift+Click"]} description={t("help.selectNote")} />
|
||||
<KeyboardShortcut commands="copyNotesToClipboard" description={t("help.copyNotes")} />
|
||||
<KeyboardShortcut commands="cutNotesToClipboard" description={t("help.cutNotes")} />
|
||||
<KeyboardShortcut commands="pasteNotesFromClipboard" description={t("help.pasteNotes")} />
|
||||
<KeyboardShortcut commands="deleteNotes" description={t("help.deleteNotes")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.editingNotes")}>
|
||||
<ul>
|
||||
<KeyboardShortcut commands="editNoteTitle" description={t("help.editNoteTitle")} />
|
||||
<FixedKeyboardShortcut keys={["Ctrl+K"]} description={t("help.createEditLink")} />
|
||||
<KeyboardShortcut commands="addLinkToText" description={t("help.createInternalLink")} />
|
||||
<KeyboardShortcut commands="followLinkUnderCursor" description={t("help.followLink")} />
|
||||
<KeyboardShortcut commands="insertDateTimeToText" description={t("help.insertDateTime")} />
|
||||
<KeyboardShortcut commands="scrollToActiveNote" description={t("help.jumpToTreePane")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.markdownAutoformat")}>
|
||||
<ul>
|
||||
<li><RawHtml html={t("help.headings")} /></li>
|
||||
<li><RawHtml html={t("help.bulletList")} /></li>
|
||||
<li><RawHtml html={t("help.numberedList")} /></li>
|
||||
<li><RawHtml html={t("help.blockQuote")} /></li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.troubleshooting")}>
|
||||
<ul>
|
||||
<KeyboardShortcut commands="reloadFrontendApp" description={t("help.reloadFrontend")} />
|
||||
<KeyboardShortcut commands="openDevTools" description={t("help.showDevTools")} />
|
||||
<KeyboardShortcut commands="showSQLConsole" description={t("help.showSQLConsole")} />
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t("help.other")}>
|
||||
<ul>
|
||||
<KeyboardShortcut commands="quickSearch" description={t("help.quickSearch")} />
|
||||
<KeyboardShortcut commands="findInText" description={t("help.inPageSearch")} />
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyboardShortcut({ commands, description }: { commands: CommandNames | CommandNames[], description: string }) {
|
||||
const [ shortcuts, setShortcuts ] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const shortcuts: string[] = [];
|
||||
for (const command of Array.isArray(commands) ? commands : [commands]) {
|
||||
const action = await keyboard_actions.getAction(command);
|
||||
if (action) {
|
||||
shortcuts.push(...(action.effectiveShortcuts ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
if (shortcuts.length === 0) {
|
||||
shortcuts.push(t("help.notSet"));
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts);
|
||||
})();
|
||||
}, [commands]);
|
||||
|
||||
return FixedKeyboardShortcut({
|
||||
keys: shortcuts,
|
||||
description
|
||||
});
|
||||
}
|
||||
|
||||
function FixedKeyboardShortcut({ keys, description }: { keys?: string[], description: string }) {
|
||||
return (
|
||||
<li>
|
||||
{keys && keys.map((key, index) =>
|
||||
<>
|
||||
<kbd key={index}>{key}</kbd>
|
||||
{index < keys.length - 1 ? ", " : "" }
|
||||
</>
|
||||
)} - <RawHtml html={description} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string, children: ComponentChildren }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{title}</h5>
|
||||
|
||||
<p className="card-text">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default class HelpDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <HelpDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { escapeQuotes } from "../../services/utils.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import importService, { type UploadFilesOptions } from "../../services/import.js";
|
||||
import options from "../../services/options.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { Modal, Tooltip } from "bootstrap";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="import-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("import.importIntoNote")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("import.close")}"></button>
|
||||
</div>
|
||||
<form class="import-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="import-file-upload-input"><strong>${t("import.chooseImportFile")}</strong></label>
|
||||
|
||||
<label class="tn-file-input tn-input-field">
|
||||
<input type="file" class="import-file-upload-input form-control-file" multiple />
|
||||
</label>
|
||||
|
||||
<p>${t("import.importDescription")} <strong class="import-note-title"></strong>.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<strong>${t("import.options")}:</strong>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.safeImportTooltip"))}">
|
||||
<input class="safe-import-checkbox" value="1" type="checkbox" checked>
|
||||
<span>${t("import.safeImport")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.explodeArchivesTooltip"))}">
|
||||
<input class="explode-archives-checkbox" value="1" type="checkbox" checked>
|
||||
<span>${t("import.explodeArchives")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.shrinkImagesTooltip"))}">
|
||||
<input class="shrink-images-checkbox" value="1" type="checkbox" checked> <span>${t("import.shrinkImages")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox">
|
||||
<input class="text-imported-as-text-checkbox" value="1" type="checkbox" checked>
|
||||
${t("import.textImportedAsText")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox">
|
||||
<input class="code-imported-as-code-checkbox" value="1" type="checkbox" checked> ${t("import.codeImportedAsCode")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox">
|
||||
<input class="replace-underscores-with-spaces-checkbox" value="1" type="checkbox" checked>
|
||||
${t("import.replaceUnderscoresWithSpaces")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="import-button btn btn-primary">${t("import.import")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class ImportDialog extends BasicWidget {
|
||||
|
||||
private parentNoteId: string | null;
|
||||
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $noteTitle!: JQuery<HTMLElement>;
|
||||
private $fileUploadInput!: JQuery<HTMLInputElement>;
|
||||
private $importButton!: JQuery<HTMLElement>;
|
||||
private $safeImportCheckbox!: JQuery<HTMLElement>;
|
||||
private $shrinkImagesCheckbox!: JQuery<HTMLElement>;
|
||||
private $textImportedAsTextCheckbox!: JQuery<HTMLElement>;
|
||||
private $codeImportedAsCodeCheckbox!: JQuery<HTMLElement>;
|
||||
private $explodeArchivesCheckbox!: JQuery<HTMLElement>;
|
||||
private $replaceUnderscoresWithSpacesCheckbox!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.parentNoteId = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$form = this.$widget.find(".import-form");
|
||||
this.$noteTitle = this.$widget.find(".import-note-title");
|
||||
this.$fileUploadInput = this.$widget.find(".import-file-upload-input");
|
||||
this.$importButton = this.$widget.find(".import-button");
|
||||
this.$safeImportCheckbox = this.$widget.find(".safe-import-checkbox");
|
||||
this.$shrinkImagesCheckbox = this.$widget.find(".shrink-images-checkbox");
|
||||
this.$textImportedAsTextCheckbox = this.$widget.find(".text-imported-as-text-checkbox");
|
||||
this.$codeImportedAsCodeCheckbox = this.$widget.find(".code-imported-as-code-checkbox");
|
||||
this.$explodeArchivesCheckbox = this.$widget.find(".explode-archives-checkbox");
|
||||
this.$replaceUnderscoresWithSpacesCheckbox = this.$widget.find(".replace-underscores-with-spaces-checkbox");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
// disabling so that import is not triggered again.
|
||||
this.$importButton.attr("disabled", "disabled");
|
||||
|
||||
if (this.parentNoteId) {
|
||||
this.importIntoNote(this.parentNoteId);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$fileUploadInput.on("change", () => {
|
||||
if (this.$fileUploadInput.val()) {
|
||||
this.$importButton.removeAttr("disabled");
|
||||
} else {
|
||||
this.$importButton.attr("disabled", "disabled");
|
||||
}
|
||||
});
|
||||
|
||||
let _ = [...this.$widget.find('[data-bs-toggle="tooltip"]')].forEach((element) => {
|
||||
Tooltip.getOrCreateInstance(element, {
|
||||
html: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async showImportDialogEvent({ noteId }: EventData<"showImportDialog">) {
|
||||
this.parentNoteId = noteId;
|
||||
|
||||
this.$fileUploadInput.val("").trigger("change"); // to trigger Import button disabling listener below
|
||||
|
||||
this.$safeImportCheckbox.prop("checked", true);
|
||||
this.$shrinkImagesCheckbox.prop("checked", options.is("compressImages"));
|
||||
this.$textImportedAsTextCheckbox.prop("checked", true);
|
||||
this.$codeImportedAsCodeCheckbox.prop("checked", true);
|
||||
this.$explodeArchivesCheckbox.prop("checked", true);
|
||||
this.$replaceUnderscoresWithSpacesCheckbox.prop("checked", true);
|
||||
|
||||
this.$noteTitle.text(await treeService.getNoteTitle(this.parentNoteId));
|
||||
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
|
||||
async importIntoNote(parentNoteId: string) {
|
||||
const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below
|
||||
|
||||
const boolToString = ($el: JQuery<HTMLElement>) => ($el.is(":checked") ? "true" : "false");
|
||||
|
||||
const options: UploadFilesOptions = {
|
||||
safeImport: boolToString(this.$safeImportCheckbox),
|
||||
shrinkImages: boolToString(this.$shrinkImagesCheckbox),
|
||||
textImportedAsText: boolToString(this.$textImportedAsTextCheckbox),
|
||||
codeImportedAsCode: boolToString(this.$codeImportedAsCodeCheckbox),
|
||||
explodeArchives: boolToString(this.$explodeArchivesCheckbox),
|
||||
replaceUnderscoresWithSpaces: boolToString(this.$replaceUnderscoresWithSpacesCheckbox)
|
||||
};
|
||||
|
||||
this.$widget.modal("hide");
|
||||
|
||||
await importService.uploadFiles("notes", parentNoteId, files, options);
|
||||
}
|
||||
}
|
||||
102
apps/client/src/widgets/dialogs/import.tsx
Normal file
102
apps/client/src/widgets/dialogs/import.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
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 Modal from "../react/Modal";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import importService, { UploadFilesOptions } from "../../services/import";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function ImportDialogComponent() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ noteTitle, setNoteTitle ] = useState<string>();
|
||||
const [ files, setFiles ] = useState<FileList | null>(null);
|
||||
const [ safeImport, setSafeImport ] = useState(true);
|
||||
const [ explodeArchives, setExplodeArchives ] = useState(true);
|
||||
const [ shrinkImages, setShrinkImages ] = useState(true);
|
||||
const [ textImportedAsText, setTextImportedAsText ] = useState(true);
|
||||
const [ codeImportedAsCode, setCodeImportedAsCode ] = useState(true);
|
||||
const [ replaceUnderscoresWithSpaces, setReplaceUnderscoresWithSpaces ] = useState(true);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showImportDialog", ({ noteId }) => {
|
||||
setParentNoteId(noteId);
|
||||
tree.getNoteTitle(noteId).then(setNoteTitle);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="import-dialog"
|
||||
size="lg"
|
||||
title={t("import.importIntoNote")}
|
||||
onSubmit={async () => {
|
||||
if (!files || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options: UploadFilesOptions = {
|
||||
safeImport: boolToString(safeImport),
|
||||
shrinkImages: boolToString(shrinkImages),
|
||||
textImportedAsText: boolToString(textImportedAsText),
|
||||
codeImportedAsCode: boolToString(codeImportedAsCode),
|
||||
explodeArchives: boolToString(explodeArchives),
|
||||
replaceUnderscoresWithSpaces: boolToString(replaceUnderscoresWithSpaces)
|
||||
};
|
||||
|
||||
setShown(false);
|
||||
await importService.uploadFiles("notes", parentNoteId, Array.from(files), options);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
|
||||
<FormFileUpload multiple onChange={setFiles} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("import.options")}>
|
||||
<FormCheckbox
|
||||
name="safe-import" hint={t("import.safeImportTooltip")} label={t("import.safeImport")}
|
||||
currentValue={safeImport} onChange={setSafeImport}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="explode-archives" hint={t("import.explodeArchivesTooltip")} label={<RawHtml html={t("import.explodeArchives")} />}
|
||||
currentValue={explodeArchives} onChange={setExplodeArchives}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="shrink-images" hint={t("import.shrinkImagesTooltip")} label={t("import.shrinkImages")}
|
||||
currentValue={shrinkImages} onChange={setShrinkImages}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="text-imported-as-text" label={t("import.textImportedAsText")}
|
||||
currentValue={textImportedAsText} onChange={setTextImportedAsText}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="code-imported-as-code" label={<RawHtml html={t("import.codeImportedAsCode")} />}
|
||||
currentValue={codeImportedAsCode} onChange={setCodeImportedAsCode}
|
||||
/>
|
||||
<FormCheckbox
|
||||
name="replace-underscores-with-spaces" label={t("import.replaceUnderscoresWithSpaces")}
|
||||
currentValue={replaceUnderscoresWithSpaces} onChange={setReplaceUnderscoresWithSpaces}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class ImportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ImportDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function boolToString(value: boolean) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type EditableTextTypeWidget from "../type_widgets/editable_text.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="include-note-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("include_note.dialog_title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("include_note.close")}"></button>
|
||||
</div>
|
||||
<form class="include-note-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="include-note-autocomplete">${t("include_note.label_note")}</label>
|
||||
<div class="input-group">
|
||||
<input class="include-note-autocomplete form-control" placeholder="${t("include_note.placeholder_search")}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${t("include_note.box_size_prompt")}
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="include-note-box-size" value="small">
|
||||
${t("include_note.box_size_small")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="include-note-box-size" value="medium" checked>
|
||||
${t("include_note.box_size_medium")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label class="form-check-label tn-radio">
|
||||
<input class="form-check-input" type="radio" name="include-note-box-size" value="full">
|
||||
${t("include_note.box_size_full")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">${t("include_note.button_include")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class IncludeNoteDialog extends BasicWidget {
|
||||
|
||||
private modal!: bootstrap.Modal;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private textTypeWidget?: EditableTextTypeWidget;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$form = this.$widget.find(".include-note-form");
|
||||
this.$autoComplete = this.$widget.find(".include-note-autocomplete");
|
||||
this.$form.on("submit", () => {
|
||||
const notePath = this.$autoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
this.modal.hide();
|
||||
this.includeNote(notePath);
|
||||
} else {
|
||||
logError("No noteId to include.");
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async showIncludeNoteDialogEvent({ textTypeWidget }: EventData<"showIncludeDialog">) {
|
||||
this.textTypeWidget = textTypeWidget;
|
||||
await this.refresh();
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.$autoComplete.val("");
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowCreatingNotes: true
|
||||
});
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
}
|
||||
|
||||
async includeNote(notePath: string) {
|
||||
const noteId = treeService.getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
return;
|
||||
}
|
||||
const note = await froca.getNote(noteId);
|
||||
const boxSize = $("input[name='include-note-box-size']:checked").val() as string;
|
||||
|
||||
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
|
||||
// there's no benefit to use insert note functionlity for images,
|
||||
// so we'll just add an IMG tag
|
||||
this.textTypeWidget?.addImage(noteId);
|
||||
} else {
|
||||
this.textTypeWidget?.addIncludeNote(noteId, boxSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
apps/client/src/widgets/dialogs/include_note.tsx
Normal file
95
apps/client/src/widgets/dialogs/include_note.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import froca from "../../services/froca";
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function IncludeNoteDialogComponent() {
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
||||
const [boxSize, setBoxSize] = useState("medium");
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
const autoCompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="include-note-dialog"
|
||||
title={t("include_note.dialog_title")}
|
||||
size="lg"
|
||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||
onHidden={() => setShown(false)}
|
||||
onSubmit={() => {
|
||||
if (!suggestion?.notePath || !textTypeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShown(false);
|
||||
includeNote(suggestion.notePath, textTypeWidget);
|
||||
}}
|
||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("include_note.label_note")}>
|
||||
<NoteAutocomplete
|
||||
placeholder={t("include_note.placeholder_search")}
|
||||
onChange={setSuggestion}
|
||||
inputRef={autoCompleteRef}
|
||||
opts={{
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowCreatingNotes: true
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup 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" },
|
||||
{ label: t("include_note.box_size_medium"), value: "medium" },
|
||||
{ label: t("include_note.box_size_full"), value: "full" },
|
||||
]}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class IncludeNoteDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <IncludeNoteDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
const noteId = tree.getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
return;
|
||||
}
|
||||
const note = await froca.getNote(noteId);
|
||||
const boxSize = $("input[name='include-note-box-size']:checked").val() as string;
|
||||
|
||||
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
|
||||
// there's no benefit to use insert note functionlity for images,
|
||||
// so we'll just add an IMG tag
|
||||
textTypeWidget.addImage(noteId);
|
||||
} else {
|
||||
textTypeWidget.addIncludeNote(noteId, boxSize);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import utils from "../../services/utils.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="cpu-arch-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("cpu_arch_warning.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${utils.isMac() ? t("cpu_arch_warning.message_macos") : t("cpu_arch_warning.message_windows")}</p>
|
||||
|
||||
<p>${t("cpu_arch_warning.recommendation")}</p>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button class="download-correct-version-button btn btn-primary btn-lg me-2">
|
||||
<span class="bx bx-download"></span>
|
||||
${t("cpu_arch_warning.download_link")}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">${t("cpu_arch_warning.continue_anyway")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class IncorrectCpuArchDialog extends BasicWidget {
|
||||
private modal!: Modal;
|
||||
private $downloadButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$downloadButton = this.$widget.find(".download-correct-version-button");
|
||||
|
||||
this.$downloadButton.on("click", () => {
|
||||
// Open the releases page where users can download the correct version
|
||||
if (utils.isElectron()) {
|
||||
const { shell } = utils.dynamicRequire("electron");
|
||||
shell.openExternal("https://github.com/TriliumNext/Trilium/releases/latest");
|
||||
} else {
|
||||
window.open("https://github.com/TriliumNext/Trilium/releases/latest", "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-focus the download button when shown
|
||||
this.$widget.on("shown.bs.modal", () => {
|
||||
this.$downloadButton.trigger("focus");
|
||||
});
|
||||
}
|
||||
|
||||
showCpuArchWarningEvent() {
|
||||
this.modal.show();
|
||||
}
|
||||
}
|
||||
54
apps/client/src/widgets/dialogs/incorrect_cpu_arch.tsx
Normal file
54
apps/client/src/widgets/dialogs/incorrect_cpu_arch.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useRef } from "preact/hooks";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import Button from "../react/Button.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
|
||||
function IncorrectCpuArchDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const downloadButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useTriliumEvent("showCpuArchWarning", () => setShown(true));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="cpu-arch-dialog"
|
||||
size="lg"
|
||||
title={t("cpu_arch_warning.title")}
|
||||
onShown={() => downloadButtonRef.current?.focus()}
|
||||
footerAlignment="between"
|
||||
footer={<>
|
||||
<Button
|
||||
buttonRef={downloadButtonRef}
|
||||
text={t("cpu_arch_warning.download_link")}
|
||||
icon="bx bx-download"
|
||||
onClick={() => {
|
||||
// Open the releases page where users can download the correct version
|
||||
if (utils.isElectron()) {
|
||||
const { shell } = utils.dynamicRequire("electron");
|
||||
shell.openExternal("https://github.com/TriliumNext/Trilium/releases/latest");
|
||||
} else {
|
||||
window.open("https://github.com/TriliumNext/Trilium/releases/latest", "_blank");
|
||||
}
|
||||
}}/>
|
||||
<Button text={t("cpu_arch_warning.continue_anyway")}
|
||||
onClick={() => setShown(false)} />
|
||||
</>}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<p>{utils.isMac() ? t("cpu_arch_warning.message_macos") : t("cpu_arch_warning.message_windows")}</p>
|
||||
<p>{t("cpu_arch_warning.recommendation")}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class IncorrectCpuArchDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <IncorrectCpuArchDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import type { ConfirmDialogCallback } from "./confirm.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="info-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("info.modalTitle")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("info.closeButton")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="info-dialog-content"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="info-dialog-ok-button btn btn-primary btn-sm">${t("info.okButton")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class InfoDialog extends BasicWidget {
|
||||
|
||||
private resolve: ConfirmDialogCallback | null;
|
||||
private modal!: bootstrap.Modal;
|
||||
private $originallyFocused!: JQuery<HTMLElement> | null;
|
||||
private $infoContent!: JQuery<HTMLElement>;
|
||||
private $okButton!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.resolve = null;
|
||||
this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$infoContent = this.$widget.find(".info-dialog-content");
|
||||
this.$okButton = this.$widget.find(".info-dialog-ok-button");
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
|
||||
|
||||
this.$widget.on("hidden.bs.modal", () => {
|
||||
if (this.resolve) {
|
||||
this.resolve();
|
||||
}
|
||||
|
||||
if (this.$originallyFocused) {
|
||||
this.$originallyFocused.trigger("focus");
|
||||
this.$originallyFocused = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.$okButton.on("click", () => this.modal.hide());
|
||||
}
|
||||
|
||||
showInfoDialogEvent({ message, callback }: EventData<"showInfoDialog">) {
|
||||
this.$originallyFocused = $(":focus");
|
||||
|
||||
if (typeof message === "string") {
|
||||
this.$infoContent.text(message);
|
||||
} else if (Array.isArray(message)) {
|
||||
this.$infoContent.html(message[0]);
|
||||
} else {
|
||||
this.$infoContent.html(message as HTMLElement);
|
||||
}
|
||||
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.resolve = callback;
|
||||
}
|
||||
}
|
||||
47
apps/client/src/widgets/dialogs/info.tsx
Normal file
47
apps/client/src/widgets/dialogs/info.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { EventData } from "../../components/app_context";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function ShowInfoDialogComponent() {
|
||||
const [ opts, setOpts ] = useState<EventData<"showInfoDialog">>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const okButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useTriliumEvent("showInfoDialog", (opts) => {
|
||||
setOpts(opts);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
return (<Modal
|
||||
className="info-dialog"
|
||||
size="sm"
|
||||
title={t("info.modalTitle")}
|
||||
onHidden={() => {
|
||||
opts?.callback?.();
|
||||
setShown(false);
|
||||
}}
|
||||
onShown={() => okButtonRef.current?.focus?.()}
|
||||
footer={<Button
|
||||
buttonRef={okButtonRef}
|
||||
text={t("info.okButton")}
|
||||
onClick={() => setShown(false)}
|
||||
/>}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
<RawHtmlBlock className="info-dialog-content" html={opts?.message ?? ""} />
|
||||
</Modal>);
|
||||
}
|
||||
|
||||
export default class InfoDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ShowInfoDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import commandRegistry from "../../services/command_registry.js";
|
||||
|
||||
const TPL = /*html*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="input-group">
|
||||
<input class="jump-to-note-autocomplete form-control" placeholder="${t("jump_to_note.search_placeholder")}">
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("jump_to_note.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="algolia-autocomplete-container jump-to-note-results"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="show-in-full-text-button btn btn-sm">${t("jump_to_note.search_button")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
export default class JumpToNoteDialog extends BasicWidget {
|
||||
|
||||
private lastOpenedTs: number;
|
||||
private modal!: bootstrap.Modal;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $results!: JQuery<HTMLElement>;
|
||||
private $modalFooter!: JQuery<HTMLElement>;
|
||||
private isCommandMode: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.lastOpenedTs = 0;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete");
|
||||
this.$results = this.$widget.find(".jump-to-note-results");
|
||||
this.$modalFooter = this.$widget.find(".modal-footer");
|
||||
this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e));
|
||||
|
||||
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
|
||||
|
||||
// Monitor input changes to detect command mode switches
|
||||
this.$autoComplete.on("input", () => {
|
||||
this.updateCommandModeState();
|
||||
});
|
||||
}
|
||||
|
||||
private updateCommandModeState() {
|
||||
const currentValue = String(this.$autoComplete.val() || "");
|
||||
const newCommandMode = currentValue.startsWith(">");
|
||||
|
||||
if (newCommandMode !== this.isCommandMode) {
|
||||
this.isCommandMode = newCommandMode;
|
||||
this.updateButtonVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private updateButtonVisibility() {
|
||||
if (this.isCommandMode) {
|
||||
this.$modalFooter.hide();
|
||||
} else {
|
||||
this.$modalFooter.show();
|
||||
}
|
||||
}
|
||||
|
||||
async jumpToNoteEvent() {
|
||||
await this.openDialog();
|
||||
}
|
||||
|
||||
async commandPaletteEvent() {
|
||||
await this.openDialog(true);
|
||||
}
|
||||
|
||||
private async openDialog(commandMode = false) {
|
||||
const dialogPromise = openDialog(this.$widget);
|
||||
if (utils.isMobile()) {
|
||||
dialogPromise.then(($dialog) => {
|
||||
const el = $dialog.find(">.modal-dialog")[0];
|
||||
|
||||
function reposition() {
|
||||
const offset = 100;
|
||||
const modalHeight = (window.visualViewport?.height ?? 0) - offset;
|
||||
const safeAreaInsetBottom = (window.visualViewport?.height ?? 0) - window.innerHeight;
|
||||
el.style.height = `${modalHeight}px`;
|
||||
el.style.bottom = `${(window.visualViewport?.height ?? 0) - modalHeight - safeAreaInsetBottom - offset}px`;
|
||||
}
|
||||
|
||||
this.$autoComplete.on("focus", () => {
|
||||
reposition();
|
||||
});
|
||||
|
||||
window.visualViewport?.addEventListener("resize", () => {
|
||||
reposition();
|
||||
});
|
||||
|
||||
reposition();
|
||||
});
|
||||
}
|
||||
|
||||
// first open dialog, then refresh since refresh is doing focus which should be visible
|
||||
this.refresh(commandMode);
|
||||
|
||||
this.lastOpenedTs = Date.now();
|
||||
}
|
||||
|
||||
async refresh(commandMode = false) {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
container: this.$results[0],
|
||||
isCommandPalette: true
|
||||
})
|
||||
// clear any event listener added in previous invocation of this function
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
|
||||
if (!suggestion.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
})
|
||||
.on("autocomplete:commandselected", async (event, suggestion, dataset) => {
|
||||
if (!suggestion.commandId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.modal.hide();
|
||||
await commandRegistry.executeCommand(suggestion.commandId);
|
||||
});
|
||||
|
||||
if (commandMode) {
|
||||
// Start in command mode - manually trigger command search
|
||||
this.$autoComplete.autocomplete("val", ">");
|
||||
this.isCommandMode = true;
|
||||
this.updateButtonVisibility();
|
||||
|
||||
// Manually populate with all commands immediately
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
|
||||
this.$autoComplete.trigger("focus");
|
||||
} else {
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
|
||||
this.isCommandMode = false;
|
||||
this.updateButtonVisibility();
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
} else {
|
||||
this.$autoComplete
|
||||
// hack, the actual search value is stored in <pre> element next to the search input
|
||||
// this is important because the search input value is replaced with the suggestion note's title
|
||||
.autocomplete("val", this.$autoComplete.next().text())
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
|
||||
// Update command mode state based on the restored value
|
||||
this.updateCommandModeState();
|
||||
|
||||
// If we restored a command mode value, manually trigger command display
|
||||
if (this.isCommandMode) {
|
||||
// Clear the value first, then set it to ">" to trigger a proper change
|
||||
this.$autoComplete.autocomplete("val", "");
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
|
||||
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't perform full text search in command mode
|
||||
if (this.isCommandMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchString = String(this.$autoComplete.val());
|
||||
|
||||
this.triggerCommand("searchNotes", { searchString });
|
||||
this.modal.hide();
|
||||
}
|
||||
}
|
||||
125
apps/client/src/widgets/dialogs/jump_to_note.tsx
Normal file
125
apps/client/src/widgets/dialogs/jump_to_note.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import appContext from "../../components/app_context";
|
||||
import commandRegistry from "../../services/command_registry";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
type Mode = "last-search" | "recent-notes" | "commands";
|
||||
|
||||
function JumpToNoteDialogComponent() {
|
||||
const [ mode, setMode ] = useState<Mode>();
|
||||
const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands");
|
||||
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
|
||||
const actualText = useRef<string>(initialText);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
let newMode: Mode;
|
||||
let initialText: string = "";
|
||||
|
||||
if (commandMode) {
|
||||
newMode = "commands";
|
||||
initialText = ">";
|
||||
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText) {
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
newMode = "last-search";
|
||||
initialText = actualText.current;
|
||||
} else {
|
||||
newMode = "recent-notes";
|
||||
}
|
||||
|
||||
if (mode !== newMode) {
|
||||
setMode(newMode);
|
||||
}
|
||||
|
||||
setInitialText(initialText);
|
||||
setShown(true);
|
||||
setLastOpenedTs(Date.now());
|
||||
}
|
||||
|
||||
useTriliumEvent("jumpToNote", () => openDialog(false));
|
||||
useTriliumEvent("commandPalette", () => openDialog(true));
|
||||
|
||||
async function onItemSelected(suggestion?: Suggestion | null) {
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShown(false);
|
||||
if (suggestion.notePath) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
} else if (suggestion.commandId) {
|
||||
await commandRegistry.executeCommand(suggestion.commandId);
|
||||
}
|
||||
}
|
||||
|
||||
function onShown() {
|
||||
const $autoComplete = refToJQuerySelector(autocompleteRef);
|
||||
switch (mode) {
|
||||
case "last-search":
|
||||
break;
|
||||
case "recent-notes":
|
||||
note_autocomplete.showRecentNotes($autoComplete);
|
||||
break;
|
||||
case "commands":
|
||||
note_autocomplete.showAllCommands($autoComplete);
|
||||
break;
|
||||
}
|
||||
|
||||
$autoComplete
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="jump-to-note-dialog"
|
||||
size="lg"
|
||||
title={<NoteAutocomplete
|
||||
placeholder={t("jump_to_note.search_placeholder")}
|
||||
inputRef={autocompleteRef}
|
||||
container={containerRef}
|
||||
text={initialText}
|
||||
opts={{
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
isCommandPalette: true
|
||||
}}
|
||||
onTextChange={(text) => {
|
||||
actualText.current = text;
|
||||
setIsCommandMode(text.startsWith(">"));
|
||||
}}
|
||||
onChange={onItemSelected}
|
||||
/>}
|
||||
onShown={onShown}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={!isCommandMode && <Button className="show-in-full-text-button" text={t("jump_to_note.search_button")} keyboardShortcut="Ctrl+Enter" />}
|
||||
show={shown}
|
||||
>
|
||||
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class JumpToNoteDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <JumpToNoteDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import server from "../../services/server.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="markdown-import-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("markdown_import.dialog_title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("markdown_import.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${t("markdown_import.modal_body_text")}</p>
|
||||
|
||||
<textarea class="markdown-import-textarea" style="height: 340px; width: 100%"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="markdown-import-button btn btn-primary">${t("markdown_import.import_button")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
export default class MarkdownImportDialog extends BasicWidget {
|
||||
|
||||
private lastOpenedTs: number;
|
||||
private modal!: bootstrap.Modal;
|
||||
private $importTextarea!: JQuery<HTMLElement>;
|
||||
private $importButton!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.lastOpenedTs = 0;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$importTextarea = this.$widget.find(".markdown-import-textarea");
|
||||
this.$importButton = this.$widget.find(".markdown-import-button");
|
||||
|
||||
this.$importButton.on("click", () => this.sendForm());
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => this.$importTextarea.trigger("focus"));
|
||||
|
||||
shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.sendForm());
|
||||
}
|
||||
|
||||
async convertMarkdownToHtml(markdownContent: string) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
|
||||
if (!textEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewFragment = textEditor.data.processor.toView(htmlContent);
|
||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
||||
|
||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
||||
textEditor.editing.view.focus();
|
||||
|
||||
toastService.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
|
||||
async pasteMarkdownIntoTextEvent() {
|
||||
await this.importMarkdownInlineEvent(); // BC with keyboard shortcuts command
|
||||
}
|
||||
|
||||
async importMarkdownInlineEvent() {
|
||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
this.convertMarkdownToHtml(text);
|
||||
} else {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
|
||||
async sendForm() {
|
||||
const text = String(this.$importTextarea.val());
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
await this.convertMarkdownToHtml(text);
|
||||
|
||||
this.$importTextarea.val("");
|
||||
}
|
||||
}
|
||||
90
apps/client/src/widgets/dialogs/markdown_import.tsx
Normal file
90
apps/client/src/widgets/dialogs/markdown_import.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
function MarkdownImportDialogComponent() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
let [ text, setText ] = useState("");
|
||||
let [ shown, setShown ] = useState(false);
|
||||
|
||||
const triggerImport = useCallback(() => {
|
||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
convertMarkdownToHtml(text);
|
||||
} else {
|
||||
setShown(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("importMarkdownInline", triggerImport);
|
||||
useTriliumEvent("pasteMarkdownIntoText", triggerImport);
|
||||
|
||||
async function sendForm() {
|
||||
await convertMarkdownToHtml(text);
|
||||
setText("");
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />}
|
||||
onShown={() => markdownImportTextArea.current?.focus()}
|
||||
onHidden={() => setShown(false) }
|
||||
show={shown}
|
||||
>
|
||||
<p>{t("markdown_import.modal_body_text")}</p>
|
||||
<textarea ref={markdownImportTextArea} value={text}
|
||||
onInput={(e) => setText(e.currentTarget.value)}
|
||||
style={{ height: 340, width: "100%" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
sendForm();
|
||||
}
|
||||
}}></textarea>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class MarkdownImportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <MarkdownImportDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
|
||||
if (!textEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewFragment = textEditor.data.processor.toView(htmlContent);
|
||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
||||
|
||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
||||
textEditor.editing.view.focus();
|
||||
|
||||
toast.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="move-to-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title me-auto">${t("move_to.dialog_title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("move_to.close")}"></button>
|
||||
</div>
|
||||
<form class="move-to-form">
|
||||
<div class="modal-body">
|
||||
<h5>${t("move_to.notes_to_move")}</h5>
|
||||
|
||||
<ul class="move-to-note-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="width: 100%">
|
||||
${t("move_to.target_parent_note")}
|
||||
<div class="input-group">
|
||||
<input class="move-to-note-autocomplete form-control" placeholder="${t("move_to.search_placeholder")}">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">${t("move_to.move_button")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class MoveToDialog extends BasicWidget {
|
||||
|
||||
private movedBranchIds: string[] | null;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $noteAutoComplete!: JQuery<HTMLElement>;
|
||||
private $noteList!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.movedBranchIds = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".move-to-form");
|
||||
this.$noteAutoComplete = this.$widget.find(".move-to-note-autocomplete");
|
||||
this.$noteList = this.$widget.find(".move-to-note-list");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
const notePath = this.$noteAutoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
this.$widget.modal("hide");
|
||||
|
||||
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (parentNoteId) {
|
||||
froca.getBranchId(parentNoteId, noteId).then((branchId) => {
|
||||
if (branchId) {
|
||||
this.moveNotesTo(branchId);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logError(t("move_to.error_no_path"));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async moveBranchIdsToEvent({ branchIds }: EventData<"moveBranchIdsTo">) {
|
||||
this.movedBranchIds = branchIds;
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.$noteAutoComplete.val("").trigger("focus");
|
||||
|
||||
this.$noteList.empty();
|
||||
|
||||
for (const branchId of this.movedBranchIds) {
|
||||
const branch = froca.getBranch(branchId);
|
||||
if (!branch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const note = await froca.getNote(branch.noteId);
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.$noteList.append($("<li>").text(note.title));
|
||||
}
|
||||
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete);
|
||||
noteAutocompleteService.showRecentNotes(this.$noteAutoComplete);
|
||||
}
|
||||
|
||||
async moveNotesTo(parentBranchId: string) {
|
||||
if (this.movedBranchIds) {
|
||||
await branchService.moveToParentNote(this.movedBranchIds, parentBranchId);
|
||||
}
|
||||
|
||||
const parentBranch = froca.getBranch(parentBranchId);
|
||||
const parentNote = await parentBranch?.getNote();
|
||||
|
||||
toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`);
|
||||
}
|
||||
}
|
||||
87
apps/client/src/widgets/dialogs/move_to.tsx
Normal file
87
apps/client/src/widgets/dialogs/move_to.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import NoteList from "../react/NoteList";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import Button from "../react/Button";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import froca from "../../services/froca";
|
||||
import branches from "../../services/branches";
|
||||
import toast from "../../services/toast";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function MoveToDialogComponent() {
|
||||
const [ movedBranchIds, setMovedBranchIds ] = useState<string[]>();
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const autoCompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useTriliumEvent("moveBranchIdsTo", ({ branchIds }) => {
|
||||
setMovedBranchIds(branchIds);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const notePath = suggestion?.notePath;
|
||||
if (!notePath) {
|
||||
logError(t("move_to.error_no_path"));
|
||||
return;
|
||||
}
|
||||
|
||||
setShown(false);
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (!parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (branchId) {
|
||||
moveNotesTo(movedBranchIds, branchId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="move-to-dialog"
|
||||
size="lg" maxWidth={1000}
|
||||
title={t("move_to.dialog_title")}
|
||||
footer={<Button text={t("move_to.move_button")} keyboardShortcut="Enter" />}
|
||||
onSubmit={onSubmit}
|
||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<h5>{t("move_to.notes_to_move")}</h5>
|
||||
<NoteList branchIds={movedBranchIds} />
|
||||
|
||||
<FormGroup label={t("move_to.target_parent_note")}>
|
||||
<NoteAutocomplete
|
||||
onChange={setSuggestion}
|
||||
inputRef={autoCompleteRef}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class MoveToDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <MoveToDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function moveNotesTo(movedBranchIds: string[] | undefined, parentBranchId: string) {
|
||||
if (movedBranchIds) {
|
||||
await branches.moveToParentNote(movedBranchIds, parentBranchId);
|
||||
}
|
||||
|
||||
const parentBranch = froca.getBranch(parentBranchId);
|
||||
const parentNote = await parentBranch?.getNote();
|
||||
|
||||
toast.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import type { MenuCommandItem } from "../../menus/context_menu.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import noteTypesService from "../../services/note_types.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Dropdown, Modal } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-type-chooser-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
.note-type-chooser-dialog {
|
||||
/* note type chooser needs to be higher than other dialogs from which it is triggered, e.g. "add link"*/
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog .input-group {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog .note-type-dropdown {
|
||||
position: relative;
|
||||
font-size: large;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
<div class="modal-dialog" style="max-width: 500px;" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("note_type_chooser.modal_title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("note_type_chooser.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${t("note_type_chooser.change_path_prompt")}
|
||||
|
||||
<div class="input-group">
|
||||
<input class="choose-note-path form-control" placeholder="${t("note_type_chooser.search_placeholder")}">
|
||||
</div>
|
||||
|
||||
${t("note_type_chooser.modal_body")}
|
||||
|
||||
<div class="dropdown" style="display: flex;">
|
||||
<button class="note-type-dropdown-trigger" type="button" style="display: none;"
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
</button>
|
||||
|
||||
<div class="note-type-dropdown dropdown-menu static"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export interface ChooseNoteTypeResponse {
|
||||
success: boolean;
|
||||
noteType?: string;
|
||||
templateNoteId?: string;
|
||||
notePath?: string;
|
||||
}
|
||||
|
||||
type Callback = (data: ChooseNoteTypeResponse) => void;
|
||||
|
||||
export default class NoteTypeChooserDialog extends BasicWidget {
|
||||
private resolve: Callback | null;
|
||||
private dropdown!: Dropdown;
|
||||
private modal!: Modal;
|
||||
private $noteTypeDropdown!: JQuery<HTMLElement>;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $originalFocused: JQuery<HTMLElement> | null;
|
||||
private $originalDialog: JQuery<HTMLElement> | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.resolve = null;
|
||||
this.$originalFocused = null; // element focused before the dialog was opened, so we can return to it afterward
|
||||
this.$originalDialog = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$autoComplete = this.$widget.find(".choose-note-path");
|
||||
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")[0]);
|
||||
|
||||
this.$widget.on("hidden.bs.modal", () => {
|
||||
if (this.resolve) {
|
||||
this.resolve({ success: false });
|
||||
}
|
||||
|
||||
if (this.$originalFocused) {
|
||||
this.$originalFocused.trigger("focus");
|
||||
this.$originalFocused = null;
|
||||
}
|
||||
|
||||
glob.activeDialog = this.$originalDialog;
|
||||
});
|
||||
|
||||
this.$noteTypeDropdown.on("click", ".dropdown-item", (e) => this.doResolve(e));
|
||||
|
||||
this.$noteTypeDropdown.on("focus", ".dropdown-item", (e) => {
|
||||
this.$noteTypeDropdown.find(".dropdown-item").each((i, el) => {
|
||||
$(el).toggleClass("active", el === e.target);
|
||||
});
|
||||
});
|
||||
|
||||
this.$noteTypeDropdown.on("keydown", ".dropdown-item", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.doResolve(e);
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.$noteTypeDropdown.parent().on("hide.bs.dropdown", (e) => {
|
||||
// prevent closing dropdown by clicking outside
|
||||
// TODO: Check if this actually works.
|
||||
//@ts-ignore
|
||||
if (e.clickEvent) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
this.modal.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: false,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: false,
|
||||
})
|
||||
}
|
||||
|
||||
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
|
||||
this.$originalFocused = $(":focus");
|
||||
|
||||
await this.refresh();
|
||||
|
||||
const noteTypes = await noteTypesService.getNoteTypeItems();
|
||||
|
||||
this.$noteTypeDropdown.empty();
|
||||
|
||||
for (const noteType of noteTypes) {
|
||||
if (noteType.title === "----") {
|
||||
this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
|
||||
} else {
|
||||
const commandItem = noteType as MenuCommandItem<CommandNames>;
|
||||
const listItem = $('<a class="dropdown-item" tabindex="0">')
|
||||
.attr("data-note-type", commandItem.type || "")
|
||||
.attr("data-template-note-id", commandItem.templateNoteId || "")
|
||||
.append($("<span>").addClass(commandItem.uiIcon || ""))
|
||||
.append(` ${noteType.title}`);
|
||||
|
||||
if (commandItem.badges) {
|
||||
for (let badge of commandItem.badges) {
|
||||
listItem.append($(`<span class="badge">`)
|
||||
.addClass(badge.className || "")
|
||||
.text(badge.title));
|
||||
}
|
||||
}
|
||||
|
||||
this.$noteTypeDropdown.append(listItem);
|
||||
}
|
||||
}
|
||||
|
||||
this.dropdown.show();
|
||||
|
||||
this.$originalDialog = glob.activeDialog;
|
||||
glob.activeDialog = this.$widget;
|
||||
this.modal.show();
|
||||
|
||||
this.$noteTypeDropdown.find(".dropdown-item:first").focus();
|
||||
|
||||
this.resolve = callback;
|
||||
}
|
||||
|
||||
doResolve(e: JQuery.KeyDownEvent | JQuery.ClickEvent) {
|
||||
const $item = $(e.target).closest(".dropdown-item");
|
||||
const noteType = $item.attr("data-note-type");
|
||||
const templateNoteId = $item.attr("data-template-note-id");
|
||||
const notePath = this.$autoComplete.getSelectedNotePath() || undefined;
|
||||
|
||||
if (this.resolve) {
|
||||
this.resolve({
|
||||
success: true,
|
||||
noteType,
|
||||
templateNoteId,
|
||||
notePath
|
||||
});
|
||||
}
|
||||
this.resolve = null;
|
||||
|
||||
this.modal.hide();
|
||||
}
|
||||
}
|
||||
130
apps/client/src/widgets/dialogs/note_type_chooser.tsx
Normal file
130
apps/client/src/widgets/dialogs/note_type_chooser.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import FormList, { FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import note_types from "../../services/note_types";
|
||||
import { MenuCommandItem, MenuItem } from "../../menus/context_menu";
|
||||
import { TreeCommandNames } from "../../menus/tree_context_menu";
|
||||
import { Suggestion } from "../../services/note_autocomplete";
|
||||
import Badge from "../react/Badge";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
export interface ChooseNoteTypeResponse {
|
||||
success: boolean;
|
||||
noteType?: string;
|
||||
templateNoteId?: string;
|
||||
notePath?: string;
|
||||
}
|
||||
|
||||
export type ChooseNoteTypeCallback = (data: ChooseNoteTypeResponse) => void;
|
||||
|
||||
const SEPARATOR_TITLE_REPLACEMENTS = [
|
||||
t("note_type_chooser.builtin_templates"),
|
||||
t("note_type_chooser.templates")
|
||||
];
|
||||
|
||||
function NoteTypeChooserDialogComponent() {
|
||||
const [ callback, setCallback ] = useState<ChooseNoteTypeCallback>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ parentNote, setParentNote ] = useState<Suggestion | null>();
|
||||
const [ noteTypes, setNoteTypes ] = useState<MenuItem<TreeCommandNames>[]>([]);
|
||||
|
||||
useTriliumEvent("chooseNoteType", ({ callback }) => {
|
||||
setCallback(() => callback);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (!noteTypes.length) {
|
||||
useEffect(() => {
|
||||
note_types.getNoteTypeItems().then(noteTypes => {
|
||||
let index = -1;
|
||||
|
||||
setNoteTypes((noteTypes ?? []).map((item, _index) => {
|
||||
if (item.title === "----") {
|
||||
index++;
|
||||
return {
|
||||
title: SEPARATOR_TITLE_REPLACEMENTS[index],
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onNoteTypeSelected(value: string) {
|
||||
const [ noteType, templateNoteId ] = value.split(",");
|
||||
|
||||
callback?.({
|
||||
success: true,
|
||||
noteType,
|
||||
templateNoteId,
|
||||
notePath: parentNote?.notePath
|
||||
});
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("note_type_chooser.modal_title")}
|
||||
className="note-type-chooser-dialog"
|
||||
size="md"
|
||||
zIndex={1100} // note type chooser needs to be higher than other dialogs from which it is triggered, e.g. "add link"
|
||||
scrollable
|
||||
onHidden={() => {
|
||||
callback?.({ success: false });
|
||||
setShown(false);
|
||||
}}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
<FormGroup label={t("note_type_chooser.change_path_prompt")}>
|
||||
<NoteAutocomplete
|
||||
onChange={setParentNote}
|
||||
placeholder={t("note_type_chooser.search_placeholder")}
|
||||
opts={{
|
||||
allowCreatingNotes: false,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: false,
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("note_type_chooser.modal_body")}>
|
||||
<FormList onSelect={onNoteTypeSelected}>
|
||||
{noteTypes.map((_item) => {
|
||||
if (_item.title === "----") {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = _item as MenuCommandItem<TreeCommandNames>;
|
||||
|
||||
if (item.enabled === false) {
|
||||
return <FormListHeader text={item.title} />
|
||||
} else {
|
||||
return <FormListItem
|
||||
value={[ item.type, item.templateNoteId ].join(",") }
|
||||
icon={item.uiIcon}>
|
||||
{item.title}
|
||||
{item.badges && item.badges.map((badge) => <Badge {...badge} />)}
|
||||
</FormListItem>;
|
||||
}
|
||||
})}
|
||||
</FormList>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class NoteTypeChooserDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <NoteTypeChooserDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="password-not-set-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-md" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("password_not_set.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("password_not_set.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${t("password_not_set.body1")}
|
||||
|
||||
${t("password_not_set.body2")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class PasswordNoteSetDialog extends BasicWidget {
|
||||
|
||||
private modal!: Modal;
|
||||
private $openPasswordOptionsButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$openPasswordOptionsButton = this.$widget.find(".open-password-options-button");
|
||||
this.$openPasswordOptionsButton.on("click", () => {
|
||||
this.modal.hide();
|
||||
this.triggerCommand("showOptions", { section: "_optionsPassword" });
|
||||
});
|
||||
}
|
||||
|
||||
showPasswordNotSetEvent() {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
36
apps/client/src/widgets/dialogs/password_not_set.tsx
Normal file
36
apps/client/src/widgets/dialogs/password_not_set.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import appContext from "../../components/app_context";
|
||||
import { useState } from "preact/hooks";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function PasswordNotSetDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
useTriliumEvent("showPasswordNotSet", () => setShown(true));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md" className="password-not-set-dialog"
|
||||
title={t("password_not_set.title")}
|
||||
footer={<Button icon="bx bx-lock" text={t("password_not_set.go_to_password_options")} onClick={() => {
|
||||
setShown(false);
|
||||
appContext.triggerCommand("showOptions", { section: "_optionsPassword" });
|
||||
}} />}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<p>{t("password_not_set.body1")}</p>
|
||||
<p>{t("password_not_set.body2")}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class PasswordNotSetDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <PasswordNotSetDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="prompt-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<form class="prompt-dialog-form">
|
||||
<div class="modal-header">
|
||||
<h5 class="prompt-title modal-title">${t("prompt.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("prompt.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="prompt-dialog-ok-button btn btn-primary btn-sm">${t("prompt.ok")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
interface ShownCallbackData {
|
||||
$dialog: JQuery<HTMLElement>;
|
||||
$question: JQuery<HTMLElement> | null;
|
||||
$answer: JQuery<HTMLElement> | null;
|
||||
$form: JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
export interface PromptDialogOptions {
|
||||
title?: string;
|
||||
message?: string;
|
||||
defaultValue?: string;
|
||||
shown?: PromptShownDialogCallback;
|
||||
callback?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export type PromptShownDialogCallback = ((callback: ShownCallbackData) => void) | null;
|
||||
|
||||
export default class PromptDialog extends BasicWidget {
|
||||
private resolve?: ((value: string | null) => void) | undefined | null;
|
||||
private shownCb?: PromptShownDialogCallback | null;
|
||||
|
||||
private modal!: Modal;
|
||||
private $dialogBody!: JQuery<HTMLElement>;
|
||||
private $question!: JQuery<HTMLElement> | null;
|
||||
private $answer!: JQuery<HTMLElement> | null;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.resolve = null;
|
||||
this.shownCb = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$dialogBody = this.$widget.find(".modal-body");
|
||||
this.$form = this.$widget.find(".prompt-dialog-form");
|
||||
this.$question = null;
|
||||
this.$answer = null;
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => {
|
||||
if (this.shownCb) {
|
||||
this.shownCb({
|
||||
$dialog: this.$widget,
|
||||
$question: this.$question,
|
||||
$answer: this.$answer,
|
||||
$form: this.$form
|
||||
});
|
||||
}
|
||||
|
||||
this.$answer?.trigger("focus").select();
|
||||
});
|
||||
|
||||
this.$widget.on("hidden.bs.modal", () => {
|
||||
if (this.resolve) {
|
||||
this.resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
this.$form.on("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (this.resolve) {
|
||||
this.resolve(this.$answer?.val() as string);
|
||||
}
|
||||
|
||||
this.modal.hide();
|
||||
});
|
||||
}
|
||||
|
||||
showPromptDialogEvent({ title, message, defaultValue, shown, callback }: PromptDialogOptions) {
|
||||
this.shownCb = shown;
|
||||
this.resolve = callback;
|
||||
|
||||
this.$widget.find(".prompt-title").text(title || t("prompt.defaultTitle"));
|
||||
|
||||
this.$question = $("<label>")
|
||||
.prop("for", "prompt-dialog-answer")
|
||||
.text(message || "");
|
||||
|
||||
this.$answer = $("<input>")
|
||||
.prop("type", "text")
|
||||
.prop("id", "prompt-dialog-answer")
|
||||
.addClass("form-control")
|
||||
.val(defaultValue || "");
|
||||
|
||||
this.$dialogBody.empty().append($("<div>").addClass("form-group").append(this.$question).append(this.$answer));
|
||||
|
||||
openDialog(this.$widget, false);
|
||||
}
|
||||
}
|
||||
90
apps/client/src/widgets/dialogs/prompt.tsx
Normal file
90
apps/client/src/widgets/dialogs/prompt.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import { Modal as BootstrapModal } from "bootstrap";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
// JQuery here is maintained for compatibility with existing code.
|
||||
interface ShownCallbackData {
|
||||
$dialog: JQuery<HTMLDivElement>;
|
||||
$question: JQuery<HTMLLabelElement> | null;
|
||||
$answer: JQuery<HTMLElement> | null;
|
||||
$form: JQuery<HTMLFormElement>;
|
||||
}
|
||||
|
||||
export type PromptShownDialogCallback = ((callback: ShownCallbackData) => void) | null;
|
||||
|
||||
export interface PromptDialogOptions {
|
||||
title?: string;
|
||||
message?: string;
|
||||
defaultValue?: string;
|
||||
shown?: PromptShownDialogCallback;
|
||||
callback?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
function PromptDialogComponent() {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
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 [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showPromptDialog", (opts) => {
|
||||
setOpts(opts);
|
||||
setShown(true);
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="prompt-dialog"
|
||||
title={opts?.title ?? t("prompt.title")}
|
||||
size="lg"
|
||||
zIndex={2000}
|
||||
modalRef={modalRef} formRef={formRef}
|
||||
onShown={() => {
|
||||
opts?.shown?.({
|
||||
$dialog: refToJQuerySelector(modalRef),
|
||||
$question: refToJQuerySelector(labelRef),
|
||||
$answer: refToJQuerySelector(answerRef),
|
||||
$form: refToJQuerySelector(formRef)
|
||||
});
|
||||
answerRef.current?.focus();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
const modal = BootstrapModal.getOrCreateInstance(modalRef.current!);
|
||||
modal.hide();
|
||||
|
||||
opts?.callback?.(value);
|
||||
}}
|
||||
onHidden={() => {
|
||||
opts?.callback?.(null);
|
||||
setShown(false);
|
||||
}}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
<FormGroup label={opts?.message} labelRef={labelRef}>
|
||||
<FormTextBox
|
||||
name="prompt-dialog-answer"
|
||||
inputRef={answerRef}
|
||||
currentValue={value} onChange={setValue} />
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class PromptDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <PromptDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="protected-session-password-dialog modal mx-auto" data-backdrop="false" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-md" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("protected_session_password.modal_title")}</h5>
|
||||
<button class="help-button" type="button" data-help-page="protected-notes.html" title="${t("protected_session_password.help_title")}">?</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("protected_session_password.close_label")}"></button>
|
||||
</div>
|
||||
<form class="protected-session-password-form">
|
||||
<div class="modal-body">
|
||||
<label for="protected-session-password" class="col-form-label">${t("protected_session_password.form_label")}</label>
|
||||
<input id="protected-session-password" class="form-control protected-session-password" type="password" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary">${t("protected_session_password.start_button")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class ProtectedSessionPasswordDialog extends BasicWidget {
|
||||
|
||||
private modal!: bootstrap.Modal;
|
||||
private $passwordForm!: JQuery<HTMLElement>;
|
||||
private $passwordInput!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$passwordForm = this.$widget.find(".protected-session-password-form");
|
||||
this.$passwordInput = this.$widget.find(".protected-session-password");
|
||||
this.$passwordForm.on("submit", () => {
|
||||
const password = String(this.$passwordInput.val());
|
||||
this.$passwordInput.val("");
|
||||
|
||||
protectedSessionService.setupProtectedSession(password);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
showProtectedSessionPasswordDialogEvent() {
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.$passwordInput.trigger("focus");
|
||||
}
|
||||
|
||||
closeProtectedSessionPasswordDialogEvent() {
|
||||
this.modal.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import protected_session from "../../services/protected_session";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function ProtectedSessionPasswordDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ password, setPassword ] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useTriliumEvent("showProtectedSessionPasswordDialog", () => setShown(true));
|
||||
useTriliumEvent("closeProtectedSessionPasswordDialog", () => setShown(false));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="protected-session-password-dialog"
|
||||
title={t("protected_session_password.modal_title")}
|
||||
size="md"
|
||||
helpPageId="bwg0e8ewQMak"
|
||||
footer={<Button text={t("protected_session_password.start_button")} />}
|
||||
onSubmit={() => protected_session.setupProtectedSession(password)}
|
||||
onShown={() => inputRef.current?.focus()}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<label htmlFor="protected-session-password" className="col-form-label">{t("protected_session_password.form_label")}</label>
|
||||
<FormTextBox
|
||||
id="protected-session-password"
|
||||
name="protected-session-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
onChange={setPassword}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class ProtectedSessionPasswordDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ProtectedSessionPasswordDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import dialogService, { openDialog } from "../../services/dialog.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="recent-changes-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("recent_changes.title")}</h5>
|
||||
<button class="erase-deleted-notes-now-button btn btn-sm" style="padding: 0 10px">${t("recent_changes.erase_notes_button")}</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("recent_changes.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="recent-changes-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface RecentChangesRow {
|
||||
noteId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export default class RecentChangesDialog extends BasicWidget {
|
||||
|
||||
private ancestorNoteId?: string;
|
||||
|
||||
private modal!: bootstrap.Modal;
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private $eraseDeletedNotesNow!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$content = this.$widget.find(".recent-changes-content");
|
||||
this.$eraseDeletedNotesNow = this.$widget.find(".erase-deleted-notes-now-button");
|
||||
this.$eraseDeletedNotesNow.on("click", () => {
|
||||
server.post("notes/erase-deleted-notes-now").then(() => {
|
||||
this.refresh();
|
||||
|
||||
toastService.showMessage(t("recent_changes.deleted_notes_message"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async showRecentChangesEvent({ ancestorNoteId }: EventData<"showRecentChanges">) {
|
||||
this.ancestorNoteId = ancestorNoteId;
|
||||
|
||||
await this.refresh();
|
||||
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.ancestorNoteId) {
|
||||
this.ancestorNoteId = hoistedNoteService.getHoistedNoteId();
|
||||
}
|
||||
|
||||
const recentChangesRows = await server.get<RecentChangesRow[]>(`recent-changes/${this.ancestorNoteId}`);
|
||||
|
||||
// preload all notes into cache
|
||||
await froca.getNotes(
|
||||
recentChangesRows.map((r) => r.noteId),
|
||||
true
|
||||
);
|
||||
|
||||
this.$content.empty();
|
||||
|
||||
if (recentChangesRows.length === 0) {
|
||||
this.$content.append(t("recent_changes.no_changes_message"));
|
||||
}
|
||||
|
||||
const groupedByDate = this.groupByDate(recentChangesRows);
|
||||
|
||||
for (const [dateDay, dayChanges] of groupedByDate) {
|
||||
const $changesList = $("<ul>");
|
||||
|
||||
const formattedDate = formatDateTime(dateDay, "full", "none");
|
||||
const dayEl = $("<div>").append($("<b>").text(formattedDate)).append($changesList);
|
||||
|
||||
for (const change of dayChanges) {
|
||||
const formattedTime = formatDateTime(change.date, "none", "short");
|
||||
|
||||
let $noteLink;
|
||||
|
||||
if (change.current_isDeleted) {
|
||||
$noteLink = $("<span>");
|
||||
|
||||
$noteLink.append($("<span>").addClass("note-title").text(change.current_title));
|
||||
|
||||
if (change.canBeUndeleted) {
|
||||
const $undeleteLink = $(`<a href="javascript:">`)
|
||||
.text(t("recent_changes.undelete_link"))
|
||||
.on("click", async () => {
|
||||
const text = t("recent_changes.confirm_undelete");
|
||||
|
||||
if (await dialogService.confirm(text)) {
|
||||
await server.put(`notes/${change.noteId}/undelete`);
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext) {
|
||||
activeContext.setNote(change.noteId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$noteLink.append(" (").append($undeleteLink).append(")");
|
||||
}
|
||||
} else {
|
||||
const note = await froca.getNote(change.noteId);
|
||||
const notePath = note?.getBestNotePathString();
|
||||
|
||||
if (notePath) {
|
||||
$noteLink = await linkService.createLink(notePath, {
|
||||
title: change.title,
|
||||
showNotePath: true
|
||||
});
|
||||
} else {
|
||||
$noteLink = $("<span>").text(note?.title ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
$changesList.append(
|
||||
$("<li>")
|
||||
.on("click", (e) => {
|
||||
// Skip clicks on the link or deleted notes
|
||||
if (e.target?.nodeName !== "A" && !change.current_isDeleted) {
|
||||
// Open the current note
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext) {
|
||||
activeContext.setNote(change.noteId);
|
||||
}
|
||||
}
|
||||
})
|
||||
.toggleClass("deleted-note", !!change.current_isDeleted)
|
||||
.append($("<span>").text(formattedTime).attr("title", change.date))
|
||||
.append($noteLink.addClass("note-title"))
|
||||
);
|
||||
}
|
||||
|
||||
this.$content.append(dayEl);
|
||||
}
|
||||
}
|
||||
|
||||
groupByDate(rows: RecentChangesRow[]) {
|
||||
const groupedByDate = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
const dateDay = row.date.substr(0, 10);
|
||||
|
||||
if (!groupedByDate.has(dateDay)) {
|
||||
groupedByDate.set(dateDay, []);
|
||||
}
|
||||
|
||||
groupedByDate.get(dateDay).push(row);
|
||||
}
|
||||
|
||||
return groupedByDate;
|
||||
}
|
||||
}
|
||||
180
apps/client/src/widgets/dialogs/recent_changes.tsx
Normal file
180
apps/client/src/widgets/dialogs/recent_changes.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import dialog from "../../services/dialog";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
import type { RecentChangeRow } from "@triliumnext/commons";
|
||||
import froca from "../../services/froca";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import link from "../../services/link";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import ws from "../../services/ws";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function RecentChangesDialogComponent() {
|
||||
const [ ancestorNoteId, setAncestorNoteId ] = useState<string>();
|
||||
const [ groupedByDate, setGroupedByDate ] = useState<Map<String, RecentChangeRow[]>>();
|
||||
const [ needsRefresh, setNeedsRefresh ] = useState(false);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => {
|
||||
setNeedsRefresh(true);
|
||||
setAncestorNoteId(ancestorNoteId ?? hoisted_note.getHoistedNoteId());
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (!groupedByDate || needsRefresh) {
|
||||
useEffect(() => {
|
||||
if (needsRefresh) {
|
||||
setNeedsRefresh(false);
|
||||
}
|
||||
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
await froca.getNotes(
|
||||
recentChanges.map((r) => r.noteId),
|
||||
true
|
||||
);
|
||||
|
||||
const groupedByDate = groupByDate(recentChanges);
|
||||
setGroupedByDate(groupedByDate);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("recent_changes.title")}
|
||||
className="recent-changes-dialog"
|
||||
size="lg"
|
||||
scrollable
|
||||
header={
|
||||
<Button
|
||||
text={t("recent_changes.erase_notes_button")}
|
||||
small style={{ padding: "0 10px" }}
|
||||
onClick={() => {
|
||||
server.post("notes/erase-deleted-notes-now").then(() => {
|
||||
setNeedsRefresh(true);
|
||||
toast.showMessage(t("recent_changes.deleted_notes_message"));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<div className="recent-changes-content">
|
||||
{groupedByDate?.size
|
||||
? <RecentChangesTimeline groupedByDate={groupedByDate} setShown={setShown} />
|
||||
: <>{t("recent_changes.no_changes_message")}</>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map<String, RecentChangeRow[]>, setShown: Dispatch<StateUpdater<boolean>> }) {
|
||||
return (
|
||||
<>
|
||||
{ Array.from(groupedByDate.entries()).map(([dateDay, dayChanges]) => {
|
||||
const formattedDate = formatDateTime(dateDay as string, "full", "none");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<b>{formattedDate}</b>
|
||||
|
||||
<ul>
|
||||
{ dayChanges.map((change) => {
|
||||
const isDeleted = change.current_isDeleted;
|
||||
const formattedTime = formatDateTime(change.date, "none", "short");
|
||||
const note = froca.getNoteFromCache(change.noteId);
|
||||
const notePath = note?.getBestNotePathString();
|
||||
|
||||
return (
|
||||
<li className={isDeleted ? "deleted-note" : ""}>
|
||||
<span title={change.date}>{formattedTime}</span>
|
||||
{ !isDeleted
|
||||
? <NoteLink notePath={notePath} title={change.current_title} />
|
||||
: <DeletedNoteLink change={change} setShown={setShown} /> }
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteLink({ notePath, title }: { notePath: string, title: string }) {
|
||||
if (!notePath || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ noteLink, setNoteLink ] = useState<JQuery<HTMLElement> | null>(null);
|
||||
useEffect(() => {
|
||||
link.createLink(notePath, {
|
||||
title,
|
||||
showNotePath: true
|
||||
}).then(setNoteLink);
|
||||
}, [notePath, title]);
|
||||
return (
|
||||
noteLink ? <RawHtml className="note-title" html={noteLink[0].innerHTML} /> : <span className="note-title">{title}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedNoteLink({ change, setShown }: { change: RecentChangeRow, setShown: Dispatch<StateUpdater<boolean>> }) {
|
||||
return (
|
||||
<>
|
||||
<span className="note-title">{change.current_title}</span>
|
||||
|
||||
(<a href="javascript:"
|
||||
onClick={async () => {
|
||||
const text = t("recent_changes.confirm_undelete");
|
||||
|
||||
if (await dialog.confirm(text)) {
|
||||
await server.put(`notes/${change.noteId}/undelete`);
|
||||
setShown(false);
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext) {
|
||||
activeContext.setNote(change.noteId);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{t("recent_changes.undelete_link")})
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default class RecentChangesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <RecentChangesDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function groupByDate(rows: RecentChangeRow[]) {
|
||||
const groupedByDate = new Map<String, RecentChangeRow[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const dateDay = row.date.substr(0, 10);
|
||||
|
||||
if (!groupedByDate.has(dateDay)) {
|
||||
groupedByDate.set(dateDay, []);
|
||||
}
|
||||
|
||||
groupedByDate.get(dateDay)!.push(row);
|
||||
}
|
||||
|
||||
return groupedByDate;
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import openService from "../../services/open.js";
|
||||
import protectedSessionHolder from "../../services/protected_session_holder.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import dialogService, { openDialog } from "../../services/dialog.js";
|
||||
import options from "../../services/options.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { NoteType } from "../../entities/fnote.js";
|
||||
import { Dropdown, Modal } from "bootstrap";
|
||||
import { renderMathInElement } from "../../services/math.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="revisions-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
.revisions-dialog .revision-content-wrapper {
|
||||
flex-grow: 1;
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.revisions-dialog .revision-content {
|
||||
overflow: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.revisions-dialog .revision-content img {
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.revisions-dialog .revision-content pre {
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title flex-grow-1">${t("revisions.note_revisions")}</h5>
|
||||
<button class="revisions-erase-all-revisions-button btn btn-sm"
|
||||
title="${t("revisions.delete_all_revisions")}"
|
||||
style="padding: 0 10px 0 10px;" type="button">${t("revisions.delete_all_button")}</button>
|
||||
<button class="help-button" type="button" data-help-page="note-revisions.html" title="${t("revisions.help_title")}">?</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("revisions.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="display: flex; height: 80vh;">
|
||||
<div class="dropdown">
|
||||
<button class="revision-list-dropdown" type="button" style="display: none;"
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
</button>
|
||||
|
||||
<div class="revision-list dropdown-menu static" style="position: static; height: 100%; overflow: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div class="revision-content-wrapper">
|
||||
<div style="flex-grow: 0; display: flex; justify-content: space-between;">
|
||||
<h3 class="revision-title" style="margin: 3px; flex-grow: 100;"></h3>
|
||||
|
||||
<div class="revision-title-buttons"></div>
|
||||
</div>
|
||||
|
||||
<div class="revision-content use-tn-links"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-0">
|
||||
<span class="revisions-snapshot-interval flex-grow-1 my-0 py-0"></span>
|
||||
<span class="maximum-revisions-for-current-note flex-grow-1 my-0 py-0"></span>
|
||||
<button class="revision-settings-button icon-action bx bx-cog my-0 py-0" title="${t("revisions.settings")}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
interface RevisionItem {
|
||||
noteId: string;
|
||||
revisionId: string;
|
||||
dateLastEdited: string;
|
||||
contentLength: number;
|
||||
type: NoteType;
|
||||
title: string;
|
||||
isProtected: boolean;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
interface FullRevision {
|
||||
content: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export default class RevisionsDialog extends BasicWidget {
|
||||
|
||||
private revisionItems: RevisionItem[];
|
||||
private note: FNote | null;
|
||||
private revisionId: string | null;
|
||||
|
||||
private modal!: Modal;
|
||||
private listDropdown!: Dropdown;
|
||||
|
||||
private $list!: JQuery<HTMLElement>;
|
||||
private $listDropdown!: JQuery<HTMLElement>;
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private $title!: JQuery<HTMLElement>;
|
||||
private $titleButtons!: JQuery<HTMLElement>;
|
||||
private $eraseAllRevisionsButton!: JQuery<HTMLElement>;
|
||||
private $maximumRevisions!: JQuery<HTMLElement>;
|
||||
private $snapshotInterval!: JQuery<HTMLElement>;
|
||||
private $revisionSettingsButton!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.revisionItems = [];
|
||||
this.note = null;
|
||||
this.revisionId = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$list = this.$widget.find(".revision-list");
|
||||
this.$listDropdown = this.$widget.find(".revision-list-dropdown");
|
||||
|
||||
this.listDropdown = Dropdown.getOrCreateInstance(this.$listDropdown[0], { autoClose: false });
|
||||
this.$content = this.$widget.find(".revision-content");
|
||||
this.$title = this.$widget.find(".revision-title");
|
||||
this.$titleButtons = this.$widget.find(".revision-title-buttons");
|
||||
this.$eraseAllRevisionsButton = this.$widget.find(".revisions-erase-all-revisions-button");
|
||||
this.$snapshotInterval = this.$widget.find(".revisions-snapshot-interval");
|
||||
this.$maximumRevisions = this.$widget.find(".maximum-revisions-for-current-note");
|
||||
this.$revisionSettingsButton = this.$widget.find(".revision-settings-button");
|
||||
this.listDropdown.show();
|
||||
|
||||
this.$listDropdown.parent().on("hide.bs.dropdown", (e) => {
|
||||
this.modal.hide();
|
||||
});
|
||||
|
||||
this.$widget.on("shown.bs.modal", () => {
|
||||
this.$list.find(`[data-revision-id="${this.revisionId}"]`).trigger("focus");
|
||||
});
|
||||
|
||||
this.$eraseAllRevisionsButton.on("click", async () => {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = t("revisions.confirm_delete_all");
|
||||
|
||||
if (await dialogService.confirm(text)) {
|
||||
await server.remove(`notes/${this.note.noteId}/revisions`);
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
toastService.showMessage(t("revisions.revisions_deleted"));
|
||||
}
|
||||
});
|
||||
|
||||
this.$list.on("focus", ".dropdown-item", (e) => {
|
||||
this.$list.find(".dropdown-item").each((i, el) => {
|
||||
$(el).toggleClass("active", el === e.target);
|
||||
});
|
||||
|
||||
this.setContentPane();
|
||||
});
|
||||
|
||||
this.$revisionSettingsButton.on("click", async () => {
|
||||
appContext.tabManager.openContextWithNote("_optionsOther", { activate: true });
|
||||
});
|
||||
}
|
||||
|
||||
async showRevisionsEvent({ noteId = appContext.tabManager.getActiveContextNoteId() }) {
|
||||
if (!noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
await this.loadRevisions(noteId);
|
||||
}
|
||||
|
||||
async loadRevisions(noteId: string) {
|
||||
this.$title.empty();
|
||||
this.$list.empty();
|
||||
this.$content.empty();
|
||||
this.$titleButtons.empty();
|
||||
|
||||
this.note = appContext.tabManager.getActiveContextNote();
|
||||
this.revisionItems = await server.get<RevisionItem[]>(`notes/${noteId}/revisions`);
|
||||
|
||||
for (const item of this.revisionItems) {
|
||||
this.$list.append(
|
||||
$('<a class="dropdown-item" tabindex="0">')
|
||||
.text(`${item.dateLastEdited.substr(0, 16)} (${utils.formatSize(item.contentLength)})`)
|
||||
.attr("data-revision-id", item.revisionId)
|
||||
.attr("title", t("revisions.revision_last_edited", { date: item.dateLastEdited }))
|
||||
);
|
||||
}
|
||||
|
||||
this.listDropdown.show();
|
||||
|
||||
if (this.revisionItems.length > 0) {
|
||||
if (!this.revisionId) {
|
||||
this.revisionId = this.revisionItems[0].revisionId;
|
||||
}
|
||||
} else {
|
||||
this.$title.text(t("revisions.no_revisions"));
|
||||
this.revisionId = null;
|
||||
}
|
||||
|
||||
this.$eraseAllRevisionsButton.toggle(this.revisionItems.length > 0);
|
||||
|
||||
// Show the footer of the revisions dialog
|
||||
this.$snapshotInterval.text(t("revisions.snapshot_interval", { seconds: options.getInt("revisionSnapshotTimeInterval") }));
|
||||
let revisionsNumberLimit: number | string = parseInt(this.note?.getLabelValue("versioningLimit") ?? "");
|
||||
if (!Number.isInteger(revisionsNumberLimit)) {
|
||||
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
|
||||
}
|
||||
if (revisionsNumberLimit === -1) {
|
||||
revisionsNumberLimit = "∞";
|
||||
}
|
||||
this.$maximumRevisions.text(t("revisions.maximum_revisions", { number: revisionsNumberLimit }));
|
||||
}
|
||||
|
||||
async setContentPane() {
|
||||
const revisionId = this.$list.find(".active").attr("data-revision-id");
|
||||
|
||||
const revisionItem = this.revisionItems.find((r) => r.revisionId === revisionId);
|
||||
if (!revisionItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$title.html(revisionItem.title);
|
||||
|
||||
this.renderContentButtons(revisionItem);
|
||||
|
||||
await this.renderContent(revisionItem);
|
||||
}
|
||||
|
||||
renderContentButtons(revisionItem: RevisionItem) {
|
||||
this.$titleButtons.empty();
|
||||
|
||||
const $restoreRevisionButton = $(`
|
||||
<button class="btn btn-sm" type="button">
|
||||
<span class="bx bx-history"></span>
|
||||
${t("revisions.restore_button")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
$restoreRevisionButton.on("click", async () => {
|
||||
const text = t("revisions.confirm_restore");
|
||||
|
||||
if (await dialogService.confirm(text)) {
|
||||
await server.post(`revisions/${revisionItem.revisionId}/restore`);
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
toastService.showMessage(t("revisions.revision_restored"));
|
||||
}
|
||||
});
|
||||
|
||||
const $eraseRevisionButton = $(`
|
||||
<button class="btn btn-sm" type="button">
|
||||
<span class="bx bx-trash"></span>
|
||||
${t("revisions.delete_button")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
$eraseRevisionButton.on("click", async () => {
|
||||
const text = t("revisions.confirm_delete");
|
||||
|
||||
if (await dialogService.confirm(text)) {
|
||||
await server.remove(`revisions/${revisionItem.revisionId}`);
|
||||
|
||||
this.loadRevisions(revisionItem.noteId);
|
||||
|
||||
toastService.showMessage(t("revisions.revision_deleted"));
|
||||
}
|
||||
});
|
||||
|
||||
if (!revisionItem.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
this.$titleButtons.append($restoreRevisionButton).append(" ");
|
||||
}
|
||||
|
||||
this.$titleButtons.append($eraseRevisionButton).append(" ");
|
||||
|
||||
const $downloadButton = $(`
|
||||
<button class="btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-download"></span>
|
||||
${t("revisions.download_button")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
$downloadButton.on("click", () => openService.downloadRevision(revisionItem.noteId, revisionItem.revisionId));
|
||||
|
||||
if (!revisionItem.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
this.$titleButtons.append($downloadButton);
|
||||
}
|
||||
}
|
||||
|
||||
async renderContent(revisionItem: RevisionItem) {
|
||||
this.$content.empty();
|
||||
|
||||
const fullRevision = await server.get<FullRevision>(`revisions/${revisionItem.revisionId}`);
|
||||
|
||||
if (revisionItem.type === "text") {
|
||||
this.$content.html(`<div class="ck-content">${fullRevision.content}</div>`);
|
||||
|
||||
if (this.$content.find("span.math-tex").length > 0) {
|
||||
renderMathInElement(this.$content[0], { trust: true });
|
||||
}
|
||||
} else if (revisionItem.type === "code") {
|
||||
this.$content.html($("<pre>")
|
||||
.text(fullRevision.content).prop("outerHTML"));
|
||||
} else if (revisionItem.type === "image") {
|
||||
if (fullRevision.mime === "image/svg+xml") {
|
||||
let encodedSVG = encodeURIComponent(fullRevision.content); //Base64 of other format images may be embedded in svg
|
||||
this.$content.html($("<img>")
|
||||
.attr("src", `data:${fullRevision.mime};utf8,${encodedSVG}`)
|
||||
.css("max-width", "100%")
|
||||
.css("max-height", "100%").prop("outerHTML"));
|
||||
} else {
|
||||
this.$content.html(
|
||||
$("<img>")
|
||||
// the reason why we put this inline as base64 is that we do not want to let user copy this
|
||||
// as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
|
||||
.attr("src", `data:${fullRevision.mime};base64,${fullRevision.content}`)
|
||||
.css("max-width", "100%")
|
||||
.css("max-height", "100%")
|
||||
.prop("outerHTML")
|
||||
);
|
||||
}
|
||||
} else if (revisionItem.type === "file") {
|
||||
const $table = $("<table cellpadding='10'>")
|
||||
.append($("<tr>")
|
||||
.append(
|
||||
$("<th>").text(t("revisions.mime")),
|
||||
$("<td>").text(revisionItem.mime)))
|
||||
.append($("<tr>").append($("<th>").text(t("revisions.file_size")), $("<td>").text(utils.formatSize(revisionItem.contentLength))));
|
||||
|
||||
if (fullRevision.content) {
|
||||
$table.append(
|
||||
$("<tr>").append(
|
||||
$('<td colspan="2">').append($('<div style="font-weight: bold;">').text(t("revisions.preview")), $('<pre class="file-preview-content"></pre>').text(fullRevision.content))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.$content.html($table.prop("outerHTML"));
|
||||
} else if (["canvas", "mindMap"].includes(revisionItem.type)) {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
|
||||
this.$content.html(
|
||||
$("<img>")
|
||||
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`)
|
||||
.css("max-width", "100%")
|
||||
.prop("outerHTML"));
|
||||
} else if (revisionItem.type === "mermaid") {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
|
||||
this.$content.html(
|
||||
$("<img>")
|
||||
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`)
|
||||
.css("max-width", "100%")
|
||||
.prop("outerHTML"));
|
||||
|
||||
this.$content.append($("<pre>").text(fullRevision.content));
|
||||
} else {
|
||||
this.$content.text(t("revisions.preview_not_available"));
|
||||
}
|
||||
}
|
||||
}
|
||||
308
apps/client/src/widgets/dialogs/revisions.tsx
Normal file
308
apps/client/src/widgets/dialogs/revisions.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import type { RevisionPojo, RevisionItem } from "@triliumnext/commons";
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import dialog from "../../services/dialog";
|
||||
import froca from "../../services/froca";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import FormList, { FormListItem } from "../react/FormList";
|
||||
import utils from "../../services/utils";
|
||||
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import { renderMathInElement } from "../../services/math";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import open from "../../services/open";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import options from "../../services/options";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function RevisionsDialogComponent() {
|
||||
const [ note, setNote ] = useState<FNote>();
|
||||
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
|
||||
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
|
||||
useTriliumEvent("showRevisions", async ({ noteId }) => {
|
||||
const note = await getNote(noteId);
|
||||
if (note) {
|
||||
setNote(note);
|
||||
setShown(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (note?.noteId) {
|
||||
server.get<RevisionItem[]>(`notes/${note.noteId}/revisions`).then(setRevisions);
|
||||
} else {
|
||||
setRevisions(undefined);
|
||||
}
|
||||
}, [ note?.noteId, refreshCounter ]);
|
||||
|
||||
if (revisions?.length && !currentRevision) {
|
||||
setCurrentRevision(revisions[0]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="revisions-dialog"
|
||||
size="xl"
|
||||
title={t("revisions.note_revisions")}
|
||||
helpPageId="vZWERwf8U3nx"
|
||||
bodyStyle={{ display: "flex", height: "80vh" }}
|
||||
header={
|
||||
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} small style={{ padding: "0 10px" }}
|
||||
onClick={async () => {
|
||||
const text = t("revisions.confirm_delete_all");
|
||||
|
||||
if (note && await dialog.confirm(text)) {
|
||||
await server.remove(`notes/${note.noteId}/revisions`);
|
||||
setRevisions([]);
|
||||
setCurrentRevision(undefined);
|
||||
toast.showMessage(t("revisions.revisions_deleted"));
|
||||
}
|
||||
}}/>)
|
||||
}
|
||||
footer={<RevisionFooter note={note} />}
|
||||
footerStyle={{ paddingTop: 0, paddingBottom: 0 }}
|
||||
onHidden={() => {
|
||||
setShown(false);
|
||||
setNote(undefined);
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<RevisionsList
|
||||
revisions={revisions ?? []}
|
||||
onSelect={(revisionId) => {
|
||||
const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
|
||||
if (correspondingRevision) {
|
||||
setCurrentRevision(correspondingRevision);
|
||||
}
|
||||
}}
|
||||
currentRevision={currentRevision}
|
||||
/>
|
||||
|
||||
<div className="revision-content-wrapper" style={{
|
||||
flexGrow: "1",
|
||||
marginLeft: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0
|
||||
}}>
|
||||
<RevisionPreview
|
||||
revisionItem={currentRevision}
|
||||
setShown={setShown}
|
||||
onRevisionDeleted={() => {
|
||||
setRefreshCounter(c => c + 1);
|
||||
setCurrentRevision(undefined);
|
||||
}} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
|
||||
return (
|
||||
<FormList onSelect={onSelect} fullHeight>
|
||||
{revisions.map((item) =>
|
||||
<FormListItem
|
||||
title={t("revisions.revision_last_edited", { date: item.dateLastEdited })}
|
||||
value={item.revisionId}
|
||||
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
||||
>
|
||||
{item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||
</FormListItem>
|
||||
)}
|
||||
</FormList>);
|
||||
}
|
||||
|
||||
function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
|
||||
revisionItem?: RevisionItem,
|
||||
setShown: Dispatch<StateUpdater<boolean>>,
|
||||
onRevisionDeleted?: () => void
|
||||
}) {
|
||||
const [ fullRevision, setFullRevision ] = useState<RevisionPojo>();
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionItem) {
|
||||
server.get<RevisionPojo>(`revisions/${revisionItem.revisionId}`).then(setFullRevision);
|
||||
} else {
|
||||
setFullRevision(undefined);
|
||||
}
|
||||
}, [revisionItem]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style="flex-grow: 0; display: flex; justify-content: space-between;">
|
||||
<h3 className="revision-title" style="margin: 3px; flex-grow: 100;">{revisionItem?.title ?? t("revisions.no_revisions")}</h3>
|
||||
{(revisionItem && <div className="revision-title-buttons">
|
||||
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) &&
|
||||
<>
|
||||
<Button
|
||||
icon="bx bx-history"
|
||||
text={t("revisions.restore_button")}
|
||||
onClick={async () => {
|
||||
if (await dialog.confirm(t("revisions.confirm_restore"))) {
|
||||
await server.post(`revisions/${revisionItem.revisionId}/restore`);
|
||||
setShown(false);
|
||||
toast.showMessage(t("revisions.revision_restored"));
|
||||
}
|
||||
}}/>
|
||||
|
||||
<Button
|
||||
icon="bx bx-trash"
|
||||
text={t("revisions.delete_button")}
|
||||
onClick={async () => {
|
||||
if (await dialog.confirm(t("revisions.confirm_delete"))) {
|
||||
await server.remove(`revisions/${revisionItem.revisionId}`);
|
||||
toast.showMessage(t("revisions.revision_deleted"));
|
||||
onRevisionDeleted?.();
|
||||
}
|
||||
}} />
|
||||
|
||||
<Button
|
||||
primary
|
||||
icon="bx bx-download"
|
||||
text={t("revisions.download_button")}
|
||||
onClick={() => {
|
||||
if (revisionItem.revisionId) {
|
||||
open.downloadRevision(revisionItem.noteId, revisionItem.revisionId)}
|
||||
}
|
||||
}/>
|
||||
</>
|
||||
}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}>
|
||||
<RevisionContent revisionItem={revisionItem} fullRevision={fullRevision} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const IMAGE_STYLE: CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "90%",
|
||||
objectFit: "contain"
|
||||
};
|
||||
|
||||
const CODE_STYLE: CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap"
|
||||
};
|
||||
|
||||
function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: RevisionItem, fullRevision?: RevisionPojo }) {
|
||||
const content = fullRevision?.content;
|
||||
if (!revisionItem || !content) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
|
||||
switch (revisionItem.type) {
|
||||
case "text": {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (contentRef.current?.querySelector("span.math-tex")) {
|
||||
renderMathInElement(contentRef.current, { trust: true });
|
||||
}
|
||||
});
|
||||
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
|
||||
}
|
||||
case "code":
|
||||
return <pre style={CODE_STYLE}>{content}</pre>;
|
||||
case "image":
|
||||
switch (revisionItem.mime) {
|
||||
case "image/svg+xml": {
|
||||
//Base64 of other format images may be embedded in svg
|
||||
const encodedSVG = encodeURIComponent(content as string);
|
||||
return <img
|
||||
src={`data:${fullRevision.mime};utf8,${encodedSVG}`}
|
||||
style={IMAGE_STYLE} />;
|
||||
}
|
||||
default: {
|
||||
// the reason why we put this inline as base64 is that we do not want to let user copy this
|
||||
// as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
|
||||
return <img
|
||||
src={`data:${fullRevision.mime};base64,${fullRevision.content}`}
|
||||
style={IMAGE_STYLE} />
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
return <table cellPadding="10">
|
||||
<tr>
|
||||
<th>{t("revisions.mime")}</th>
|
||||
<td>{revisionItem.mime}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("revisions.file_size")}</th>
|
||||
<td>{revisionItem.contentLength && utils.formatSize(revisionItem.contentLength)}</td>
|
||||
</tr>
|
||||
{fullRevision.content &&
|
||||
<tr>
|
||||
<td colspan={2}>
|
||||
<strong>{t("revisions.preview")}</strong>
|
||||
<pre className="file-preview-content" style={CODE_STYLE}>{fullRevision.content}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>;
|
||||
case "canvas":
|
||||
case "mindMap":
|
||||
case "mermaid": {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
return <img
|
||||
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}
|
||||
style={IMAGE_STYLE} />;
|
||||
}
|
||||
default:
|
||||
return <>{t("revisions.preview_not_available")}</>
|
||||
}
|
||||
}
|
||||
|
||||
function RevisionFooter({ note }: { note?: FNote }) {
|
||||
if (!note) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let revisionsNumberLimit: number | string = parseInt(note?.getLabelValue("versioningLimit") ?? "");
|
||||
if (!Number.isInteger(revisionsNumberLimit)) {
|
||||
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
|
||||
}
|
||||
if (revisionsNumberLimit === -1) {
|
||||
revisionsNumberLimit = "∞";
|
||||
}
|
||||
|
||||
return <>
|
||||
<span class="revisions-snapshot-interval flex-grow-1 my-0 py-0">
|
||||
{t("revisions.snapshot_interval", { seconds: options.getInt("revisionSnapshotTimeInterval") })}
|
||||
</span>
|
||||
<span class="maximum-revisions-for-current-note flex-grow-1 my-0 py-0">
|
||||
{t("revisions.maximum_revisions", { number: revisionsNumberLimit })}
|
||||
</span>
|
||||
<ActionButton
|
||||
icon="bx bx-cog" text={t("revisions.settings")}
|
||||
onClick={() => appContext.tabManager.openContextWithNote("_optionsOther", { activate: true })}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default class RevisionsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <RevisionsDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function getNote(noteId?: string | null) {
|
||||
if (noteId) {
|
||||
return await froca.getNote(noteId);
|
||||
} else {
|
||||
return appContext.tabManager.getActiveContextNote();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { closeActiveDialog, openDialog } from "../../services/dialog.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
|
||||
const TPL = /*html*/`<div class="sort-child-notes-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" style="max-width: 500px" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("sort_child_notes.sort_children_by")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("sort_child_notes.close")}"></button>
|
||||
</div>
|
||||
<form class="sort-child-notes-form">
|
||||
<div class="modal-body">
|
||||
<h5>${t("sort_child_notes.sorting_criteria")}</h5>
|
||||
<div class="form-check">
|
||||
<label for="sort-by-title" class="form-check-label tn-radio">
|
||||
<input id="sort-by-title" class="form-check-input" type="radio" name="sort-by" value="title" checked>
|
||||
${t("sort_child_notes.title")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="sort-by-dateCreated" class="form-check-label tn-radio">
|
||||
<input id="sort-by-dateCreated" class="form-check-input" type="radio" name="sort-by" value="dateCreated">
|
||||
${t("sort_child_notes.date_created")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="sort-by-dateModified" class="form-check-label tn-radio">
|
||||
<input id="sort-by-dateModified" class="form-check-input" type="radio" name="sort-by" value="dateModified">
|
||||
${t("sort_child_notes.date_modified")}
|
||||
</label>
|
||||
</div>
|
||||
<br/>
|
||||
<h5>${t("sort_child_notes.sorting_direction")}</h5>
|
||||
<div class="form-check">
|
||||
<label for="sort-direction-asc" class="form-check-label tn-radio">
|
||||
<input id="sort-direction-asc" class="form-check-input" type="radio" name="sort-direction" value="asc" checked>
|
||||
${t("sort_child_notes.ascending")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="sort-direction-desc" class="form-check-label tn-radio">
|
||||
<input id="sort-direction-desc" class="form-check-input" type="radio" name="sort-direction" value="desc">
|
||||
${t("sort_child_notes.descending")}
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
<h5>${t("sort_child_notes.folders")}</h5>
|
||||
<div class="form-check">
|
||||
<label for="sort-folders-first" class="form-check-label tn-checkbox">
|
||||
<input id="sort-folders-first" class="form-check-input" type="checkbox" name="sort-folders-first" value="1">
|
||||
${t("sort_child_notes.sort_folders_at_top")}
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
<h5>${t("sort_child_notes.natural_sort")}</h5>
|
||||
<div class="form-check">
|
||||
<label for="sort-natural" class="form-check-label tn-checkbox">
|
||||
<input id="sort-natural" class="form-check-input" type="checkbox" name="sort-natural" value="1">
|
||||
${t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
<div class="form-check">
|
||||
<label>
|
||||
${t("sort_child_notes.natural_sort_language")}
|
||||
<input class="form-control" name="sort-locale">
|
||||
${t("sort_child_notes.the_language_code_for_natural_sort")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">${t("sort_child_notes.sort")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class SortChildNotesDialog extends BasicWidget {
|
||||
|
||||
private parentNoteId?: string;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".sort-child-notes-form");
|
||||
|
||||
this.$form.on("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const sortBy = this.$form.find("input[name='sort-by']:checked").val();
|
||||
const sortDirection = this.$form.find("input[name='sort-direction']:checked").val();
|
||||
const foldersFirst = this.$form.find("input[name='sort-folders-first']").is(":checked");
|
||||
const sortNatural = this.$form.find("input[name='sort-natural']").is(":checked");
|
||||
const sortLocale = this.$form.find("input[name='sort-locale']").val();
|
||||
|
||||
await server.put(`notes/${this.parentNoteId}/sort-children`, { sortBy, sortDirection, foldersFirst, sortNatural, sortLocale });
|
||||
|
||||
closeActiveDialog();
|
||||
});
|
||||
}
|
||||
|
||||
async sortChildNotesEvent({ node }: EventData<"sortChildNotes">) {
|
||||
this.parentNoteId = node.data.noteId;
|
||||
|
||||
openDialog(this.$widget);
|
||||
|
||||
this.$form.find("input:first").focus();
|
||||
}
|
||||
}
|
||||
102
apps/client/src/widgets/dialogs/sort_child_notes.tsx
Normal file
102
apps/client/src/widgets/dialogs/sort_child_notes.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import server from "../../services/server";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function SortChildNotesDialogComponent() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ sortBy, setSortBy ] = useState("title");
|
||||
const [ sortDirection, setSortDirection ] = useState("asc");
|
||||
const [ foldersFirst, setFoldersFirst ] = useState(false);
|
||||
const [ sortNatural, setSortNatural ] = useState(false);
|
||||
const [ sortLocale, setSortLocale ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("sortChildNotes", ({ node }) => {
|
||||
setParentNoteId(node.data.noteId);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
await server.put(`notes/${parentNoteId}/sort-children`, {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
foldersFirst,
|
||||
sortNatural,
|
||||
sortLocale
|
||||
});
|
||||
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="sort-child-notes-dialog"
|
||||
title={t("sort_child_notes.sort_children_by")}
|
||||
size="lg" maxWidth={500}
|
||||
onSubmit={onSubmit}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
footer={<Button text={t("sort_child_notes.sort")} keyboardShortcut="Enter" />}
|
||||
>
|
||||
<h5>{t("sort_child_notes.sorting_criteria")}</h5>
|
||||
<FormRadioGroup
|
||||
name="sort-by"
|
||||
values={[
|
||||
{ value: "title", label: t("sort_child_notes.title") },
|
||||
{ value: "dateCreated", label: t("sort_child_notes.date_created") },
|
||||
{ value: "dateModified", label: t("sort_child_notes.date_modified") }
|
||||
]}
|
||||
currentValue={sortBy} onChange={setSortBy}
|
||||
/>
|
||||
<br/>
|
||||
|
||||
<h5>{t("sort_child_notes.sorting_direction")}</h5>
|
||||
<FormRadioGroup
|
||||
name="sort-direction"
|
||||
values={[
|
||||
{ value: "asc", label: t("sort_child_notes.ascending") },
|
||||
{ value: "desc", label: t("sort_child_notes.descending") }
|
||||
]}
|
||||
currentValue={sortDirection} onChange={setSortDirection}
|
||||
/>
|
||||
<br/>
|
||||
|
||||
<h5>{t("sort_child_notes.folders")}</h5>
|
||||
<FormCheckbox
|
||||
label={t("sort_child_notes.sort_folders_at_top")}
|
||||
name="sort-folders-first"
|
||||
currentValue={foldersFirst} onChange={setFoldersFirst}
|
||||
/>
|
||||
<br />
|
||||
|
||||
<h5>{t("sort_child_notes.natural_sort")}</h5>
|
||||
<FormCheckbox
|
||||
name="sort-natural"
|
||||
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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class SortChildNotesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <SortChildNotesDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { escapeQuotes } from "../../services/utils.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import importService from "../../services/import.js";
|
||||
import options from "../../services/options.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal, Tooltip } from "bootstrap";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="upload-attachments-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("upload_attachments.upload_attachments_to_note")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("upload_attachments.close")}"></button>
|
||||
</div>
|
||||
<form class="upload-attachment-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="upload-attachment-file-upload-input"><strong>${t("upload_attachments.choose_files")}</strong></label>
|
||||
<label class="tn-file-input tn-input-field">
|
||||
<input type="file" class="upload-attachment-file-upload-input form-control-file" multiple />
|
||||
</label>
|
||||
<p>${t("upload_attachments.files_will_be_uploaded")} <strong class="upload-attachment-note-title"></strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<strong>${t("upload_attachments.options")}:</strong>
|
||||
<div class="checkbox">
|
||||
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("upload_attachments.tooltip"))}">
|
||||
<input class="shrink-images-checkbox form-check-input" value="1" type="checkbox" checked> <span>${t("upload_attachments.shrink_images")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="upload-attachment-button btn btn-primary">${t("upload_attachments.upload")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class UploadAttachmentsDialog extends BasicWidget {
|
||||
|
||||
private parentNoteId: string | null;
|
||||
private modal!: bootstrap.Modal;
|
||||
private $form!: JQuery<HTMLElement>;
|
||||
private $noteTitle!: JQuery<HTMLElement>;
|
||||
private $fileUploadInput!: JQuery<HTMLInputElement>;
|
||||
private $uploadButton!: JQuery<HTMLElement>;
|
||||
private $shrinkImagesCheckbox!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.parentNoteId = null;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$form = this.$widget.find(".upload-attachment-form");
|
||||
this.$noteTitle = this.$widget.find(".upload-attachment-note-title");
|
||||
this.$fileUploadInput = this.$widget.find(".upload-attachment-file-upload-input");
|
||||
this.$uploadButton = this.$widget.find(".upload-attachment-button");
|
||||
this.$shrinkImagesCheckbox = this.$widget.find(".shrink-images-checkbox");
|
||||
|
||||
this.$form.on("submit", () => {
|
||||
// disabling so that import is not triggered again.
|
||||
this.$uploadButton.attr("disabled", "disabled");
|
||||
if (this.parentNoteId) {
|
||||
this.uploadAttachments(this.parentNoteId);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$fileUploadInput.on("change", () => {
|
||||
if (this.$fileUploadInput.val()) {
|
||||
this.$uploadButton.removeAttr("disabled");
|
||||
} else {
|
||||
this.$uploadButton.attr("disabled", "disabled");
|
||||
}
|
||||
});
|
||||
|
||||
Tooltip.getOrCreateInstance(this.$widget.find('[data-bs-toggle="tooltip"]')[0], {
|
||||
html: true
|
||||
});
|
||||
}
|
||||
|
||||
async showUploadAttachmentsDialogEvent({ noteId }: EventData<"showUploadAttachmentsDialog">) {
|
||||
this.parentNoteId = noteId;
|
||||
|
||||
this.$fileUploadInput.val("").trigger("change"); // to trigger upload button disabling listener below
|
||||
this.$shrinkImagesCheckbox.prop("checked", options.is("compressImages"));
|
||||
|
||||
this.$noteTitle.text(await treeService.getNoteTitle(this.parentNoteId));
|
||||
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
|
||||
async uploadAttachments(parentNoteId: string) {
|
||||
const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below
|
||||
|
||||
function boolToString($el: JQuery<HTMLElement>): "true" | "false" {
|
||||
return ($el.is(":checked") ? "true" : "false");
|
||||
}
|
||||
|
||||
const options = {
|
||||
shrinkImages: boolToString(this.$shrinkImagesCheckbox)
|
||||
};
|
||||
|
||||
this.modal.hide();
|
||||
|
||||
await importService.uploadFiles("attachments", parentNoteId, files, options);
|
||||
}
|
||||
}
|
||||
75
apps/client/src/widgets/dialogs/upload_attachments.tsx
Normal file
75
apps/client/src/widgets/dialogs/upload_attachments.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormFileUpload from "../react/FormFileUpload";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import options from "../../services/options";
|
||||
import importService from "../../services/import.js";
|
||||
import tree from "../../services/tree";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
|
||||
function UploadAttachmentsDialogComponent() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ files, setFiles ] = useState<FileList | null>(null);
|
||||
const [ shrinkImages, setShrinkImages ] = useState(options.is("compressImages"));
|
||||
const [ isUploading, setIsUploading ] = useState(false);
|
||||
const [ description, setDescription ] = useState<string | undefined>(undefined);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showUploadAttachmentsDialog", ({ noteId }) => {
|
||||
setParentNoteId(noteId);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (parentNoteId) {
|
||||
useEffect(() => {
|
||||
tree.getNoteTitle(parentNoteId).then((noteTitle) =>
|
||||
setDescription(t("upload_attachments.files_will_be_uploaded", { noteTitle })));
|
||||
}, [parentNoteId]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="upload-attachments-dialog"
|
||||
size="lg"
|
||||
title={t("upload_attachments.upload_attachments_to_note")}
|
||||
footer={<Button text={t("upload_attachments.upload")} primary disabled={!files || isUploading} />}
|
||||
onSubmit={async () => {
|
||||
if (!files || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const filesCopy = Array.from(files);
|
||||
await importService.uploadFiles("attachments", parentNoteId, filesCopy, { shrinkImages });
|
||||
setIsUploading(false);
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("upload_attachments.choose_files")} description={description}>
|
||||
<FormFileUpload onChange={setFiles} multiple />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("upload_attachments.options")}>
|
||||
<FormCheckbox
|
||||
name="shrink-images"
|
||||
hint={t("upload_attachments.tooltip")} label={t("upload_attachments.shrink_images")}
|
||||
currentValue={shrinkImages} onChange={setShrinkImages}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class UploadAttachmentsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <UploadAttachmentsDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
13
apps/client/src/widgets/react/ActionButton.tsx
Normal file
13
apps/client/src/widgets/react/ActionButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
interface ActionButtonProps {
|
||||
text: string;
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ActionButton({ text, icon, onClick }: ActionButtonProps) {
|
||||
return <button
|
||||
class={`icon-action ${icon}`}
|
||||
title={text}
|
||||
onClick={onClick}
|
||||
/>;
|
||||
}
|
||||
17
apps/client/src/widgets/react/Alert.tsx
Normal file
17
apps/client/src/widgets/react/Alert.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface AlertProps {
|
||||
type: "info" | "danger";
|
||||
title?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function Alert({ title, type, children }: AlertProps) {
|
||||
return (
|
||||
<div className={`alert alert-${type}`}>
|
||||
{title && <h4>{title}</h4>}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/client/src/widgets/react/Badge.tsx
Normal file
8
apps/client/src/widgets/react/Badge.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
interface BadgeProps {
|
||||
className?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function Badge({ title, className }: BadgeProps) {
|
||||
return <span class={`badge ${className ?? ""}`}>{title}</span>
|
||||
}
|
||||
68
apps/client/src/widgets/react/Button.tsx
Normal file
68
apps/client/src/widgets/react/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useRef, useMemo } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
|
||||
interface ButtonProps {
|
||||
/** Reference to the button element. Mostly useful for requesting focus. */
|
||||
buttonRef?: RefObject<HTMLButtonElement>;
|
||||
text: string;
|
||||
className?: string;
|
||||
icon?: string;
|
||||
keyboardShortcut?: string;
|
||||
/** Called when the button is clicked. If not set, the button will submit the form (if any). */
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
small?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) => {
|
||||
// Memoize classes array to prevent recreation
|
||||
const classes = useMemo(() => {
|
||||
const classList: string[] = ["btn"];
|
||||
if (primary) {
|
||||
classList.push("btn-primary");
|
||||
} else {
|
||||
classList.push("btn-secondary");
|
||||
}
|
||||
if (className) {
|
||||
classList.push(className);
|
||||
}
|
||||
if (small) {
|
||||
classList.push("btn-sm");
|
||||
}
|
||||
return classList.join(" ");
|
||||
}, [primary, className, small]);
|
||||
|
||||
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Memoize keyboard shortcut rendering
|
||||
const shortcutElements = useMemo(() => {
|
||||
if (!keyboardShortcut) return null;
|
||||
const splitShortcut = keyboardShortcut.split("+");
|
||||
return splitShortcut.map((key, index) => (
|
||||
<>
|
||||
<kbd key={index}>{key.toUpperCase()}</kbd>
|
||||
{index < splitShortcut.length - 1 ? "+" : ""}
|
||||
</>
|
||||
));
|
||||
}, [keyboardShortcut]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
type={onClick ? "button" : "submit"}
|
||||
onClick={onClick}
|
||||
ref={buttonRef}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
>
|
||||
{icon && <span className={`bx ${icon}`}></span>}
|
||||
{text} {shortcutElements}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
52
apps/client/src/widgets/react/Dropdown.tsx
Normal file
52
apps/client/src/widgets/react/Dropdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
|
||||
interface DropdownProps {
|
||||
className?: string;
|
||||
isStatic?: boolean;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function Dropdown({ className, isStatic, children }: DropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current);
|
||||
return () => dropdown.dispose();
|
||||
}, []); // Add dependency array
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropdownRef.current) return;
|
||||
|
||||
const handleHide = () => {
|
||||
// Remove console.log from production code
|
||||
};
|
||||
|
||||
const $dropdown = $(dropdownRef.current);
|
||||
$dropdown.on("hide.bs.dropdown", handleHide);
|
||||
|
||||
// Add proper cleanup
|
||||
return () => {
|
||||
$dropdown.off("hide.bs.dropdown", handleHide);
|
||||
};
|
||||
}, []); // Add dependency array
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} class="dropdown" style={{ display: "flex" }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
style={{ display: "none" }}
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-display={ isStatic ? "static" : undefined } />
|
||||
|
||||
<div class={`dropdown-menu ${className ?? ""} ${isStatic ? "static" : undefined}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
apps/client/src/widgets/react/FormCheckbox.tsx
Normal file
70
apps/client/src/widgets/react/FormCheckbox.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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";
|
||||
|
||||
interface FormCheckboxProps {
|
||||
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.
|
||||
*/
|
||||
hint?: string;
|
||||
currentValue: boolean;
|
||||
disabled?: boolean;
|
||||
onChange(newValue: boolean): void;
|
||||
}
|
||||
|
||||
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => {
|
||||
const labelRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
// Fix: Move useEffect outside conditional
|
||||
useEffect(() => {
|
||||
if (!hint || !labelRef.current) return;
|
||||
|
||||
const tooltipInstance = Tooltip.getOrCreateInstance(labelRef.current, {
|
||||
html: true,
|
||||
template: '<div class="tooltip tooltip-top" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>'
|
||||
});
|
||||
|
||||
return () => tooltipInstance?.dispose();
|
||||
}, [hint]); // Proper dependency
|
||||
|
||||
// Memoize style object
|
||||
const labelStyle = useMemo(() =>
|
||||
hint ? { textDecoration: "underline dotted var(--main-text-color)" } : undefined,
|
||||
[hint]
|
||||
);
|
||||
|
||||
// Memoize onChange handler
|
||||
const handleChange = useCallback((e: Event) => {
|
||||
onChange((e.target as HTMLInputElement).checked);
|
||||
}, [onChange]);
|
||||
|
||||
// Memoize title attribute
|
||||
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
|
||||
|
||||
return (
|
||||
<div className="form-checkbox">
|
||||
<label
|
||||
className="form-check-label tn-checkbox"
|
||||
style={labelStyle}
|
||||
title={titleText}
|
||||
ref={labelRef}
|
||||
>
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={currentValue || false}
|
||||
value="1"
|
||||
disabled={disabled}
|
||||
onChange={handleChange} />
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default FormCheckbox;
|
||||
13
apps/client/src/widgets/react/FormFileUpload.tsx
Normal file
13
apps/client/src/widgets/react/FormFileUpload.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
interface FormFileUploadProps {
|
||||
onChange: (files: FileList | null) => void;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export default function FormFileUpload({ onChange, multiple }: FormFileUploadProps) {
|
||||
return (
|
||||
<label class="tn-file-input tn-input-field">
|
||||
<input type="file" class="form-control-file" multiple={multiple}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).files)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
24
apps/client/src/widgets/react/FormGroup.tsx
Normal file
24
apps/client/src/widgets/react/FormGroup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ComponentChildren, RefObject } from "preact";
|
||||
|
||||
interface FormGroupProps {
|
||||
labelRef?: RefObject<HTMLLabelElement>;
|
||||
label?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
description?: string | ComponentChildren;
|
||||
}
|
||||
|
||||
export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) {
|
||||
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>
|
||||
|
||||
{description && <small className="form-text">{description}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
apps/client/src/widgets/react/FormList.tsx
Normal file
97
apps/client/src/widgets/react/FormList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { ComponentChildren } from "preact";
|
||||
import Icon from "./Icon";
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "preact/compat";
|
||||
|
||||
interface FormListOpts {
|
||||
children: ComponentChildren;
|
||||
onSelect?: (value: string) => void;
|
||||
style?: CSSProperties;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
export default function FormList({ children, onSelect, style, fullHeight }: FormListOpts) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current || !wrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $wrapperRef = $(wrapperRef.current);
|
||||
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current);
|
||||
$wrapperRef.on("hide.bs.dropdown", (e) => e.preventDefault());
|
||||
|
||||
return () => {
|
||||
$wrapperRef.off("hide.bs.dropdown");
|
||||
dropdown.dispose();
|
||||
}
|
||||
}, [ triggerRef, wrapperRef ]);
|
||||
|
||||
const builtinStyles = useMemo(() => {
|
||||
const style: CSSProperties = {};
|
||||
if (fullHeight) {
|
||||
style.height = "100%";
|
||||
}
|
||||
return style;
|
||||
}, [ fullHeight ]);
|
||||
|
||||
return (
|
||||
<div className="dropdownWrapper" ref={wrapperRef} style={builtinStyles}>
|
||||
<div className="dropdown" style={builtinStyles}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button" style="display: none;"
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu static show" style={{
|
||||
...style ?? {},
|
||||
...builtinStyles,
|
||||
position: "relative",
|
||||
}} onClick={(e) => {
|
||||
const value = (e.target as HTMLElement)?.dataset?.value;
|
||||
if (value && onSelect) {
|
||||
onSelect(value);
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormListItemOpts {
|
||||
children: ComponentChildren;
|
||||
icon?: string;
|
||||
value?: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function FormListItem({ children, icon, value, title, active }: FormListItemOpts) {
|
||||
return (
|
||||
<a
|
||||
class={`dropdown-item ${active ? "active" : ""}`}
|
||||
data-value={value} title={title}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormListHeaderOpts {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function FormListHeader({ text }: FormListHeaderOpts) {
|
||||
return (
|
||||
<li>
|
||||
<h6 className="dropdown-header">{text}</h6>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user