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,129 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import NoteTypeWidget from "../note_type.js";
import ProtectedNoteSwitchWidget from "../protected_note_switch.js";
import EditabilitySelectWidget from "../editability_select.js";
import BookmarkSwitchWidget from "../bookmark_switch.js";
import SharedSwitchWidget from "../shared_switch.js";
import { t } from "../../services/i18n.js";
import TemplateSwitchWidget from "../template_switch.js";
import type FNote from "../../entities/fnote.js";
import NoteLanguageWidget from "../note_language.js";
const TPL = /*html*/`
<div class="basic-properties-widget">
<style>
.basic-properties-widget {
padding: 0px 12px 6px 12px;
display: flex;
align-items: baseline;
flex-wrap: wrap;
}
.basic-properties-widget > * {
margin-top: 9px;
margin-bottom: 2px;
}
.basic-properties-widget > * > :last-child {
margin-right: 30px;
}
.note-type-container,
.editability-select-container,
.note-language-container {
display: flex;
align-items: center;
}
</style>
<div class="note-type-container">
<span>${t("basic_properties.note_type")}:</span> &nbsp;
</div>
<div class="protected-note-switch-container"></div>
<div class="editability-select-container">
<span>${t("basic_properties.editable")}:</span> &nbsp;
</div>
<div class="bookmark-switch-container"></div>
<div class="shared-switch-container"></div>
<div class="template-switch-container"></div>
<div class="note-language-container">
<span>${t("basic_properties.language")}:</span> &nbsp;
</div>
</div>`;
export default class BasicPropertiesWidget extends NoteContextAwareWidget {
private noteTypeWidget: NoteTypeWidget;
private protectedNoteSwitchWidget: ProtectedNoteSwitchWidget;
private editabilitySelectWidget: EditabilitySelectWidget;
private bookmarkSwitchWidget: BookmarkSwitchWidget;
private sharedSwitchWidget: SharedSwitchWidget;
private templateSwitchWidget: TemplateSwitchWidget;
private noteLanguageWidget: NoteLanguageWidget;
constructor() {
super();
this.noteTypeWidget = new NoteTypeWidget().contentSized();
this.protectedNoteSwitchWidget = new ProtectedNoteSwitchWidget().contentSized();
this.editabilitySelectWidget = new EditabilitySelectWidget().contentSized();
this.bookmarkSwitchWidget = new BookmarkSwitchWidget().contentSized();
this.sharedSwitchWidget = new SharedSwitchWidget().contentSized();
this.templateSwitchWidget = new TemplateSwitchWidget().contentSized();
this.noteLanguageWidget = new NoteLanguageWidget().contentSized();
this.child(
this.noteTypeWidget,
this.protectedNoteSwitchWidget,
this.editabilitySelectWidget,
this.bookmarkSwitchWidget,
this.sharedSwitchWidget,
this.templateSwitchWidget,
this.noteLanguageWidget);
}
get name() {
return "basicProperties";
}
get toggleCommand() {
return "toggleRibbonBasicProperties";
}
getTitle() {
return {
show: !this.note?.isLaunchBarConfig(),
title: t("basic_properties.basic_properties"),
icon: "bx bx-slider"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$widget.find(".note-type-container").append(this.noteTypeWidget.render());
this.$widget.find(".protected-note-switch-container").append(this.protectedNoteSwitchWidget.render());
this.$widget.find(".editability-select-container").append(this.editabilitySelectWidget.render());
this.$widget.find(".bookmark-switch-container").append(this.bookmarkSwitchWidget.render());
this.$widget.find(".shared-switch-container").append(this.sharedSwitchWidget.render());
this.$widget.find(".template-switch-container").append(this.templateSwitchWidget.render());
this.$widget.find(".note-language-container").append(this.noteLanguageWidget.render());
}
async refreshWithNote(note: FNote) {
await super.refreshWithNote(note);
if (!this.note) {
return;
}
this.$widget.find(".editability-select-container").toggle(this.note && ["text", "code", "mermaid"].includes(this.note.type));
this.$widget.find(".note-language-container").toggle(this.note && ["text"].includes(this.note.type));
}
}

View File

@@ -0,0 +1,141 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="book-properties-widget">
<style>
.book-properties-widget {
padding: 12px 12px 6px 12px;
display: flex;
}
.book-properties-widget > * {
margin-right: 15px;
}
</style>
<div style="display: flex; align-items: baseline">
<span style="white-space: nowrap">${t("book_properties.view_type")}:&nbsp; &nbsp;</span>
<select class="view-type-select form-select form-select-sm">
<option value="grid">${t("book_properties.grid")}</option>
<option value="list">${t("book_properties.list")}</option>
<option value="calendar">${t("book_properties.calendar")}</option>
</select>
</div>
<button type="button"
class="collapse-all-button btn btn-sm"
title="${t("book_properties.collapse_all_notes")}">
<span class="bx bx-layer-minus"></span>
${t("book_properties.collapse")}
</button>
<button type="button"
class="expand-children-button btn btn-sm"
title="${t("book_properties.expand_all_children")}">
<span class="bx bx-move-vertical"></span>
${t("book_properties.expand")}
</button>
</div>
`;
export default class BookPropertiesWidget extends NoteContextAwareWidget {
private $viewTypeSelect!: JQuery<HTMLElement>;
private $expandChildrenButton!: JQuery<HTMLElement>;
private $collapseAllButton!: JQuery<HTMLElement>;
get name() {
return "bookProperties";
}
get toggleCommand() {
return "toggleRibbonTabBookProperties";
}
isEnabled() {
return this.note && this.note.type === "book";
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("book_properties.book_properties"),
icon: "bx bx-book"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$viewTypeSelect = this.$widget.find(".view-type-select");
this.$viewTypeSelect.on("change", () => this.toggleViewType(String(this.$viewTypeSelect.val())));
this.$expandChildrenButton = this.$widget.find(".expand-children-button");
this.$expandChildrenButton.on("click", async () => {
if (!this.noteId || !this.note) {
return;
}
if (!this.note?.isLabelTruthy("expanded")) {
await attributeService.addLabel(this.noteId, "expanded");
}
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
});
this.$collapseAllButton = this.$widget.find(".collapse-all-button");
this.$collapseAllButton.on("click", async () => {
if (!this.noteId || !this.note) {
return;
}
// owned is important - we shouldn't remove inherited expanded labels
for (const expandedAttr of this.note.getOwnedLabels("expanded")) {
await attributeService.removeAttributeById(this.noteId, expandedAttr.attributeId);
}
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
});
}
async refreshWithNote(note: FNote) {
if (!this.note) {
return;
}
const viewType = this.note.getLabelValue("viewType") || "grid";
this.$viewTypeSelect.val(viewType);
this.$expandChildrenButton.toggle(viewType === "list");
this.$collapseAllButton.toggle(viewType === "list");
}
async toggleViewType(type: string) {
if (!this.noteId) {
return;
}
if (!["list", "grid", "calendar"].includes(type)) {
throw new Error(t("book_properties.invalid_view_type", { type }));
}
await attributeService.setLabel(this.noteId, "viewType", type);
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,76 @@
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import utils from "../../services/utils.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="classic-toolbar-widget"></div>
<style>
.classic-toolbar-widget {
--ck-color-toolbar-background: transparent;
--ck-color-button-default-background: transparent;
--ck-color-button-default-disabled-background: transparent;
min-height: 39px;
}
.classic-toolbar-widget .ck.ck-toolbar {
border: none;
}
.classic-toolbar-widget .ck.ck-button.ck-disabled {
opacity: 0.3;
}
</style>
`;
/**
* Handles the editing toolbar when the CKEditor is in decoupled mode.
*
* <p>
* This toolbar is only enabled if the user has selected the classic CKEditor.
*
* <p>
* The ribbon item is active by default for text notes, as long as they are not in read-only mode.
*/
export default class ClassicEditorToolbar extends NoteContextAwareWidget {
get name() {
return "classicEditor";
}
get toggleCommand() {
return "toggleRibbonTabClassicEditor";
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
}
async getTitle() {
return {
show: await this.#shouldDisplay(),
activate: true,
title: t("classic_editor_toolbar.title"),
icon: "bx bx-text"
};
}
async #shouldDisplay() {
if (options.get("textNoteEditorType") !== "ckeditor-classic") {
return false;
}
if (!this.note || this.note.type !== "text") {
return false;
}
if (await this.noteContext?.isReadOnly()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,99 @@
import linkService from "../../services/link.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import options from "../../services/options.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="edited-notes-widget">
<style>
.edited-notes-widget {
padding: 12px;
max-height: 200px;
width: 100%;
overflow: auto;
}
</style>
<div class="no-edited-notes-found">${t("edited_notes.no_edited_notes_found")}</div>
<div class="edited-notes-list"></div>
</div>
`;
// TODO: Deduplicate with server.
interface EditedNotesResponse {
noteId: string;
isDeleted: boolean;
title: string;
notePath: string[];
}
export default class EditedNotesWidget extends NoteContextAwareWidget {
private $list!: JQuery<HTMLElement>;
private $noneFound!: JQuery<HTMLElement>;
get name() {
return "editedNotes";
}
isEnabled() {
return super.isEnabled() && this.note?.hasOwnedLabel("dateNote");
}
getTitle() {
return {
show: this.isEnabled(),
// promoted attributes have priority over edited notes
activate: (this.note?.getPromotedDefinitionAttributes().length === 0 || !options.is("promotedAttributesOpenInRibbon")) && options.is("editedNotesOpenInRibbon"),
title: t("edited_notes.title"),
icon: "bx bx-calendar-edit"
};
}
async doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$list = this.$widget.find(".edited-notes-list");
this.$noneFound = this.$widget.find(".no-edited-notes-found");
}
async refreshWithNote(note: FNote) {
let editedNotes = await server.get<EditedNotesResponse[]>(`edited-notes/${note.getLabelValue("dateNote")}`);
editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId);
this.$list.empty();
this.$noneFound.hide();
if (editedNotes.length === 0) {
this.$noneFound.show();
return;
}
const noteIds = editedNotes.flatMap((n) => n.noteId);
await froca.getNotes(noteIds, true); // preload all at once
for (let i = 0; i < editedNotes.length; i++) {
const editedNote = editedNotes[i];
const $item = $('<span class="edited-note-line">');
if (editedNote.isDeleted) {
const title = `${editedNote.title} ${t("edited_notes.deleted")}`;
$item.append($("<i>").text(title).attr("title", title));
} else {
$item.append(editedNote.notePath ? await linkService.createLink(editedNote.notePath.join("/"), { showNotePath: true }) : $("<span>").text(editedNote.title));
}
if (i < editedNotes.length - 1) {
$item.append(", ");
}
this.$list.append($item);
}
}
}

View File

@@ -0,0 +1,156 @@
import server from "../../services/server.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import toastService from "../../services/toast.js";
import openService from "../../services/open.js";
import utils from "../../services/utils.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="file-properties-widget">
<style>
.file-table {
width: 100%;
margin-top: 10px;
}
.file-table th, .file-table td {
padding: 5px;
overflow-wrap: anywhere;
}
.file-buttons {
padding: 10px;
display: flex;
justify-content: space-evenly;
}
</style>
<table class="file-table">
<tr>
<th class="text-nowrap">${t("file_properties.note_id")}:</th>
<td class="file-note-id"></td>
<th class="text-nowrap">${t("file_properties.original_file_name")}:</th>
<td class="file-filename"></td>
</tr>
<tr>
<th class="text-nowrap">${t("file_properties.file_type")}:</th>
<td class="file-filetype"></td>
<th class="text-nowrap">${t("file_properties.file_size")}:</th>
<td class="file-filesize"></td>
</tr>
<tr>
<td colspan="4">
<div class="file-buttons">
<button class="file-download btn btn-sm btn-primary" type="button">
<span class="bx bx-download"></span>
${t("file_properties.download")}
</button>
<button class="file-open btn btn-sm btn-primary" type="button">
<span class="bx bx-link-external"></span>
${t("file_properties.open")}
</button>
<button class="file-upload-new-revision btn btn-sm btn-primary">
<span class="bx bx-folder-open"></span>
${t("file_properties.upload_new_revision")}
</button>
<input type="file" class="file-upload-new-revision-input" style="display: none">
</div>
</td>
</tr>
</table>
</div>`;
export default class FilePropertiesWidget extends NoteContextAwareWidget {
private $fileNoteId!: JQuery<HTMLElement>;
private $fileName!: JQuery<HTMLElement>;
private $fileType!: JQuery<HTMLElement>;
private $fileSize!: JQuery<HTMLElement>;
private $downloadButton!: JQuery<HTMLElement>;
private $openButton!: JQuery<HTMLElement>;
private $uploadNewRevisionButton!: JQuery<HTMLElement>;
private $uploadNewRevisionInput!: JQuery<HTMLFormElement>;
get name() {
return "fileProperties";
}
get toggleCommand() {
return "toggleRibbonTabFileProperties";
}
isEnabled() {
return this.note && this.note.type === "file";
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("file_properties.title"),
icon: "bx bx-file"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$fileNoteId = this.$widget.find(".file-note-id");
this.$fileName = this.$widget.find(".file-filename");
this.$fileType = this.$widget.find(".file-filetype");
this.$fileSize = this.$widget.find(".file-filesize");
this.$downloadButton = this.$widget.find(".file-download");
this.$openButton = this.$widget.find(".file-open");
this.$uploadNewRevisionButton = this.$widget.find(".file-upload-new-revision");
this.$uploadNewRevisionInput = this.$widget.find(".file-upload-new-revision-input");
this.$downloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId));
this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime));
this.$uploadNewRevisionButton.on("click", () => {
this.$uploadNewRevisionInput.trigger("click");
});
this.$uploadNewRevisionInput.on("change", async () => {
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
this.$uploadNewRevisionInput.val("");
const result = await server.upload(`notes/${this.noteId}/file`, fileToUpload);
if (result.uploaded) {
toastService.showMessage(t("file_properties.upload_success"));
this.refresh();
} else {
toastService.showError(t("file_properties.upload_failed"));
}
});
}
async refreshWithNote(note: FNote) {
this.$widget.show();
if (!this.note) {
return;
}
this.$fileNoteId.text(note.noteId);
this.$fileName.text(note.getLabelValue("originalFileName") || "?");
this.$fileType.text(note.mime);
const blob = await this.note.getBlob();
this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0));
// open doesn't work for protected notes since it works through a browser which isn't in protected session
this.$openButton.toggle(!note.isProtected);
this.$downloadButton.toggle(!note.isProtected || protectedSessionHolder.isProtectedSessionAvailable());
this.$uploadNewRevisionButton.toggle(!note.isProtected || protectedSessionHolder.isProtectedSessionAvailable());
}
}

