chore(monorepo/client): move client source files

This commit is contained in:
Elian Doran
2025-04-22 22:12:56 +03:00
parent 23572bd47c
commit 9afe2ef761
372 changed files with 1 additions and 4 deletions

View File

@@ -0,0 +1,116 @@
import { formatDateTime } from "../../utils/formatters.js";
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import openService from "../../services/open.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
interface AppInfo {
appVersion: string;
dbVersion: number;
syncVersion: number;
buildDate: string;
buildRevision: string;
dataDirectory: string;
}
const TPL = /*html*/`
<div class="about-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("about.title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("about.close")}"></button>
</div>
<div class="modal-body">
<table class="table table-borderless">
<tr>
<th>${t("about.homepage")}</th>
<td><a class="tn-link" href="https://github.com/TriliumNext/Notes" class="external">https://github.com/TriliumNext/Notes</a></td>
</tr>
<tr>
<th>${t("about.app_version")}</th>
<td class="app-version"></td>
</tr>
<tr>
<th>${t("about.db_version")}</th>
<td class="db-version"></td>
</tr>
<tr>
<th>${t("about.sync_version")}</th>
<td class="sync-version"></td>
</tr>
<tr>
<th>${t("about.build_date")}</th>
<td class="build-date"></td>
</tr>
<tr>
<th>${t("about.build_revision")}</th>
<td><a class="tn-link build-revision external" href="" target="_blank"></a></td>
</tr>
<tr>
<th>${t("about.data_directory")}</th>
<td class="data-directory"></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<style>
.about-dialog a {
word-break: break-all;
}
</style>
`;
export default class AboutDialog extends BasicWidget {
private $appVersion!: JQuery<HTMLElement>;
private $dbVersion!: JQuery<HTMLElement>;
private $syncVersion!: JQuery<HTMLElement>;
private $buildDate!: JQuery<HTMLElement>;
private $buildRevision!: JQuery<HTMLElement>;
private $dataDirectory!: JQuery<HTMLElement>;
doRender(): void {
this.$widget = $(TPL);
this.$appVersion = this.$widget.find(".app-version");
this.$dbVersion = this.$widget.find(".db-version");
this.$syncVersion = this.$widget.find(".sync-version");
this.$buildDate = this.$widget.find(".build-date");
this.$buildRevision = this.$widget.find(".build-revision");
this.$dataDirectory = this.$widget.find(".data-directory");
}
async refresh() {
const appInfo = await server.get<AppInfo>("app-info");
this.$appVersion.text(appInfo.appVersion);
this.$dbVersion.text(appInfo.dbVersion.toString());
this.$syncVersion.text(appInfo.syncVersion.toString());
this.$buildDate.text(formatDateTime(appInfo.buildDate));
this.$buildRevision.text(appInfo.buildRevision);
this.$buildRevision.attr("href", `https://github.com/TriliumNext/Notes/commit/${appInfo.buildRevision}`);
if (utils.isElectron()) {
this.$dataDirectory.html(
$("<a></a>", {
href: "#",
class: "tn-link",
text: appInfo.dataDirectory
}).prop("outerHTML")
);
this.$dataDirectory.find("a").on("click", (event: JQuery.ClickEvent) => {
event.preventDefault();
openService.openDirectory(appInfo.dataDirectory);
});
} else {
this.$dataDirectory.text(appInfo.dataDirectory);
}
}
async openAboutDialogEvent() {
await this.refresh();
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,188 @@
import { t } from "../../services/i18n.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.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";
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();
utils.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,108 @@
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 utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import appContext from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import { Modal } from "bootstrap";
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);
utils.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,175 @@
import BasicWidget from "../basic_widget.js";
import froca from "../../services/froca.js";
import bulkActionService from "../../services/bulk_action.js";
import utils from "../../services/utils.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";
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);
utils.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();
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,140 @@
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.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";
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);
}
}
utils.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,151 @@
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,198 @@
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import type { FAttributeRow } from "../../entities/fattribute.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", () => {
utils.closeActiveDialog();
this.resolve({ proceed: false });
});
this.$okButton.on("click", () => {
utils.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();
utils.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,263 @@
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";
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
utils.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,159 @@
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.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>
</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() {
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,179 @@
import utils, { 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";
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));
utils.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,116 @@
import { t } from "../../services/i18n.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.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";
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();
utils.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,79 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
import type { ConfirmDialogCallback } from "./confirm.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);
}
utils.openDialog(this.$widget);
this.resolve = callback;
}
}

View File

@@ -0,0 +1,132 @@
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";
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 $showInFullTextButton!: JQuery<HTMLElement>;
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.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
this.$showInFullTextButton.on("click", (e) => this.showInFullText(e));
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
}
async jumpToNoteEvent() {
const dialogPromise = utils.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();
this.lastOpenedTs = Date.now();
}
async refresh() {
noteAutocompleteService
.initNoteAutocomplete(this.$autoComplete, {
allowCreatingNotes: true,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true,
container: this.$results[0]
})
// clear any event listener added in previous invocation of this function
.off("autocomplete:noteselected")
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
if (!suggestion.notePath) {
return false;
}
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
});
// 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) {
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");
}
}
showInFullText(e: JQuery.TriggeredEvent) {
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
e.preventDefault();
e.stopPropagation();
const searchString = String(this.$autoComplete.val());
this.triggerCommand("searchNotes", { searchString });
this.modal.hide();
}
}

View File

@@ -0,0 +1,104 @@
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";
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);
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 {
utils.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,120 @@
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.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";
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;
utils.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,166 @@
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 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 .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.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"></div>
</div>
</div>
</div>
</div>
</div>`;
export interface ChooseNoteTypeResponse {
success: boolean;
noteType?: string;
templateNoteId?: 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 $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.$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();
}
});
}
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
this.$originalFocused = $(":focus");
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>;
this.$noteTypeDropdown.append(
$('<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}`)
);
}
}
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");
if (this.resolve) {
this.resolve({
success: true,
noteType,
templateNoteId
});
}
this.resolve = null;
this.modal.hide();
}
}

View File

@@ -0,0 +1,42 @@
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.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() {
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,115 @@
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.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));
utils.openDialog(this.$widget, false);
}
}

View File

@@ -0,0 +1,60 @@
import { t } from "../../services/i18n.js";
import protectedSessionService from "../../services/protected_session.js";
import utils from "../../services/utils.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() {
utils.openDialog(this.$widget);
this.$passwordInput.trigger("focus");
}
closeProtectedSessionPasswordDialogEvent() {
this.modal.hide();
}
}

View File

@@ -0,0 +1,178 @@
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 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 utils from "../../services/utils.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();
utils.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,382 @@
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 libraryLoader from "../../services/library_loader.js";
import openService from "../../services/open.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import BasicWidget from "../basic_widget.js";
import dialogService 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";
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;
}
utils.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) {
await libraryLoader.requireLibrary(libraryLoader.KATEX);
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,111 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import utils from "../../services/utils.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 () => {
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 });
utils.closeActiveDialog();
});
}
async sortChildNotesEvent({ node }: EventData<"sortChildNotes">) {
this.parentNoteId = node.data.noteId;
utils.openDialog(this.$widget);
this.$form.find("input:first").focus();
}
}

View File

@@ -0,0 +1,119 @@
import { t } from "../../services/i18n.js";
import utils, { 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";
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));
utils.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);
}
}