React modals (#6544)

This commit is contained in:
Elian Doran
2025-08-11 22:26:14 +03:00
committed by GitHub
119 changed files with 15325 additions and 15277 deletions

View File

@@ -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,9 +26,10 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
# The "$1" will be replaced by the keypath specified.
refactorTemplates:
- t("$1")
- {t("$1")}
- ${t("$1")}
- <%= t("$1") %>
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true
monopoly: true

View File

@@ -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 = {

View File

@@ -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;
@@ -82,12 +82,12 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void
// Check if we're in command mode
if (options.isCommandPalette && term.startsWith(">")) {
const commandQuery = term.substring(1).trim();
// Get commands (all if no query, filtered if query provided)
const commands = commandQuery.length === 0
const commands = commandQuery.length === 0
? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
action: "command",
@@ -99,7 +99,7 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void
commandShortcut: cmd.shortcut,
icon: cmd.icon
}));
cb(commandSuggestions);
return;
}
@@ -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,

View File

@@ -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 });
});
}

View File

@@ -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;

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,380 +1,380 @@
{
"about": {
"title": "O Trilium Belеškama",
"close": "Zatvori",
"homepage": "Početna stranica:",
"app_version": "Verzija aplikacije:",
"db_version": "Verzija baze podataka:",
"sync_version": "Verzija sinhronizacije:",
"build_date": "Datum izgradnje:",
"build_revision": "Revizija izgradnje:",
"data_directory": "Direktorijum sa podacima:"
"about": {
"title": "O Trilium Belеškama",
"close": "Zatvori",
"homepage": "Početna stranica:",
"app_version": "Verzija aplikacije:",
"db_version": "Verzija baze podataka:",
"sync_version": "Verzija sinhronizacije:",
"build_date": "Datum izgradnje:",
"build_revision": "Revizija izgradnje:",
"data_directory": "Direktorijum sa podacima:"
},
"toast": {
"critical-error": {
"title": "Kritična greška",
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
},
"toast": {
"critical-error": {
"title": "Kritična greška",
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
},
"widget-error": {
"title": "Pokretanje vidžeta nije uspelo",
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
},
"bundle-error": {
"title": "Pokretanje prilagođene skripte neuspešno",
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
}
"widget-error": {
"title": "Pokretanje vidžeta nije uspelo",
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
},
"add_link": {
"add_link": "Dodaj link",
"help_on_links": "Pomoć na linkovima",
"close": "Zatvori",
"note": "Beleška",
"search_note": "potražite belešku po njenom imenu",
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
"link_title": "Naziv linka",
"button_add_link": "Dodaj link <kbd>enter</kbd>"
"bundle-error": {
"title": "Pokretanje prilagođene skripte neuspešno",
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
}
},
"add_link": {
"add_link": "Dodaj link",
"help_on_links": "Pomoć na linkovima",
"close": "Zatvori",
"note": "Beleška",
"search_note": "potražite belešku po njenom imenu",
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
"link_title": "Naziv linka",
"button_add_link": "Dodaj link <kbd>enter</kbd>"
},
"branch_prefix": {
"edit_branch_prefix": "Izmeni prefiks grane",
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
"close": "Zatvori",
"prefix": "Prefiks: ",
"save": "Sačuvaj",
"branch_prefix_saved": "Prefiks grane je sačuvan."
},
"bulk_actions": {
"bulk_actions": "Grupne akcije",
"close": "Zatvori",
"affected_notes": "Pogođene beleške",
"include_descendants": "Obuhvati potomke izabranih beleški",
"available_actions": "Dostupne akcije",
"chosen_actions": "Izabrane akcije",
"execute_bulk_actions": "Izvrši grupne akcije",
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
"labels": "Oznake",
"relations": "Odnosi",
"notes": "Beleške",
"other": "Ostalo"
},
"clone_to": {
"clone_notes_to": "Klonirajte beleške u...",
"close": "Zatvori",
"help_on_links": "Pomoć na linkovima",
"notes_to_clone": "Beleške za kloniranje",
"target_parent_note": "Ciljna nadređena beleška",
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
"prefix_optional": "Prefiks (opciono)",
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
"no_path_to_clone_to": "Nema putanje za kloniranje.",
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
},
"confirm": {
"confirmation": "Potvrda",
"close": "Zatvori",
"cancel": "Otkaži",
"ok": "U redu",
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
},
"export": {
"export_note_title": "Izvezi belešku",
"close": "Zatvori",
"export_type_subtree": "Ova beleška i svi njeni potomci",
"format_html": "HTML - preporučuje se jer čuva formatiranje",
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
"opml_version_1": "OPML v1.0 - samo običan tekst",
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
"export_type_single": "Samo ovu belešku bez njenih potomaka",
"export": "Izvoz",
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
"export_status": "Status izvoza",
"export_in_progress": "Izvoz u toku: {{progressCount}}",
"export_finished_successfully": "Izvoz je uspešno završen.",
"format_pdf": "PDF - za namene štampanja ili deljenja."
},
"help": {
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
"close": "Zatvori",
"noteNavigation": "Navigacija beleški",
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
"notSet": "nije podešeno",
"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": "idi do nadređene beleške",
"collapseWholeTree": "sakupi celo drvo beleški",
"collapseSubTree": "sakupi pod-drvo",
"tabShortcuts": "Prečice na karticama",
"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",
"activateNextTab": "aktiviraj narednu karticu",
"activatePreviousTab": "aktiviraj prethodnu karticu",
"creatingNotes": "Pravljenje beleški",
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
"movingCloningNotes": "Premeštanje / kloniranje beleški",
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
"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": "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": "napravi / izmeni spoljašnji link",
"createInternalLink": "napravi unutrašnji link",
"followLink": "prati link ispod kursora",
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
"troubleshooting": "Rešavanje problema",
"reloadFrontend": "ponovo učitaj Trilium frontend",
"showDevTools": "prikaži alate za programere",
"showSQLConsole": "prikaži SQL konzolu",
"other": "Ostalo",
"quickSearch": "fokus na unos za brzu pretragu",
"inPageSearch": "pretraga unutar stranice"
},
"import": {
"importIntoNote": "Uvezi u belešku",
"close": "Zatvori",
"chooseImportFile": "Izaberi datoteku za uvoz",
"importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u",
"options": "Opcije",
"safeImportTooltip": "Trilium <code>.zip</code> izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.",
"safeImport": "Bezbedan uvoz",
"explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati <code>.zip</code>, <code>.enex</code> i <code>.opml</code> datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.",
"explodeArchives": "Pročitaj sadržaj <code>.zip</code>, <code>.enex</code> i <code>.opml</code> arhiva.",
"shrinkImagesTooltip": "<p>Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.</p><p>Ovo se ne primenjuje na <code>.zip</code> uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.</p>",
"shrinkImages": "Smanji slike",
"textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka",
"codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput <code>.json</code>) ako beleške sa kodom ako nije jasno iz metapodataka",
"replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški",
"import": "Uvezi",
"failed": "Uvoz nije uspeo: {{message}}.",
"html_import_tags": {
"title": "HTML oznake za uvoz",
"description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.",
"placeholder": "Unesite HTML oznake, po jednu u svaki red",
"reset_button": "Vrati na podrazumevanu listu"
},
"branch_prefix": {
"edit_branch_prefix": "Izmeni prefiks grane",
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
"close": "Zatvori",
"prefix": "Prefiks: ",
"save": "Sačuvaj",
"branch_prefix_saved": "Prefiks grane je sačuvan."
},
"bulk_actions": {
"bulk_actions": "Grupne akcije",
"close": "Zatvori",
"affected_notes": "Pogođene beleške",
"include_descendants": "Obuhvati potomke izabranih beleški",
"available_actions": "Dostupne akcije",
"chosen_actions": "Izabrane akcije",
"execute_bulk_actions": "Izvrši grupne akcije",
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
"labels": "Oznake",
"relations": "Odnosi",
"notes": "Beleške",
"other": "Ostalo"
},
"clone_to": {
"clone_notes_to": "Klonirajte beleške u...",
"close": "Zatvori",
"help_on_links": "Pomoć na linkovima",
"notes_to_clone": "Beleške za kloniranje",
"target_parent_note": "Ciljna nadređena beleška",
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
"prefix_optional": "Prefiks (opciono)",
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
"no_path_to_clone_to": "Nema putanje za kloniranje.",
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
},
"confirm": {
"confirmation": "Potvrda",
"close": "Zatvori",
"cancel": "Otkaži",
"ok": "U redu",
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
},
"export": {
"export_note_title": "Izvezi belešku",
"close": "Zatvori",
"export_type_subtree": "Ova beleška i svi njeni potomci",
"format_html": "HTML - preporučuje se jer čuva formatiranje",
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
"opml_version_1": "OPML v1.0 - samo običan tekst",
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
"export_type_single": "Samo ovu belešku bez njenih potomaka",
"export": "Izvoz",
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
"export_status": "Status izvoza",
"export_in_progress": "Izvoz u toku: {{progressCount}}",
"export_finished_successfully": "Izvoz je uspešno završen.",
"format_pdf": "PDF - za namene štampanja ili deljenja."
},
"help": {
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
"close": "Zatvori",
"noteNavigation": "Navigacija beleški",
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
"notSet": "nije podešeno",
"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",
"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",
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
"openEmptyTab": "otvori praznu karticu",
"closeActiveTab": "zatvori aktivnu karticu",
"activateNextTab": "aktiviraj narednu karticu",
"activatePreviousTab": "aktiviraj prethodnu karticu",
"creatingNotes": "Pravljenje beleški",
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
"movingCloningNotes": "Premeštanje / kloniranje beleški",
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
"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",
"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",
"createInternalLink": "napravi unutrašnji link",
"followLink": "prati link ispod kursora",
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
"troubleshooting": "Rešavanje problema",
"reloadFrontend": "ponovo učitaj Trilium frontend",
"showDevTools": "prikaži alate za programere",
"showSQLConsole": "prikaži SQL konzolu",
"other": "Ostalo",
"quickSearch": "fokus na unos za brzu pretragu",
"inPageSearch": "pretraga unutar stranice"
},
"import": {
"importIntoNote": "Uvezi u belešku",
"close": "Zatvori",
"chooseImportFile": "Izaberi datoteku za uvoz",
"importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u",
"options": "Opcije",
"safeImportTooltip": "Trilium <code>.zip</code> izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.",
"safeImport": "Bezbedan uvoz",
"explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati <code>.zip</code>, <code>.enex</code> i <code>.opml</code> datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.",
"explodeArchives": "Pročitaj sadržaj <code>.zip</code>, <code>.enex</code> i <code>.opml</code> arhiva.",
"shrinkImagesTooltip": "<p>Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.</p><p>Ovo se ne primenjuje na <code>.zip</code> uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.</p>",
"shrinkImages": "Smanji slike",
"textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka",
"codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput <code>.json</code>) ako beleške sa kodom ako nije jasno iz metapodataka",
"replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški",
"import": "Uvezi",
"failed": "Uvoz nije uspeo: {{message}}.",
"html_import_tags": {
"title": "HTML oznake za uvoz",
"description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.",
"placeholder": "Unesite HTML oznake, po jednu u svaki red",
"reset_button": "Vrati na podrazumevanu listu"
},
"import-status": "Status uvoza",
"in-progress": "Uvoz u toku: {{progress}}",
"successful": "Uvoz je uspešno završen."
},
"include_note": {
"dialog_title": "Uključi belešku",
"close": "Zatvori",
"label_note": "Beleška",
"placeholder_search": "pretraži belešku po njenom imenu",
"box_size_prompt": "Veličina kutije priložene beleške:",
"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>"
},
"info": {
"modalTitle": "Informativna poruka",
"closeButton": "Zatvori",
"okButton": "U redu"
},
"jump_to_note": {
"search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...",
"close": "Zatvori",
"search_button": "Pretraga u punom tekstu <kbd>Ctrl+Enter</kbd>"
},
"markdown_import": {
"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_success": "Markdown sadržaj je učitan u dokument."
},
"move_to": {
"dialog_title": "Premesti beleške u ...",
"close": "Zatvori",
"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>",
"error_no_path": "Nema putanje za premeštanje.",
"move_success_message": "Izabrane beleške su premeštene u "
},
"note_type_chooser": {
"change_path_prompt": "Promenite gde će se napraviti nova beleška:",
"search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)",
"modal_title": "Izaberite tip beleške",
"close": "Zatvori",
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
"templates": "Šabloni:"
},
"password_not_set": {
"title": "Lozinka nije podešena",
"close": "Zatvori",
"body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.",
"body2": "Za biste mogli da sačuvate beleške, kliknite <a class=\"open-password-options-button\" href=\"javascript:\">ovde</a> da otvorite dijalog sa Opcijama i podesite svoju lozinku."
},
"prompt": {
"title": "Upit",
"close": "Zatvori",
"ok": "U redu <kbd>enter</kbd>",
"defaultTitle": "Upit"
},
"protected_session_password": {
"modal_title": "Zaštićena sesija",
"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>"
},
"recent_changes": {
"title": "Nedavne promene",
"erase_notes_button": "Obriši izabrane beleške odmah",
"close": "Zatvori",
"deleted_notes_message": "Obrisane beleške su uklonjene.",
"no_changes_message": "Još uvek nema izmena...",
"undelete_link": "poništi brisanje",
"confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?"
},
"revisions": {
"note_revisions": "Revizije beleški",
"delete_all_revisions": "Obriši sve revizije ove beleške",
"delete_all_button": "Obriši sve revizije",
"help_title": "Pomoć za Revizije beleški",
"close": "Zatvori",
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
"no_revisions": "Još uvek nema revizija za ovu belešku...",
"restore_button": "Vrati",
"confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.",
"delete_button": "Obriši",
"confirm_delete": "Da li želite da obrišete ovu reviziju?",
"revisions_deleted": "Revizije beleške su obrisane.",
"revision_restored": "Revizija beleške je vraćena.",
"revision_deleted": "Revizija beleške je obrisana.",
"snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.",
"maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.",
"settings": "Podešavanja revizija beleški",
"download_button": "Preuzmi",
"mime": "MIME: ",
"file_size": "Veličina datoteke:",
"preview": "Pregled:",
"preview_not_available": "Pregled nije dostupan za ovaj tip beleške."
},
"sort_child_notes": {
"sort_children_by": "Sortiranje podbeleški po...",
"close": "Zatvori",
"sorting_criteria": "Kriterijum za sortiranje",
"title": "naslov",
"date_created": "datum kreiranja",
"date_modified": "datum izmene",
"sorting_direction": "Smer sortiranja",
"ascending": "uzlazni",
"descending": "silazni",
"folders": "Fascikle",
"sort_folders_at_top": "sortiraj fascikle na vrh",
"natural_sort": "Prirodno sortiranje",
"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>"
},
"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",
"options": "Opcije",
"shrink_images": "Smanji slike",
"upload": "Otpremi",
"tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena."
},
"attribute_detail": {
"attr_detail_title": "Naslov detalja atributa",
"close_button_title": "Otkaži izmene i zatvori",
"attr_is_owned_by": "Atribut je u vlasništvu",
"attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte",
"name": "Naziv",
"value": "Vrednost",
"target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.",
"target_note": "Ciljna beleška",
"promoted_title": "Promovisani atribut je istaknut na belešci.",
"promoted": "Promovisan",
"promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.",
"promoted_alias": "Pseudonim",
"multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.",
"multiplicity": "Multiplicitet",
"single_value": "Jednostruka vrednost",
"multi_value": "Višestruka vrednost",
"label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.",
"label_type": "Tip",
"text": "Tekst",
"number": "Broj",
"boolean": "Boolean",
"date": "Datum",
"date_time": "Datum i vreme",
"time": "Vreme",
"url": "URL",
"precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.",
"precision": "Preciznost",
"digits": "cifre",
"inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.",
"inverse_relation": "Inverzna relacija",
"inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.",
"inheritable": "Nasledno",
"save_and_close": "Sačuvaj i zatvori <kbd>Ctrl+Enter</kbd>",
"delete": "Obriši",
"related_notes_title": "Druge beleške sa ovom oznakom",
"more_notes": "Još beleški",
"label": "Detalji oznake",
"label_definition": "Detalji definicije oznake",
"relation": "Detalji relacije",
"relation_definition": "Detalji definicije relacije",
"disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte",
"calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.",
"archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).",
"exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški",
"run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n<ul>\n<li>frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.</li>\n<li>mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..</li>\n<li>backendStartup - kada se Trilium backend pokrene</li>\n<li>hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka <code>runAtHour</code> da se označi u kom satu.</li>\n<li>daily - pokreće se jednom dnevno</li>\n</ul>",
"run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.",
"run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa <code>#run=hourly</code>. Može biti definisano više puta za više pokretanja u toku dana.",
"disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.",
"sorted": "čuva podbeleške sortirane alfabetski po naslovu",
"sort_direction": "Uzlazno (podrazumevano) ili silazno",
"sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu",
"top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)",
"hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci",
"import-status": "Status uvoza",
"in-progress": "Uvoz u toku: {{progress}}",
"successful": "Uvoz je uspešno završen."
},
"include_note": {
"dialog_title": "Uključi belešku",
"close": "Zatvori",
"label_note": "Beleška",
"placeholder_search": "pretraži belešku po njenom imenu",
"box_size_prompt": "Veličina kutije priložene beleške:",
"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"
},
"info": {
"modalTitle": "Informativna poruka",
"closeButton": "Zatvori",
"okButton": "U redu"
},
"jump_to_note": {
"search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...",
"close": "Zatvori",
"search_button": "Pretraga u punom tekstu <kbd>Ctrl+Enter</kbd>"
},
"markdown_import": {
"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",
"import_success": "Markdown sadržaj je učitan u dokument."
},
"move_to": {
"dialog_title": "Premesti beleške u ...",
"close": "Zatvori",
"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",
"error_no_path": "Nema putanje za premeštanje.",
"move_success_message": "Izabrane beleške su premeštene u "
},
"note_type_chooser": {
"change_path_prompt": "Promenite gde će se napraviti nova beleška:",
"search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)",
"modal_title": "Izaberite tip beleške",
"close": "Zatvori",
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
"templates": "Šabloni"
},
"password_not_set": {
"title": "Lozinka nije podešena",
"close": "Zatvori",
"body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.",
"body2": "Za biste mogli da sačuvate beleške, kliknite <a class=\"open-password-options-button\" href=\"javascript:\">ovde</a> da otvorite dijalog sa Opcijama i podesite svoju lozinku."
},
"prompt": {
"title": "Upit",
"close": "Zatvori",
"ok": "U redu <kbd>enter</kbd>",
"defaultTitle": "Upit"
},
"protected_session_password": {
"modal_title": "Zaštićena sesija",
"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"
},
"recent_changes": {
"title": "Nedavne promene",
"erase_notes_button": "Obriši izabrane beleške odmah",
"close": "Zatvori",
"deleted_notes_message": "Obrisane beleške su uklonjene.",
"no_changes_message": "Još uvek nema izmena...",
"undelete_link": "poništi brisanje",
"confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?"
},
"revisions": {
"note_revisions": "Revizije beleški",
"delete_all_revisions": "Obriši sve revizije ove beleške",
"delete_all_button": "Obriši sve revizije",
"help_title": "Pomoć za Revizije beleški",
"close": "Zatvori",
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
"no_revisions": "Još uvek nema revizija za ovu belešku...",
"restore_button": "Vrati",
"confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.",
"delete_button": "Obriši",
"confirm_delete": "Da li želite da obrišete ovu reviziju?",
"revisions_deleted": "Revizije beleške su obrisane.",
"revision_restored": "Revizija beleške je vraćena.",
"revision_deleted": "Revizija beleške je obrisana.",
"snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.",
"maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.",
"settings": "Podešavanja revizija beleški",
"download_button": "Preuzmi",
"mime": "MIME: ",
"file_size": "Veličina datoteke:",
"preview": "Pregled:",
"preview_not_available": "Pregled nije dostupan za ovaj tip beleške."
},
"sort_child_notes": {
"sort_children_by": "Sortiranje podbeleški po...",
"close": "Zatvori",
"sorting_criteria": "Kriterijum za sortiranje",
"title": "naslov",
"date_created": "datum kreiranja",
"date_modified": "datum izmene",
"sorting_direction": "Smer sortiranja",
"ascending": "uzlazni",
"descending": "silazni",
"folders": "Fascikle",
"sort_folders_at_top": "sortiraj fascikle na vrh",
"natural_sort": "Prirodno sortiranje",
"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"
},
"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 {{noteTitle}}",
"options": "Opcije",
"shrink_images": "Smanji slike",
"upload": "Otpremi",
"tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena."
},
"attribute_detail": {
"attr_detail_title": "Naslov detalja atributa",
"close_button_title": "Otkaži izmene i zatvori",
"attr_is_owned_by": "Atribut je u vlasništvu",
"attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte",
"name": "Naziv",
"value": "Vrednost",
"target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.",
"target_note": "Ciljna beleška",
"promoted_title": "Promovisani atribut je istaknut na belešci.",
"promoted": "Promovisan",
"promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.",
"promoted_alias": "Pseudonim",
"multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.",
"multiplicity": "Multiplicitet",
"single_value": "Jednostruka vrednost",
"multi_value": "Višestruka vrednost",
"label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.",
"label_type": "Tip",
"text": "Tekst",
"number": "Broj",
"boolean": "Boolean",
"date": "Datum",
"date_time": "Datum i vreme",
"time": "Vreme",
"url": "URL",
"precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.",
"precision": "Preciznost",
"digits": "cifre",
"inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.",
"inverse_relation": "Inverzna relacija",
"inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.",
"inheritable": "Nasledno",
"save_and_close": "Sačuvaj i zatvori <kbd>Ctrl+Enter</kbd>",
"delete": "Obriši",
"related_notes_title": "Druge beleške sa ovom oznakom",
"more_notes": "Još beleški",
"label": "Detalji oznake",
"label_definition": "Detalji definicije oznake",
"relation": "Detalji relacije",
"relation_definition": "Detalji definicije relacije",
"disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte",
"calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.",
"archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).",
"exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški",
"run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n<ul>\n<li>frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.</li>\n<li>mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..</li>\n<li>backendStartup - kada se Trilium backend pokrene</li>\n<li>hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka <code>runAtHour</code> da se označi u kom satu.</li>\n<li>daily - pokreće se jednom dnevno</li>\n</ul>",
"run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.",
"run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa <code>#run=hourly</code>. Može biti definisano više puta za više pokretanja u toku dana.",
"disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.",
"sorted": "čuva podbeleške sortirane alfabetski po naslovu",
"sort_direction": "Uzlazno (podrazumevano) ili silazno",
"sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu",
"top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)",
"hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci",
"read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.",
"auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku",
"app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.",
@@ -513,5 +513,5 @@
"delete_matched_notes": "Obriši podudarne beleške",
"delete_matched_notes_description": "Ovo će obrisati podudarne beleške.",
"undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene."
}
}
}

File diff suppressed because it is too large Load Diff

View 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>
);
});

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View 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} />
}
}