View File

@@ -0,0 +1,136 @@
import server from "../../services/server.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import toastService from "../../services/toast.js";
import openService from "../../services/open.js";
import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="image-properties">
<div style="display: flex; justify-content: space-evenly; margin: 10px;">
<span>
<strong>${t("image_properties.original_file_name")}:</strong>
<span class="image-filename"></span>
</span>
<span>
<strong>${t("image_properties.file_type")}:</strong>
<span class="image-filetype"></span>
</span>
<span>
<strong>${t("image_properties.file_size")}:</strong>
<span class="image-filesize"></span>
</span>
</div>
<div class="no-print" style="display: flex; justify-content: space-evenly; margin: 10px;">
<button class="image-download btn btn-sm btn-primary" type="button">
<span class="bx bx-download"></span>
${t("image_properties.download")}
</button>
<button class="image-open btn btn-sm btn-primary" type="button">
<span class="bx bx-link-external"></span>
${t("image_properties.open")}
</button>
<button class="image-copy-reference-to-clipboard btn btn-sm btn-primary" type="button">
<span class="bx bx-copy"></span>
${t("image_properties.copy_reference_to_clipboard")}
</button>
<button class="image-upload-new-revision btn btn-sm btn-primary" type="button">
<span class="bx bx-folder-open"></span>
${t("image_properties.upload_new_revision")}
</button>
</div>
<input type="file" class="image-upload-new-revision-input" style="display: none">
</div>`;
export default class ImagePropertiesWidget extends NoteContextAwareWidget {
private $copyReferenceToClipboardButton!: JQuery<HTMLElement>;
private $uploadNewRevisionButton!: JQuery<HTMLElement>;
private $uploadNewRevisionInput!: JQuery<HTMLFormElement>;
private $fileName!: JQuery<HTMLElement>;
private $fileType!: JQuery<HTMLElement>;
private $fileSize!: JQuery<HTMLElement>;
private $openButton!: JQuery<HTMLElement>;
private $imageDownloadButton!: JQuery<HTMLElement>;
get name() {
return "imageProperties";
}
get toggleCommand() {
return "toggleRibbonTabImageProperties";
}
isEnabled() {
return this.note && this.note.type === "image";
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("image_properties.title"),
icon: "bx bx-image"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$copyReferenceToClipboardButton = this.$widget.find(".image-copy-reference-to-clipboard");
this.$copyReferenceToClipboardButton.on("click", () => this.triggerEvent(`copyImageReferenceToClipboard`, { ntxId: this.noteContext?.ntxId }));
this.$uploadNewRevisionButton = this.$widget.find(".image-upload-new-revision");
this.$uploadNewRevisionInput = this.$widget.find(".image-upload-new-revision-input");
this.$fileName = this.$widget.find(".image-filename");
this.$fileType = this.$widget.find(".image-filetype");
this.$fileSize = this.$widget.find(".image-filesize");
this.$openButton = this.$widget.find(".image-open");
this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime));
this.$imageDownloadButton = this.$widget.find(".image-download");
this.$imageDownloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId));
this.$uploadNewRevisionButton.on("click", () => {
this.$uploadNewRevisionInput.trigger("click");
});
this.$uploadNewRevisionInput.on("change", async () => {
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
this.$uploadNewRevisionInput.val("");
const result = await server.upload(`images/${this.noteId}`, fileToUpload);
if (result.uploaded) {
toastService.showMessage(t("image_properties.upload_success"));
await utils.clearBrowserCache();
this.refresh();
} else {
toastService.showError(t("image_properties.upload_failed", { message: result.message }));
}
});
}
async refreshWithNote(note: FNote) {
this.$widget.show();
const blob = await this.note?.getBlob();
this.$fileName.text(note.getLabelValue("originalFileName") || "?");
this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0));
this.$fileType.text(note.mime);
}
}

View File

@@ -0,0 +1,119 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
import attributeRenderer from "../../services/attribute_renderer.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="inherited-attributes-widget">
<style>
.inherited-attributes-widget {
position: relative;
}
.inherited-attributes-container {
color: var(--muted-text-color);
max-height: 200px;
overflow: auto;
padding: 14px 12px 13px 12px;
}
</style>
<div class="inherited-attributes-container"></div>
</div>`;
export default class InheritedAttributesWidget extends NoteContextAwareWidget {
private attributeDetailWidget: AttributeDetailWidget;
private $container!: JQuery<HTMLElement>;
get name() {
return "inheritedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabInheritedAttributes";
}
constructor() {
super();
this.attributeDetailWidget = new AttributeDetailWidget().contentSized().setParent(this);
this.child(this.attributeDetailWidget);
}
getTitle() {
return {
show: !this.note?.isLaunchBarConfig(),
title: t("inherited_attribute_list.title"),
icon: "bx bx-list-plus"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$container = this.$widget.find(".inherited-attributes-container");
this.$widget.append(this.attributeDetailWidget.render());
}
async refreshWithNote(note: FNote) {
this.$container.empty();
const inheritedAttributes = this.getInheritedAttributes(note);
if (inheritedAttributes.length === 0) {
this.$container.append(t("inherited_attribute_list.no_inherited_attributes"));
return;
}
for (const attribute of inheritedAttributes) {
const $attr = (await attributeRenderer.renderAttribute(attribute, false)).on("click", (e) => {
setTimeout(
() =>
this.attributeDetailWidget.showAttributeDetail({
attribute: {
noteId: attribute.noteId,
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable
},
isOwned: false,
x: e.pageX,
y: e.pageY
}),
100
);
});
this.$container.append($attr).append(" ");
}
}
getInheritedAttributes(note: FNote) {
const attrs = note.getAttributes().filter((attr) => attr.noteId !== this.noteId);
attrs.sort((a, b) => {
if (a.noteId === b.noteId) {
return a.position - b.position;
} else {
// inherited attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
return a.noteId < b.noteId ? -1 : 1;
}
});
return attrs;
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,130 @@
import { isIOS } from "../../services/utils.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="classic-toolbar-outer-container">
<div class="classic-toolbar-widget"></div>
</div>
<style>
.classic-toolbar-outer-container.visible {
height: 38px;
background-color: var(--main-background-color);
position: relative;
overflow: visible;
flex-shrink: 0;
}
#root-widget.virtual-keyboard-opened .classic-toolbar-outer-container.ios {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.classic-toolbar-widget {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 38px;
overflow: scroll;
display: flex;
align-items: flex-end;
user-select: none;
}
.classic-toolbar-widget::-webkit-scrollbar {
height: 0 !important;
width: 0 !important;
}
.classic-toolbar-widget.dropdown-active {
height: 50vh;
}
.classic-toolbar-widget .ck.ck-toolbar {
--ck-color-toolbar-background: transparent;
--ck-color-button-default-background: transparent;
--ck-color-button-default-disabled-background: transparent;
position: absolute;
background-color: transparent;
border: none;
}
.classic-toolbar-widget .ck.ck-button.ck-disabled {
opacity: 0.3;
}
</style>
`;
/**
* Handles the editing toolbar for CKEditor in mobile mode. The toolbar acts as a floating bar, with two different mechanism:
*
* - On iOS, because it does not respect the viewport meta value `interactive-widget=resizes-content`, we need to listen to window resizes and scroll and reposition the toolbar using absolute positioning.
* - On Android, the viewport change makes the keyboard resize the content area, all we have to do is to hide the tab bar and global menu (handled in the global style).
*/
export default class MobileEditorToolbar extends NoteContextAwareWidget {
private observer: MutationObserver;
private $innerWrapper!: JQuery<HTMLElement>;
constructor() {
super();
this.observer = new MutationObserver((e) => this.#onDropdownStateChanged(e));
}
get name() {
return "classicEditor";
}
doRender() {
this.$widget = $(TPL);
this.$innerWrapper = this.$widget.find(".classic-toolbar-widget");
this.contentSized();
// Observe when a dropdown is expanded to apply a style that allows the dropdown to be visible, since we can't have the element both visible and the toolbar scrollable.
this.observer.disconnect();
this.observer.observe(this.$widget[0], {
attributeFilter: ["aria-expanded"],
subtree: true
});
if (isIOS()) {
this.#handlePositioningOniOS();
}
}
#handlePositioningOniOS() {
const adjustPosition = () => {
let bottom = window.innerHeight - (window.visualViewport?.height || 0);
this.$widget.css("bottom", `${bottom}px`);
}
this.$widget.addClass("ios");
window.visualViewport?.addEventListener("resize", adjustPosition);
window.addEventListener("scroll", adjustPosition);
}
#onDropdownStateChanged(e: MutationRecord[]) {
const dropdownActive = e.map((e) => (e.target as any).ariaExpanded === "true").reduce((acc, e) => acc && e);
this.$innerWrapper.toggleClass("dropdown-active", dropdownActive);
}
async #shouldDisplay() {
if (!this.note || this.note.type !== "text") {
return false;
}
if (await this.noteContext?.isReadOnly()) {
return false;
}
return true;
}
async refreshWithNote() {
this.toggleExt(await this.#shouldDisplay());
}
}

View File

@@ -0,0 +1,173 @@
import { formatDateTime } from "../../utils/formatters.js";
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="note-info-widget">
<style>
.note-info-widget {
padding: 12px;
}
.note-info-widget-table {
max-width: 100%;
display: block;
overflow-x: auto;
white-space: nowrap;
}
.note-info-widget-table td, .note-info-widget-table th {
padding: 5px;
}
.note-info-mime {
max-width: 13em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<table class="note-info-widget-table">
<tr>
<th>${t("note_info_widget.note_id")}:</th>
<td class="note-info-note-id"></td>
<th>${t("note_info_widget.created")}:</th>
<td class="note-info-date-created"></td>
<th>${t("note_info_widget.modified")}:</th>
<td class="note-info-date-modified"></td>
</tr>
<tr>
<th>${t("note_info_widget.type")}:</th>
<td>
<span class="note-info-type"></span>
<span class="note-info-mime"></span>
</td>
<th title="${t("note_info_widget.note_size_info")}">${t("note_info_widget.note_size")}:</th>
<td colspan="3">
<button class="btn btn-sm calculate-button" style="padding: 0px 10px 0px 10px;">
<span class="bx bx-calculator"></span> ${t("note_info_widget.calculate")}
</button>
<span class="note-sizes-wrapper">
<span class="note-size"></span>
<span class="subtree-size"></span>
</span>
</td>
</tr>
</table>
</div>
`;
// TODO: Deduplicate with server
interface NoteSizeResponse {
noteSize: number;
}
interface SubtreeSizeResponse {
subTreeNoteCount: number;
subTreeSize: number;
}
interface MetadataResponse {
dateCreated: number;
dateModified: number;
}
export default class NoteInfoWidget extends NoteContextAwareWidget {
private $noteId!: JQuery<HTMLElement>;
private $dateCreated!: JQuery<HTMLElement>;
private $dateModified!: JQuery<HTMLElement>;
private $type!: JQuery<HTMLElement>;
private $mime!: JQuery<HTMLElement>;
private $noteSizesWrapper!: JQuery<HTMLElement>;
private $noteSize!: JQuery<HTMLElement>;
private $subTreeSize!: JQuery<HTMLElement>;
private $calculateButton!: JQuery<HTMLElement>;
get name() {
return "noteInfo";
}
get toggleCommand() {
return "toggleRibbonTabNoteInfo";
}
isEnabled() {
return !!this.note;
}
getTitle() {
return {
show: this.isEnabled(),
title: t("note_info_widget.title"),
icon: "bx bx-info-circle"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$noteId = this.$widget.find(".note-info-note-id");
this.$dateCreated = this.$widget.find(".note-info-date-created");
this.$dateModified = this.$widget.find(".note-info-date-modified");
this.$type = this.$widget.find(".note-info-type");
this.$mime = this.$widget.find(".note-info-mime");
this.$noteSizesWrapper = this.$widget.find(".note-sizes-wrapper");
this.$noteSize = this.$widget.find(".note-size");
this.$subTreeSize = this.$widget.find(".subtree-size");
this.$calculateButton = this.$widget.find(".calculate-button");
this.$calculateButton.on("click", async () => {
this.$noteSizesWrapper.show();
this.$calculateButton.hide();
this.$noteSize.empty().append($('<span class="bx bx-loader bx-spin"></span>'));
this.$subTreeSize.empty().append($('<span class="bx bx-loader bx-spin"></span>'));
const noteSizeResp = await server.get<NoteSizeResponse>(`stats/note-size/${this.noteId}`);
this.$noteSize.text(utils.formatSize(noteSizeResp.noteSize));
const subTreeResp = await server.get<SubtreeSizeResponse>(`stats/subtree-size/${this.noteId}`);
if (subTreeResp.subTreeNoteCount > 1) {
this.$subTreeSize.text(t("note_info_widget.subtree_size", { size: utils.formatSize(subTreeResp.subTreeSize), count: subTreeResp.subTreeNoteCount }));
} else {
this.$subTreeSize.text("");
}
});
}
async refreshWithNote(note: FNote) {
const metadata = await server.get<MetadataResponse>(`notes/${this.noteId}/metadata`);
this.$noteId.text(note.noteId);
this.$dateCreated.text(formatDateTime(metadata.dateCreated)).attr("title", metadata.dateCreated);
this.$dateModified.text(formatDateTime(metadata.dateModified)).attr("title", metadata.dateModified);
this.$type.text(note.type);
if (note.mime) {
this.$mime.text(`(${note.mime})`);
} else {
this.$mime.empty();
}
this.$calculateButton.show();
this.$noteSizesWrapper.hide();
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.noteId && (loadResults.isNoteReloaded(this.noteId) || loadResults.isNoteContentReloaded(this.noteId))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,131 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import NoteMapWidget from "../note_map.js";
import { t } from "../../services/i18n.js";
const TPL = /*html*/`
<div class="note-map-ribbon-widget">
<style>
.note-map-ribbon-widget {
position: relative;
}
.note-map-ribbon-widget .note-map-container {
height: 300px;
}
.open-full-button, .collapse-button {
position: absolute;
right: 5px;
bottom: 5px;
z-index: 1000;
}
.style-resolver {
color: var(--muted-text-color);
display: none;
}
</style>
<button class="bx bx-arrow-to-bottom icon-action open-full-button" title="${t("note_map.open_full")}"></button>
<button class="bx bx-arrow-to-top icon-action collapse-button" style="display: none;" title="${t("note_map.collapse")}"></button>
<div class="note-map-container"></div>
</div>`;
export default class NoteMapRibbonWidget extends NoteContextAwareWidget {
private openState!: "small" | "full";
private noteMapWidget: NoteMapWidget;
private $container!: JQuery<HTMLElement>;
private $openFullButton!: JQuery<HTMLElement>;
private $collapseButton!: JQuery<HTMLElement>;
constructor() {
super();
this.noteMapWidget = new NoteMapWidget("ribbon");
this.child(this.noteMapWidget);
}
get name() {
return "noteMap";
}
get toggleCommand() {
return "toggleRibbonTabNoteMap";
}
getTitle() {
return {
show: this.isEnabled(),
title: t("note_map.title"),
icon: "bx bxs-network-chart"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$container = this.$widget.find(".note-map-container");
this.$container.append(this.noteMapWidget.render());
this.openState = "small";
this.$openFullButton = this.$widget.find(".open-full-button");
this.$openFullButton.on("click", () => {
this.setFullHeight();
this.$openFullButton.hide();
this.$collapseButton.show();
this.openState = "full";
this.noteMapWidget.setDimensions();
});
this.$collapseButton = this.$widget.find(".collapse-button");
this.$collapseButton.on("click", () => {
this.setSmallSize();
this.$openFullButton.show();
this.$collapseButton.hide();
this.openState = "small";
this.noteMapWidget.setDimensions();
});
const handleResize = () => {
if (!this.noteMapWidget.graph) {
// no graph has been even rendered
return;
}
if (this.openState === "full") {
this.setFullHeight();
} else if (this.openState === "small") {
this.setSmallSize();
}
};
new ResizeObserver(handleResize).observe(this.$widget[0]);
}
setSmallSize() {
const SMALL_SIZE_HEIGHT = 300;
const width = this.$widget.width() ?? 0;
this.$widget.find(".note-map-container").height(SMALL_SIZE_HEIGHT).width(width);
}
setFullHeight() {
const { top } = this.$widget[0].getBoundingClientRect();
const height = ($(window).height() ?? 0) - top;
const width = this.$widget.width() ?? 0;
this.$widget.find(".note-map-container")
.height(height)
.width(width);
}
}

View File

@@ -0,0 +1,154 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import treeService from "../../services/tree.js";
import linkService from "../../services/link.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { NotePathRecord } from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="note-paths-widget">
<style>
.note-paths-widget {
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
.note-path-list {
margin-top: 10px;
}
.note-path-list .path-current a {
font-weight: bold;
}
.note-path-list .path-archived a {
color: var(--muted-text-color) !important;
}
.note-path-list .path-search a {
font-style: italic;
}
</style>
<div class="note-path-intro"></div>
<ul class="note-path-list"></ul>
<button class="btn btn-sm" data-trigger-command="cloneNoteIdsTo">${t("note_paths.clone_button")}</button>
</div>`;
export default class NotePathsWidget extends NoteContextAwareWidget {
private $notePathIntro!: JQuery<HTMLElement>;
private $notePathList!: JQuery<HTMLElement>;
get name() {
return "notePaths";
}
get toggleCommand() {
return "toggleRibbonTabNotePaths";
}
getTitle() {
return {
show: true,
title: t("note_paths.title"),
icon: "bx bx-collection"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$notePathIntro = this.$widget.find(".note-path-intro");
this.$notePathList = this.$widget.find(".note-path-list");
}
async refreshWithNote(note: FNote) {
this.$notePathList.empty();
if (!this.note || this.noteId === "root") {
this.$notePathList.empty().append(await this.getRenderedPath(["root"]));
return;
}
const sortedNotePaths = this.note.getSortedNotePathRecords(this.hoistedNoteId).filter((notePath) => !notePath.isHidden);
if (sortedNotePaths.length > 0) {
this.$notePathIntro.text(t("note_paths.intro_placed"));
} else {
this.$notePathIntro.text(t("note_paths.intro_not_placed"));
}
const renderedPaths = [];
for (const notePathRecord of sortedNotePaths) {
const notePath = notePathRecord.notePath;
renderedPaths.push(await this.getRenderedPath(notePath, notePathRecord));
}
this.$notePathList.empty().append(...renderedPaths);
}
async getRenderedPath(notePath: string[], notePathRecord: NotePathRecord | null = null) {
const $pathItem = $("<li>");
const pathSegments: string[] = [];
const lastIndex = notePath.length - 1;
for (let i = 0; i < notePath.length; i++) {
const noteId = notePath[i];
pathSegments.push(noteId);
const title = await treeService.getNoteTitle(noteId);
const $noteLink = await linkService.createLink(pathSegments.join("/"), { title });
$noteLink.find("a").addClass("no-tooltip-preview tn-link");
$pathItem.append($noteLink);
if (i != lastIndex) {
$pathItem.append(" / ");
}
}
const icons = [];
if (this.notePath === notePath.join("/")) {
$pathItem.addClass("path-current");
}
if (!notePathRecord || notePathRecord.isInHoistedSubTree) {
$pathItem.addClass("path-in-hoisted-subtree");
} else {
icons.push(`<span class="bx bx-trending-up" title="${t("note_paths.outside_hoisted")}"></span>`);
}
if (notePathRecord?.isArchived) {
$pathItem.addClass("path-archived");
icons.push(`<span class="bx bx-archive" title="${t("note_paths.archived")}"></span>`);
}
if (notePathRecord?.isSearch) {
$pathItem.addClass("path-search");
icons.push(`<span class="bx bx-search" title="${t("note_paths.search")}"></span>`);
}
if (icons.length > 0) {
$pathItem.append(` ${icons.join(" ")}`);
}
return $pathItem;
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getBranchRows().find((branch) => branch.noteId === this.noteId) || (this.noteId != null && loadResults.isNoteReloaded(this.noteId))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,54 @@
import type FNote from "../../entities/fnote.js";
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="note-properties-widget">
<style>
.note-properties-widget {
padding: 12px;
color: var(--muted-text-color);
}
</style>
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
${t("note_properties.this_note_was_originally_taken_from")} <a class="page-url external"></a>
</div>
</div>`;
/**
* TODO: figure out better name or conceptualize better.
*/
export default class NotePropertiesWidget extends NoteContextAwareWidget {
private $pageUrl!: JQuery<HTMLElement>;
isEnabled() {
return this.note && !!this.note.getLabelValue("pageUrl");
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("note_properties.info"),
icon: "bx bx-info-square"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$pageUrl = this.$widget.find(".page-url");
}
async refreshWithNote(note: FNote) {
const pageUrl = note.getLabelValue("pageUrl");
this.$pageUrl
.attr("href", pageUrl)
.attr("title", pageUrl)
.text(pageUrl ?? "");
}
}

View File

@@ -0,0 +1,86 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
import AttributeEditorWidget from "../attribute_widgets/attribute_editor.js";
import type { CommandListenerData } from "../../components/app_context.js";
import type FAttribute from "../../entities/fattribute.js";
const TPL = /*html*/`
<div class="attribute-list">
<style>
.attribute-list {
margin-left: 7px;
margin-right: 7px;
margin-top: 5px;
margin-bottom: 2px;
position: relative;
}
.attribute-list-editor p {
margin: 0 !important;
}
</style>
<div class="attr-editor-placeholder"></div>
</div>
`;
export default class OwnedAttributeListWidget extends NoteContextAwareWidget {
private attributeDetailWidget: AttributeDetailWidget;
private attributeEditorWidget: AttributeEditorWidget;
private $title!: JQuery<HTMLElement>;
get name() {
return "ownedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabOwnedAttributes";
}
constructor() {
super();
this.attributeDetailWidget = new AttributeDetailWidget().contentSized().setParent(this);
this.attributeEditorWidget = new AttributeEditorWidget(this.attributeDetailWidget).contentSized().setParent(this);
this.child(this.attributeEditorWidget, this.attributeDetailWidget);
}
getTitle() {
return {
show: !this.note?.isLaunchBarConfig(),
title: t("owned_attribute_list.owned_attributes"),
icon: "bx bx-list-check"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$widget.find(".attr-editor-placeholder").replaceWith(this.attributeEditorWidget.render());
this.$widget.append(this.attributeDetailWidget.render());
this.$title = $("<div>");
}
async saveAttributesCommand() {
await this.attributeEditorWidget.save();
}
async reloadAttributesCommand() {
await this.attributeEditorWidget.refresh();
}
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
// TODO: See why we need FAttribute[] and Attribute[]
await this.attributeEditorWidget.updateAttributeList(attributes as FAttribute[]);
}
focus() {
this.attributeEditorWidget.focus();
}
}

View File

@@ -0,0 +1,391 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import attributeService from "../../services/attributes.js";
import options from "../../services/options.js";
import utils from "../../services/utils.js";
import type FNote from "../../entities/fnote.js";
import type { Attribute } from "../../services/attribute_parser.js";
import type FAttribute from "../../entities/fattribute.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="promoted-attributes-widget">
<style>
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.promoted-attributes-container {
margin: 0 1.5em;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-left: 10px;
display: flex;
min-height: 40px;
}
.promoted-attribute-cell strong {
word-break:keep-all;
white-space: nowrap;
}
.promoted-attribute-cell input[type="checkbox"] {
width: 22px !important;
flex-grow: 0;
width: unset;
}
</style>
<div class="promoted-attributes-container"></div>
</div>`;
// TODO: Deduplicate
interface AttributeResult {
attributeId: string;
}
/**
* This widget is quite special because it's used in the desktop ribbon, but in mobile outside of ribbon.
* This works without many issues (apart from autocomplete), but it should be kept in mind when changing things
* and testing.
*/
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
private $container!: JQuery<HTMLElement>;
get name() {
return "promotedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabPromotedAttributes";
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$container = this.$widget.find(".promoted-attributes-container");
}
getTitle(note: FNote) {
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
if (promotedDefAttrs.length === 0) {
return { show: false };
}
return {
show: true,
activate: options.is("promotedAttributesOpenInRibbon"),
title: t("promoted_attributes.promoted_attributes"),
icon: "bx bx-table"
};
}
async refreshWithNote(note: FNote) {
this.$container.empty();
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
if (promotedDefAttrs.length === 0) {
this.toggleInt(false);
return;
}
const $cells = [];
for (const definitionAttr of promotedDefAttrs) {
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
if ($cell) {
$cells.push($cell);
}
}
}
// we replace the whole content in one step, so there can't be any race conditions
// (previously we saw promoted attributes doubling)
this.$container.empty().append(...$cells);
this.toggleInt(true);
}
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
const definition = definitionAttr.getDefinition();
const id = `value-${valueAttr.attributeId}`;
const $input = $("<input>")
.prop("tabindex", 200 + definitionAttr.position)
.prop("id", id)
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
.attr("data-attribute-type", valueAttr.type)
.attr("data-attribute-name", valueAttr.name)
.prop("value", valueAttr.value)
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
.addClass("form-control")
.addClass("promoted-attribute-input")
.on("change", (event) => this.promotedAttributeChanged(event));
const $actionCell = $("<div>");
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
const $wrapper = $('<div class="promoted-attribute-cell">')
.append(
$("<label>")
.prop("for", id)
.text(definition.promotedAlias ?? valueName)
)
.append($("<div>").addClass("input-group").append($input))
.append($actionCell)
.append($multiplicityCell);
if (valueAttr.type === "label") {
if (definition.labelType === "text") {
$input.prop("type", "text");
// autocomplete for label values is just nice to have, mobile can keep labels editable without autocomplete
if (utils.isDesktop()) {
// no need to await for this, can be done asynchronously
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributeValues) => {
if (_attributeValues.length === 0) {
return;
}
const attributeValues = _attributeValues.map((attribute) => ({ value: attribute }));
$input.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
source: function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$input.on("autocomplete:selected", (e) => this.promotedAttributeChanged(e));
});
}
} else if (definition.labelType === "number") {
$input.prop("type", "number");
let step = 1;
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
step /= 10;
}
$input.prop("step", step);
$input.css("text-align", "right").css("width", "120");
} else if (definition.labelType === "boolean") {
$input.prop("type", "checkbox");
$input.wrap($(`<label class="tn-checkbox"></label>`));
$wrapper.find(".input-group").removeClass("input-group");
if (valueAttr.value === "true") {
$input.prop("checked", "checked");
}
} else if (definition.labelType === "date") {
$input.prop("type", "date");
} else if (definition.labelType === "datetime") {
$input.prop("type", "datetime-local");
} else if (definition.labelType === "time") {
$input.prop("type", "time");
} else if (definition.labelType === "url") {
$input.prop("placeholder", t("promoted_attributes.url_placeholder"));
const $openButton = $("<span>")
.addClass("input-group-text open-external-link-button bx bx-window-open")
.prop("title", t("promoted_attributes.open_external_link"))
.on("click", () => window.open($input.val() as string, "_blank"));
$input.after($openButton);
} else {
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
}
} else if (valueAttr.type === "relation") {
if (valueAttr.value) {
$input.val(await treeService.getNoteTitle(valueAttr.value));
}
if (utils.isDesktop()) {
// no need to wait for this
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true });
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
this.promotedAttributeChanged(event);
});
$input.setSelectedNotePath(valueAttr.value);
} else {
// we can't provide user a way to edit the relation so make it read only
$input.attr("readonly", "readonly");
}
} else {
ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type }));
return;
}
if (definition.multiplicity === "multi") {
const $addButton = $("<span>")
.addClass("bx bx-plus pointer tn-tool-button")
.prop("title", t("promoted_attributes.add_new_attribute"))
.on("click", async () => {
const $new = await this.createPromotedAttributeCell(
definitionAttr,
{
attributeId: "",
type: valueAttr.type,
name: valueName,
value: ""
},
valueName
);
if ($new) {
$wrapper.after($new);
$new.find("input").trigger("focus");
}
});
const $removeButton = $("<span>")
.addClass("bx bx-trash pointer tn-tool-button")
.prop("title", t("promoted_attributes.remove_this_attribute"))
.on("click", async () => {
const attributeId = $input.attr("data-attribute-id");
if (attributeId) {
await server.remove(`notes/${this.noteId}/attributes/${attributeId}`, this.componentId);
}
// if it's the last one the create new empty form immediately
const sameAttrSelector = `input[data-attribute-type='${valueAttr.type}'][data-attribute-name='${valueName}']`;
if (this.$widget.find(sameAttrSelector).length <= 1) {
const $new = await this.createPromotedAttributeCell(
definitionAttr,
{
attributeId: "",
type: valueAttr.type,
name: valueName,
value: ""
},
valueName
);
if ($new) {
$wrapper.after($new);
}
}
$wrapper.remove();
});
$multiplicityCell.append(" &nbsp;").append($addButton).append(" &nbsp;").append($removeButton);
}
return $wrapper;
}
async promotedAttributeChanged(event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) {
const $attr = $(event.target);
let value;
if ($attr.prop("type") === "checkbox") {
value = $attr.is(":checked") ? "true" : "false";
} else if ($attr.attr("data-attribute-type") === "relation") {
const selectedPath = $attr.getSelectedNotePath();
value = selectedPath ? treeService.getNoteIdFromUrl(selectedPath) : "";
} else {
value = $attr.val();
}
const result = await server.put<AttributeResult>(
`notes/${this.noteId}/attribute`,
{
attributeId: $attr.attr("data-attribute-id"),
type: $attr.attr("data-attribute-type"),
name: $attr.attr("data-attribute-name"),
value: value
},
this.componentId
);
$attr.attr("data-attribute-id", result.attributeId);
}
focus() {
this.$widget.find(".promoted-attribute-input:first").focus();
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,76 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="script-runner-widget">
<style>
.script-runner-widget {
padding: 12px;
color: var(--muted-text-color);
}
.execute-description {
margin-bottom: 10px;
}
</style>
<div class="execute-description"></div>
<div style="display: flex; justify-content: space-around">
<button data-trigger-command="runActiveNote" class="execute-button btn btn-sm"></button>
</div>
</div>`;
export default class ScriptExecutorWidget extends NoteContextAwareWidget {
private $executeButton!: JQuery<HTMLElement>;
private $executeDescription!: JQuery<HTMLElement>;
isEnabled() {
return (
super.isEnabled() &&
this.note &&
(this.note.mime.startsWith("application/javascript") || this.isTriliumSqlite()) &&
(this.note.hasLabel("executeDescription") || this.note.hasLabel("executeButton"))
);
}
isTriliumSqlite() {
return this.note?.mime === "text/x-sqlite;schema=trilium";
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: this.isTriliumSqlite() ? t("script_executor.query") : t("script_executor.script"),
icon: "bx bx-play"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$executeButton = this.$widget.find(".execute-button");
this.$executeDescription = this.$widget.find(".execute-description");
}
async refreshWithNote(note: FNote) {
const executeTitle = note.getLabelValue("executeButton") || (this.isTriliumSqlite() ? t("script_executor.execute_query") : t("script_executor.execute_script"));
this.$executeButton.text(executeTitle);
this.$executeButton.attr("title", executeTitle);
keyboardActionService.updateDisplayedShortcuts(this.$widget);
const executeDescription = note.getLabelValue("executeDescription");
if (executeDescription) {
this.$executeDescription.show().html(executeDescription);
} else {
this.$executeDescription.empty().hide();
}
}
}

View File

@@ -0,0 +1,334 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import froca from "../../services/froca.js";
import ws from "../../services/ws.js";
import toastService from "../../services/toast.js";
import treeService from "../../services/tree.js";
import SearchString from "../search_options/search_string.js";
import FastSearch from "../search_options/fast_search.js";
import Ancestor from "../search_options/ancestor.js";
import IncludeArchivedNotes from "../search_options/include_archived_notes.js";
import OrderBy from "../search_options/order_by.js";
import SearchScript from "../search_options/search_script.js";
import Limit from "../search_options/limit.js";
import Debug from "../search_options/debug.js";
import appContext, { type EventData } from "../../components/app_context.js";
import bulkActionService from "../../services/bulk_action.js";
import { Dropdown } from "bootstrap";
import type FNote from "../../entities/fnote.js";
import type { AttributeType } from "../../entities/fattribute.js";
const TPL = /*html*/`
<div class="search-definition-widget">
<style>
.search-setting-table {
margin-top: 0;
margin-bottom: 7px;
width: 100%;
border-collapse: separate;
border-spacing: 10px;
}
.search-setting-table div {
white-space: nowrap;
}
.search-setting-table .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;
}
.search-setting-table .title-column {
/* minimal width so that table remains static sized and most space remains for middle column with settings */
width: 50px;
white-space: nowrap;
}
.search-setting-table .button-column .dropdown-menu {
white-space: normal;
}
.attribute-list hr {
height: 1px;
border-color: var(--main-border-color);
position: relative;
top: 4px;
margin-top: 5px;
margin-bottom: 0;
}
.search-definition-widget input:invalid {
border: 3px solid red;
}
.add-search-option button {
margin-top: 5px; /* to give some spacing when buttons overflow on the next line */
}
.dropdown-header {
background-color: var(--accented-background-color);
}
</style>
<div class="search-settings">
<table class="search-setting-table">
<tr>
<td class="title-column">${t("search_definition.add_search_option")}</td>
<td colspan="2" class="add-search-option">
<button type="button" class="btn btn-sm" data-search-option-add="searchString">
<span class="bx bx-text"></span>
${t("search_definition.search_string")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="searchScript">
<span class="bx bx-code"></span>
${t("search_definition.search_script")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="ancestor">
<span class="bx bx-filter-alt"></span>
${t("search_definition.ancestor")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="fastSearch"
title="${t("search_definition.fast_search_description")}">
<span class="bx bx-run"></span>
${t("search_definition.fast_search")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="includeArchivedNotes"
title="${t("search_definition.include_archived_notes_description")}">
<span class="bx bx-archive"></span>
${t("search_definition.include_archived")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="orderBy">
<span class="bx bx-arrow-from-top"></span>
${t("search_definition.order_by")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="limit" title="${t("search_definition.limit_description")}">
<span class="bx bx-stop"></span>
${t("search_definition.limit")}
</button>
<button type="button" class="btn btn-sm" data-search-option-add="debug" title="${t("search_definition.debug_description")}">
<span class="bx bx-bug"></span>
${t("search_definition.debug")}
</button>
<div class="dropdown" style="display: inline-block;">
<button class="btn btn-sm dropdown-toggle action-add-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="bx bxs-zap"></span>
${t("search_definition.action")}
</button>
<div class="dropdown-menu action-list"></div>
</div>
</td>
</tr>
<tbody class="search-options"></tbody>
<tbody class="action-options"></tbody>
<tbody>
<tr>
<td colspan="3">
<div style="display: flex; justify-content: space-evenly">
<button type="button" class="btn btn-sm search-button">
<span class="bx bx-search"></span>
${t("search_definition.search_button")}
</button>
<button type="button" class="btn btn-sm search-and-execute-button">
<span class="bx bxs-zap"></span>
${t("search_definition.search_execute")}
</button>
<button type="button" class="btn btn-sm save-to-note-button">
<span class="bx bx-save"></span>
${t("search_definition.save_to_note")}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>`;
const OPTION_CLASSES = [SearchString, SearchScript, Ancestor, FastSearch, IncludeArchivedNotes, OrderBy, Limit, Debug];
// TODO: Deduplicate with server
interface SaveSearchNoteResponse {
notePath: string;
}
export default class SearchDefinitionWidget extends NoteContextAwareWidget {
private $component!: JQuery<HTMLElement>;
private $actionList!: JQuery<HTMLElement>;
private $searchOptions!: JQuery<HTMLElement>;
private $searchButton!: JQuery<HTMLElement>;
private $searchAndExecuteButton!: JQuery<HTMLElement>;
private $saveToNoteButton!: JQuery<HTMLElement>;
private $actionOptions!: JQuery<HTMLElement>;
get name() {
return "searchDefinition";
}
isEnabled() {
return this.note && this.note.type === "search";
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("search_definition.search_parameters"),
icon: "bx bx-search"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$component = this.$widget.find(".search-definition-widget");
this.$actionList = this.$widget.find(".action-list");
for (const actionGroup of bulkActionService.ACTION_GROUPS) {
this.$actionList.append($('<h6 class="dropdown-header">').append(actionGroup.title));
for (const action of actionGroup.actions) {
this.$actionList.append($('<a class="dropdown-item" href="#">').attr("data-action-add", action.actionName).text(action.actionTitle));
}
}
this.$widget.on("click", "[data-search-option-add]", async (event) => {
const searchOptionName = $(event.target).attr("data-search-option-add");
const clazz = OPTION_CLASSES.find((SearchOptionClass) => SearchOptionClass.optionName === searchOptionName);
if (clazz && this.noteId) {
await clazz.create(this.noteId);
} else {
logError(t("search_definition.unknown_search_option", { searchOptionName }));
}
this.refresh();
});
this.$widget.on("click", "[data-action-add]", async (event) => {
Dropdown.getOrCreateInstance(this.$widget.find(".action-add-toggle")[0]);
const actionName = $(event.target).attr("data-action-add");
if (this.noteId && actionName) {
await bulkActionService.addAction(this.noteId, actionName);
}
this.refresh();
});
this.$searchOptions = this.$widget.find(".search-options");
this.$actionOptions = this.$widget.find(".action-options");
this.$searchButton = this.$widget.find(".search-button");
this.$searchButton.on("click", () => this.triggerCommand("refreshResults"));
this.$searchAndExecuteButton = this.$widget.find(".search-and-execute-button");
this.$searchAndExecuteButton.on("click", () => this.searchAndExecute());
this.$saveToNoteButton = this.$widget.find(".save-to-note-button");
this.$saveToNoteButton.on("click", async () => {
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: this.noteId });
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
// Note the {{- notePathTitle}} in json file is not typo, it's unescaping
// See https://www.i18next.com/translation-function/interpolation#unescape
toastService.showMessage(t("search_definition.search_note_saved", { notePathTitle: await treeService.getNotePathTitle(notePath) }));
});
}
async refreshResultsCommand() {
if (!this.noteId) {
return;
}
try {
const result = await froca.loadSearchNote(this.noteId);
if (result && result.error) {
this.handleEvent("showSearchError", { error: result.error });
}
} catch (e: any) {
toastService.showError(e.message);
}
this.triggerEvent("searchRefreshed", { ntxId: this.noteContext?.ntxId });
}
async refreshSearchDefinitionCommand() {
await this.refresh();
}
async refreshWithNote(note: FNote) {
if (!this.note) {
return;
}
this.$component.show();
this.$saveToNoteButton.toggle(note.isHiddenCompletely());
this.$searchOptions.empty();
for (const OptionClass of OPTION_CLASSES) {
const { attributeType, optionName } = OptionClass;
const attr = this.note.getAttribute(attributeType as AttributeType, optionName);
this.$widget.find(`[data-search-option-add='${optionName}'`).toggle(!attr);
if (attr) {
const searchOption = new OptionClass(attr, this.note).setParent(this);
this.child(searchOption);
const renderedEl = searchOption.render();
if (renderedEl) {
this.$searchOptions.append(renderedEl);
}
}
}
const actions = bulkActionService.parseActions(this.note);
const renderedEls = actions
.map((action) => action.render())
.filter((e) => e) as JQuery<HTMLElement>[];
this.$actionOptions.empty().append(...renderedEls);
this.$searchAndExecuteButton.css("visibility", actions.length > 0 ? "visible" : "_hidden");
}
getContent() {
return "";
}
async searchAndExecute() {
await server.post(`search-and-execute-note/${this.noteId}`);
this.triggerCommand("refreshResults");
toastService.showMessage(t("search_definition.actions_executed"), 3000);
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// only refreshing deleted attrs, otherwise components update themselves
if (loadResults.getAttributeRows().find((attrRow) => attrRow.type === "label" && attrRow.name === "action" && attrRow.isDeleted)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,120 @@
import { t } from "../../services/i18n.js";
import linkService from "../../services/link.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="similar-notes-widget">
<style>
.similar-notes-wrapper {
max-height: 200px;
overflow: auto;
padding: 12px;
}
.similar-notes-wrapper a {
display: inline-block;
border: 1px dotted var(--main-border-color);
border-radius: 20px;
background-color: var(--accented-background-color);
padding: 0 10px 0 10px;
margin: 0 3px 0 3px;
max-width: 10em;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>
<div class="similar-notes-wrapper"></div>
</div>
`;
// TODO: Deduplicate with server
interface SimilarNote {
score: number;
notePath: string[];
noteId: string;
}
export default class SimilarNotesWidget extends NoteContextAwareWidget {
private $similarNotesWrapper!: JQuery<HTMLElement>;
private title?: string;
private rendered?: boolean;
get name() {
return "similarNotes";
}
get toggleCommand() {
return "toggleRibbonTabSimilarNotes";
}
isEnabled() {
return super.isEnabled() && this.note?.type !== "search" && !this.note?.isLabelTruthy("similarNotesWidgetDisabled");
}
getTitle() {
return {
show: this.isEnabled(),
title: t("similar_notes.title"),
icon: "bx bx-bar-chart"
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$similarNotesWrapper = this.$widget.find(".similar-notes-wrapper");
}
async refreshWithNote(note: FNote) {
if (!this.note) {
return;
}
// remember which title was when we found the similar notes
this.title = this.note.title;
const similarNotes = await server.get<SimilarNote[]>(`similar-notes/${this.noteId}`);
if (similarNotes.length === 0) {
this.$similarNotesWrapper.empty().append(t("similar_notes.no_similar_notes_found"));
return;
}
const noteIds = similarNotes.flatMap((note) => note.notePath);
await froca.getNotes(noteIds, true); // preload all at once
const $list = $("<div>");
for (const similarNote of similarNotes) {
const note = await froca.getNote(similarNote.noteId, true);
if (!note) {
continue;
}
const $item = (await linkService.createLink(similarNote.notePath.join("/"))).css("font-size", 24 * (1 - 1 / (1 + similarNote.score)));
$list.append($item);
}
this.$similarNotesWrapper.empty().append($list);
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.note && this.title !== this.note.title) {
this.rendered = false;
this.refresh();
}
}
}