View File

@@ -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;
}
}

View 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");
}
}

View File

@@ -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;
}
}

View 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} />
}
}

View File

@@ -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;
}
}

View 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} />
}
}

View File

@@ -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;
}
}

View File

@@ -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} />;
}
}

View File

@@ -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);
}
}

View 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} />
}
}

View File

@@ -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);
}
}

View File

@@ -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} />
}
}

View File

@@ -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;
}
}

View 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} />
}
}

View File

@@ -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;
}
}

View 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} />
}
}

View File

@@ -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;
}
}

View File

@@ -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} />
}
}

View File

@@ -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;
}
}

View File

@@ -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} />
}
}

View File

@@ -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;
}
}

View File

@@ -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} />
}
}

View File

@@ -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;
}
}

View File

@@ -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} />
}
}

View File

@@ -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,59 +8,64 @@ 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) ? (
<table className="table table-borderless">
<tbody>
<tr>
<th>{t("about.homepage")}</th>
<td><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
</tr>
<tr>
<th>{t("about.app_version")}</th>
<td className="app-version">{appInfo.appVersion}</td>
</tr>
<tr>
<th>{t("about.db_version")}</th>
<td className="db-version">{appInfo.dbVersion}</td>
</tr>
<tr>
<th>{t("about.sync_version")}</th>
<td className="sync-version">{appInfo.syncVersion}</td>
</tr>
<tr>
<th>{t("about.build_date")}</th>
<td className="build-date">{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>
</td>
</tr>
<tr>
<th>{t("about.data_directory")}</th>
<td className="data-directory">
<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />
</td>
</tr>
</tbody>
</table>
) : (
<div className="loading-spinner"></div>
)}
<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>
<th>{t("about.homepage")}</th>
<td><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
</tr>
<tr>
<th>{t("about.app_version")}</th>
<td className="app-version">{appInfo?.appVersion}</td>
</tr>
<tr>
<th>{t("about.db_version")}</th>
<td className="db-version">{appInfo?.dbVersion}</td>
</tr>
<tr>
<th>{t("about.sync_version")}</th>
<td className="sync-version">{appInfo?.syncVersion}</td>
</tr>
<tr>
<th>{t("about.build_date")}</th>
<td className="build-date">
{appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""}
</td>
</tr>
<tr>
<th>{t("about.build_revision")}</th>
<td>
{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">
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
</td>
</tr>
</tbody>
</table>
</Modal>
);
}
@@ -85,7 +89,4 @@ export default class AboutDialog extends ReactBasicWidget {
return <AboutDialogComponent />;
}
async openAboutDialogEvent() {
openDialog(this.$widget);
}
}

View File

@@ -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");
}
}

View 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 />;
}
}

View File

@@ -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> &nbsp;
<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"));
}
}

View 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"));
}

View 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;
}

View File

@@ -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);
}
}

View 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 />
}
}

View File

@@ -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 }));
}
}
}

View 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 }));
}
}

View File

@@ -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>
&nbsp;
<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();
}
}

View 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 />;
}
}

View File

@@ -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>
&nbsp;
<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");
}
}

View 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 />;
}
}

View 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;
}

View File

@@ -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);
}
});

View 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);
}
});

View File

@@ -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);
}
}

View 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 />;
}
}

View File

@@ -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);
}
}

View 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";
}

View File

@@ -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);
}
}
}

View 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);
}
}

View File

@@ -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();
}
}

View 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 />
}
}

View File

@@ -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;
}
}

View 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 />;
}
}

View File

@@ -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();
}
}

View 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 />;
}
}

View File

@@ -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("");
}
}

View 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"));
}

View File

@@ -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}`);
}
}

View 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}`);
}

View File

@@ -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();
}
}

View 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 />
}
}

View File

@@ -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);
}
}

View 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 />;
}
}

View File

@@ -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);
}
}

View 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 />;
}
}

View File

@@ -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();
}
}

View File

@@ -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 />;
}
}

View File

@@ -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;
}
}

View 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>
&nbsp;
(<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;
}

View File

@@ -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(" &nbsp; ");
}
this.$titleButtons.append($eraseRevisionButton).append(" &nbsp; ");
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"));
}
}
}

View 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"));
}
}}/>
&nbsp;
<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?.();
}
}} />
&nbsp;
<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();
}
}

View File

@@ -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();
}
}

View 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 />;
}
}

View File

@@ -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);
}
}

View 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 />;
}
}

View 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}
/>;
}

View 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>
);
}

View 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>
}

View 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;

View 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>
)
}

View 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;

View 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>
)
}

View 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>
);
}

View 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} />&nbsp;
{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