moved to section widgets

This commit is contained in:
zadam
2021-05-29 13:24:47 +02:00
parent 57b6271c0b
commit 7a389baf08
14 changed files with 13 additions and 13 deletions

View File

@@ -0,0 +1,62 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import NoteTypeWidget from "../note_type.js";
import ProtectedNoteSwitchWidget from "../protected_note_switch.js";
const TPL = `
<div class="basic-properties-widget">
<style>
.basic-properties-widget {
padding: 12px 12px 6px 12px;
display: flex;
align-items: baseline;
}
.note-type-container {
display: flex;
align-items: center;
}
.basic-properties-widget > * {
margin-right: 30px;
}
</style>
<div class="note-type-container" style="display: flex">
<span>Note type:</span> &nbsp;
</div>
<div class="protected-note-switch-container"></div>
</div>`;
export default class BasicPropertiesWidget extends NoteContextAwareWidget {
constructor() {
super();
this.noteTypeWidget = new NoteTypeWidget();
this.protectedNoteSwitchWidget = new ProtectedNoteSwitchWidget();
this.child(this.noteTypeWidget, this.protectedNoteSwitchWidget);
}
static getType() { return "basic-properties"; }
isEnabled() {
return this.note;
}
getTitle() {
return {
show: this.isEnabled(),
title: 'Basic Properties',
icon: 'bx bx-slider'
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$widget.find(".note-type-container").append(this.noteTypeWidget.render());
this.$widget.find(".protected-note-switch-container").append(this.protectedNoteSwitchWidget.render());
}
}

View File

@@ -0,0 +1,106 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import server from "../../services/server.js";
import attributeService from "../../services/attributes.js";
const TPL = `
<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">View type:&nbsp; &nbsp;</span>
<select class="view-type-select form-control form-control-sm">
<option value="grid">Grid</option>
<option value="list">List</option>
</select>
</div>
<button type="button"
class="collapse-all-button btn btn-sm"
title="Collapse all notes">
<span class="bx bx-layer-minus"></span>
Collapse
</button>
<button type="button"
class="expand-children-button btn btn-sm"
title="Expand all children">
<span class="bx bx-move-vertical"></span>
Expand
</button>
</div>
`;
export default class BookPropertiesWidget extends NoteContextAwareWidget {
static getType() { return "book-properties"; }
isEnabled() {
return this.note && this.note.type === 'book';
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: 'Book Properties',
icon: 'bx bx-book'
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$viewTypeSelect = this.$widget.find('.view-type-select');
this.$viewTypeSelect.on('change', () => this.toggleViewType(this.$viewTypeSelect.val()));
this.$expandChildrenButton = this.$widget.find('.expand-children-button');
this.$expandChildrenButton.on('click', async () => {
if (!this.note.hasLabel('expanded')) {
await attributeService.addLabel(this.noteId, 'expanded');
}
});
this.$collapseAllButton = this.$widget.find('.collapse-all-button');
this.$collapseAllButton.on('click', async () => {
// 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);
}
});
}
async refreshWithNote(note) {
const viewType = this.note.getLabelValue('viewType') || 'grid';
this.$viewTypeSelect.val(viewType);
this.$expandChildrenButton.toggle(viewType === 'list');
this.$collapseAllButton.toggle(viewType === 'list');
}
async toggleViewType(type) {
if (type !== 'list' && type !== 'grid') {
throw new Error(`Invalid view type ${type}`);
}
await attributeService.setLabel(this.noteId, 'viewType', type);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes().find(attr => attr.noteId === this.noteId && attr.name === 'viewType')) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,137 @@
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";
const TPL = `
<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>Note ID:</th>
<td class="file-note-id"></td>
<th>Original file name:</th>
<td class="file-filename"></td>
</tr>
<tr>
<th>File type:</th>
<td class="file-filetype"></td>
<th>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">Download</button>
&nbsp;
<button class="file-open btn btn-sm btn-primary" type="button">Open</button>
&nbsp;
<button class="file-upload-new-revision btn btn-sm btn-primary">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 {
static getType() { return "file"; }
isEnabled() {
return this.note && this.note.type === 'file';
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: 'File',
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', () => openService.downloadFileNote(this.noteId));
this.$openButton.on('click', () => openService.openNoteExternally(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 formData = new FormData();
formData.append('upload', fileToUpload);
const result = await $.ajax({
url: baseApiUrl + 'notes/' + this.noteId + '/file',
headers: await server.getHeaders(),
data: formData,
type: 'PUT',
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false, // NEEDED, DON'T REMOVE THIS
});
if (result.uploaded) {
toastService.showMessage("New file revision has been uploaded.");
this.refresh();
}
else {
toastService.showError("Upload of a new file revision failed.");
}
});
}
async refreshWithNote(note) {
const attributes = note.getAttributes();
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
this.$widget.show();
this.$fileNoteId.text(note.noteId);
this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileType.text(note.mime);
const noteComplement = await this.noteContext.getNoteComplement();
this.$fileSize.text(noteComplement.contentLength + " bytes");
// open doesn't work for protected notes since it works through browser which isn't in protected session
this.$openButton.toggle(!note.isProtected);
}
}

View File

@@ -0,0 +1,121 @@
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";
const TPL = `
<div class="image-properties">
<div style="display: flex; justify-content: space-evenly; margin: 10px;">
<span>
<strong>Original file name:</strong>
<span class="image-filename"></span>
</span>
<span>
<strong>File type:</strong>
<span class="image-filetype"></span>
</span>
<span>
<strong>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">Download</button>
<button class="image-open btn btn-sm btn-primary" type="button">Open</button>
<button class="image-copy-to-clipboard btn btn-sm btn-primary" type="button">Copy to clipboard</button>
<button class="image-upload-new-revision btn btn-sm btn-primary" type="button">Upload new revision</button>
</div>
<input type="file" class="image-upload-new-revision-input" style="display: none">
</div>`;
export default class ImagePropertiesWidget extends NoteContextAwareWidget {
static getType() { return "image"; }
isEnabled() {
return this.note && this.note.type === 'image';
}
getTitle(note) {
return {
show: this.isEnabled(),
activate: true,
title: 'Image',
icon: 'bx bx-image'
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$copyToClipboardButton = this.$widget.find(".image-copy-to-clipboard");
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', () => openService.openNoteExternally(this.noteId));
this.$imageDownloadButton = this.$widget.find(".image-download");
this.$imageDownloadButton.on('click', () => openService.downloadFileNote(this.noteId));
this.$copyToClipboardButton.on('click', () => this.triggerEvent(`copyImageToClipboard`, {ntxId: this.noteContext.ntxId}));
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 formData = new FormData();
formData.append('upload', fileToUpload);
const result = await $.ajax({
url: baseApiUrl + 'images/' + this.noteId,
headers: await server.getHeaders(),
data: formData,
type: 'PUT',
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false, // NEEDED, DON'T REMOVE THIS
});
if (result.uploaded) {
toastService.showMessage("New image revision has been uploaded.");
await utils.clearBrowserCache();
this.refresh();
}
else {
toastService.showError("Upload of a new image revision failed: " + result.message);
}
});
}
async refreshWithNote(note) {
const attributes = note.getAttributes();
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
this.$widget.show();
const noteComplement = await this.noteContext.getNoteComplement();
this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileSize.text(noteComplement.contentLength + " bytes");
this.$fileType.text(note.mime);
const imageHash = utils.randomString(10);
}
}

View File

@@ -0,0 +1,87 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
import attributeRenderer from "../../services/attribute_renderer.js";
const TPL = `
<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: 12px 12px 11px 12px;
}
</style>
<div class="inherited-attributes-container"></div>
</div>`;
export default class InheritedAttributesWidget extends NoteContextAwareWidget {
constructor() {
super();
this.attributeDetailWidget = new AttributeDetailWidget().setParent(this);
this.child(this.attributeDetailWidget);
}
getTitle() {
return {
show: true,
title: "Inherited attributes",
icon: "bx bx-list-plus"
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$container = this.$widget.find('.inherited-attributes-container');
this.$widget.append(this.attributeDetailWidget.render());
}
async refreshWithNote(note) {
this.$container.empty();
const inheritedAttributes = this.getInheritedAttributes(note);
if (inheritedAttributes.length === 0) {
this.$container.append("No inherited attributes.");
return;
}
for (const attribute of inheritedAttributes) {
const $attr = (await attributeRenderer.renderAttribute(attribute, false))
.on('click', e => 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
}));
this.$container
.append($attr)
.append(" ");
}
}
getInheritedAttributes(note) {
return note.getAttributes().filter(attr => attr.noteId !== this.noteId);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes(this.componentId).find(attr => attr.isAffecting(this.note))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,105 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import froca from "../../services/froca.js";
const TPL = `
<div class="link-map-widget">
<style>
.link-map-widget {
position: relative;
}
.open-full-dialog-button {
position: absolute;
right: 10px;
top: 10px;
z-index: 1000;
}
</style>
<button class="bx bx-expand icon-action open-full-dialog-button" title="Show Link Map dialog"></button>
<div class="link-map-container" style="height: 300px;"></div>
</div>`;
let linkMapContainerIdCtr = 1;
export default class LinkMapWidget extends NoteContextAwareWidget {
static getType() { return "link-map"; }
isEnabled() {
return this.note;
}
getTitle() {
return {
show: this.isEnabled(),
title: 'Link Map',
icon: 'bx bx-network-chart'
};
}
doRender() {
this.$widget = $(TPL);
this.$widget.find('.open-full-dialog-button').on('click', () => this.triggerCommand('showLinkMap'));
this.overflowing();
}
async refreshWithNote(note) {
let shown = false;
const observer = new IntersectionObserver(entries => {
if (!shown && entries[0].isIntersecting) {
shown = true;
this.displayLinkMap(note);
}
}, {
rootMargin: '0px',
threshold: 0.1
});
observer.observe(this.$widget[0]);
}
async displayLinkMap(note) {
this.$widget.find(".link-map-container").empty();
const $linkMapContainer = this.$widget.find('.link-map-container');
$linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++);
const LinkMapServiceClass = (await import('../../services/link_map.js')).default;
this.linkMapService = new LinkMapServiceClass(note, $linkMapContainer, {
maxDepth: 3,
zoom: 0.6,
stopCheckerCallback: () => this.noteId !== note.noteId // stop when current note is not what was originally requested
});
await this.linkMapService.render();
}
cleanup() {
if (this.linkMapService) {
this.linkMapService.cleanup();
}
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes().find(attr => attr.type === 'relation' && (attr.noteId === this.noteId || attr.value === this.noteId))) {
this.noteSwitched();
}
const changedNoteIds = loadResults.getNoteIds();
if (changedNoteIds.length > 0) {
const $linkMapContainer = this.$widget.find('.link-map-container');
for (const noteId of changedNoteIds) {
const note = froca.notes[noteId];
if (note) {
$linkMapContainer.find(`a[data-note-path="${noteId}"]`).text(note.title);
}
}
}
}
}

View File

@@ -0,0 +1,156 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import server from "../../services/server.js";
const TPL = `
<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>Note ID:</th>
<td class="note-info-note-id"></td>
<th>Created:</th>
<td class="note-info-date-created"></td>
<th>Modified:</th>
<td class="note-info-date-modified"></td>
</tr>
<tr>
<th>Type:</th>
<td>
<span class="note-info-type"></span>
<span class="note-info-mime"></span>
</td>
<th title="Note size provides rough estimate of storage requirements for this note. It takes into account note's content and content of its note revisions.">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> calculate
</button>
<span class="note-sizes-wrapper">
<span class="note-size"></span>
<span class="subtree-size"></span>
</span>
</td>
</tr>
</table>
</div>
`;
export default class NoteInfoWidget extends NoteContextAwareWidget {
static getType() { return "note-info"; }
isEnabled() {
return this.note;
}
getTitle() {
return {
show: this.isEnabled(),
title: 'Note Info',
icon: 'bx bx-info-circle'
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
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(`stats/note-size/${this.noteId}`);
this.$noteSize.text(this.formatSize(noteSizeResp.noteSize));
const subTreeResp = await server.get(`stats/subtree-size/${this.noteId}`);
if (subTreeResp.subTreeNoteCount > 1) {
this.$subTreeSize.text("(subtree size: " + this.formatSize(subTreeResp.subTreeSize) + ` in ${subTreeResp.subTreeNoteCount} notes)`);
}
else {
this.$subTreeSize.text("");
}
});
}
async refreshWithNote(note) {
const noteComplement = await this.noteContext.getNoteComplement();
this.$noteId.text(note.noteId);
this.$dateCreated
.text(noteComplement.dateCreated.substr(0, 16))
.attr("title", noteComplement.dateCreated);
this.$dateModified
.text(noteComplement.combinedDateModified.substr(0, 16))
.attr("title", noteComplement.combinedDateModified);
this.$type.text(note.type);
if (note.mime) {
this.$mime.text('(' + note.mime + ')');
}
else {
this.$mime.empty();
}
this.$calculateButton.show();
this.$noteSizesWrapper.hide();
}
formatSize(size) {
size = Math.max(Math.round(size / 1024), 1);
if (size < 1024) {
return `${size} KiB`;
}
else {
return `${Math.round(size / 102.4) / 10} MiB`;
}
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteReloaded(this.noteId) || loadResults.isNoteContentReloaded(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,124 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import treeService from "../../services/tree.js";
import linkService from "../../services/link.js";
const TPL = `
<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 {
font-weight: bold;
}
.note-path-list .path-archived {
color: var(--muted-text-color) !important;
}
.note-path-list .path-search {
font-style: italic;
}
</style>
<div>This note is placed into the following paths:</div>
<ul class="note-path-list"></ul>
<button class="btn btn-sm" data-trigger-command="cloneNoteIdsTo">Clone note to new location...</button>
</div>`;
export default class NotePathsWidget extends NoteContextAwareWidget {
static getType() { return "note-paths"; }
isEnabled() {
return this.note;
}
getTitle() {
return {
show: true,
title: 'Note Paths',
icon: 'bx bx-collection'
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$notePathList = this.$widget.find(".note-path-list");
this.$widget.on('show.bs.dropdown', () => this.renderDropdown());
}
async refreshWithNote(note) {
this.$notePathList.empty();
if (this.noteId === 'root') {
await this.addPath('root');
return;
}
for (const notePathRecord of this.note.getSortedNotePaths(this.hoistedNoteId)) {
await this.addPath(notePathRecord);
}
}
async addPath(notePathRecord) {
const notePath = notePathRecord.notePath.join('/');
const title = await treeService.getNotePathTitle(notePath);
const $noteLink = await linkService.createNoteLink(notePath, {title});
$noteLink
.find('a')
.addClass("no-tooltip-preview");
const icons = [];
if (this.notePath === notePath) {
$noteLink.addClass("path-current");
}
if (notePathRecord.isInHoistedSubTree) {
$noteLink.addClass("path-in-hoisted-subtree");
}
else {
icons.push(`<span class="bx bx-trending-up" title="This path is outside of hoisted note and you would have to unhoist."></span>`);
}
if (notePathRecord.isArchived) {
$noteLink.addClass("path-archived");
icons.push(`<span class="bx bx-archive" title="Archived"></span>`);
}
if (notePathRecord.isSearch) {
$noteLink.addClass("path-search");
icons.push(`<span class="bx bx-search" title="Search"></span>`);
}
if (icons.length > 0) {
$noteLink.append(` ${icons.join(' ')}`);
}
this.$notePathList.append($("<li>").append($noteLink));
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getBranches().find(branch => branch.noteId === this.noteId)
|| loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,48 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = `
<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">
This note was originally taken from: <a class="page-url external"></a>
</div>
</div>`;
export default class NotePropertiesWidget extends NoteContextAwareWidget {
static getType() { return "note-properties"; }
isEnabled() {
return this.note && !!this.note.getLabelValue('pageUrl');
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: 'Info',
icon: 'bx bx-info-square'
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$pageUrl = this.$widget.find('.page-url');
}
async refreshWithNote(note) {
const pageUrl = note.getLabelValue('pageUrl');
this.$pageUrl
.attr('href', pageUrl)
.attr('title', pageUrl)
.text(pageUrl);
}
}

View File

@@ -0,0 +1,71 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
import AttributeEditorWidget from "../attribute_widgets/attribute_editor.js";
const TPL = `
<div class="attribute-list">
<style>
.attribute-list {
margin-left: 7px;
margin-right: 7px;
margin-top: 3px;
position: relative;
}
.attribute-list-editor p {
margin: 0 !important;
}
</style>
<div class="attr-editor-placeholder"></div>
</div>
`;
export default class OwnedAttributeListWidget extends NoteContextAwareWidget {
constructor() {
super();
this.attributeDetailWidget = new AttributeDetailWidget().setParent(this);
this.attributeEditorWidget = new AttributeEditorWidget(this.attributeDetailWidget).setParent(this);
this.child(this.attributeEditorWidget, this.attributeDetailWidget);
}
getTitle() {
return {
show: true,
title: "Owned attributes",
icon: "bx bx-list-check"
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
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}) {
await this.attributeEditorWidget.updateAttributeList(attributes);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes(this.componentId).find(attr => attr.isAffecting(this.note))) {
this.refreshWithNote(this.note, true);
this.getTitle(this.note);
}
}
}

View File

@@ -0,0 +1,296 @@
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";
const TPL = `
<div>
<style>
.promoted-attributes-container {
margin: auto;
display: flex;
flex-direction: row;
flex-shrink: 0;
flex-grow: 0;
justify-content: space-evenly;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
}
.promoted-attribute-cell div.input-group {
margin-left: 10px;
}
</style>
<div class="promoted-attributes-container"></div>
</div>
`;
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$container = this.$widget.find(".promoted-attributes-container");
}
getTitle(note) {
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
if (promotedDefAttrs.length === 0) {
return { show: false };
}
return {
show: true,
activate: true,
title: "Promoted attributes",
icon: "bx bx-table"
};
}
async refreshWithNote(note) {
this.$container.empty();
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
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);
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);
$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, valueAttr, valueName) {
const definition = definitionAttr.getDefinition();
const $input = $("<input>")
.prop("tabindex", 200 + definitionAttr.position)
.prop("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
.prop("attribute-type", valueAttr.type)
.prop("attribute-name", valueAttr.name)
.prop("value", valueAttr.value)
.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($("<strong>").text(valueName))
.append($("<div>").addClass("input-group").append($input))
.append($actionCell)
.append($multiplicityCell);
if (valueAttr.type === 'label') {
if (definition.labelType === 'text') {
$input.prop("type", "text");
// no need to await for this, can be done asynchronously
server.get('attributes/values/' + encodeURIComponent(valueAttr.name)).then(attributeValues => {
if (attributeValues.length === 0) {
return;
}
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");
// hack, without this the checkbox is invisible
// we should be using a different bootstrap structure for checkboxes
$input.css('width', '80px');
if (valueAttr.value === "true") {
$input.prop("checked", "checked");
}
}
else if (definition.labelType === 'date') {
$input.prop("type", "date");
}
else if (definition.labelType === 'url') {
$input.prop("placeholder", "http://website...");
const $openButton = $("<span>")
.addClass("input-group-text open-external-link-button bx bx-window-open")
.prop("title", "Open external link")
.on('click', () => window.open($input.val(), '_blank'));
$input.after($("<div>")
.addClass("input-group-append")
.append($openButton));
}
else {
ws.logError("Unknown labelType=" + definitionAttr.labelType);
}
}
else if (valueAttr.type === 'relation') {
if (valueAttr.value) {
$input.val(await treeService.getNoteTitle(valueAttr.value));
}
// 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 {
ws.logError("Unknown attribute type=" + valueAttr.type);
return;
}
if (definition.multiplicity === "multi") {
const addButton = $("<span>")
.addClass("bx bx-plus pointer")
.prop("title", "Add new attribute")
.on('click', async () => {
const $new = await this.createPromotedAttributeCell(definitionAttr, {
attributeId: "",
type: valueAttr.type,
name: valueName,
value: ""
}, valueName);
$wrapper.after($new);
$new.find('input').trigger('focus');
});
const removeButton = $("<span>")
.addClass("bx bx-trash pointer")
.prop("title", "Remove this attribute")
.on('click', async () => {
if (valueAttr.attributeId) {
await server.remove("notes/" + this.noteId + "/attributes/" + valueAttr.attributeId, this.componentId);
}
$wrapper.remove();
});
$multiplicityCell
.append(" &nbsp;")
.append(addButton)
.append(" &nbsp;")
.append(removeButton);
}
return $wrapper;
}
async promotedAttributeChanged(event) {
const $attr = $(event.target);
let value;
if ($attr.prop("type") === "checkbox") {
value = $attr.is(':checked') ? "true" : "false";
}
else if ($attr.prop("attribute-type") === "relation") {
const selectedPath = $attr.getSelectedNotePath();
value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : "";
}
else {
value = $attr.val();
}
const result = await server.put(`notes/${this.noteId}/attribute`, {
attributeId: $attr.prop("attribute-id"),
type: $attr.prop("attribute-type"),
name: $attr.prop("attribute-name"),
value: value
}, this.componentId);
$attr.prop("attribute-id", result.attributeId);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes(this.componentId).find(attr => attr.isAffecting(this.note))) {
this.refresh();
this.getTitle(this.note);
this.triggerCommand('refreshSectionContainer');
}
}
}

View File

@@ -0,0 +1,344 @@
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 DeleteNoteSearchAction from "../search_actions/delete_note.js";
import DeleteLabelSearchAction from "../search_actions/delete_label.js";
import DeleteRelationSearchAction from "../search_actions/delete_relation.js";
import RenameLabelSearchAction from "../search_actions/rename_label.js";
import SetLabelValueSearchAction from "../search_actions/set_label_value.js";
import SetRelationTargetSearchAction from "../search_actions/set_relation_target.js";
import RenameRelationSearchAction from "../search_actions/rename_relation.js";
import ExecuteScriptSearchAction from "../search_actions/execute_script.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 DeleteNoteRevisionsSearchAction from "../search_actions/delete_note_revisions.js";
import Debug from "../search_options/debug.js";
const TPL = `
<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 */
}
</style>
<div class="search-settings">
<table class="search-setting-table">
<tr>
<td class="title-column">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>
search string
</button>
<button type="button" class="btn btn-sm" data-search-option-add="searchScript">
<span class="bx bx-code"></span>
search script
</button>
<button type="button" class="btn btn-sm" data-search-option-add="ancestor">
<span class="bx bx-filter-alt"></span>
ancestor
</button>
<button type="button" class="btn btn-sm" data-search-option-add="fastSearch"
title="Fast search option disables full text search of note contents which might speed up searching in large databases.">
<span class="bx bx-run"></span>
fast search
</button>
<button type="button" class="btn btn-sm" data-search-option-add="includeArchivedNotes"
title="Archived notes are by default excluded from search results, with this option they will be included.">
<span class="bx bx-archive"></span>
include archived
</button>
<button type="button" class="btn btn-sm" data-search-option-add="orderBy">
<span class="bx bx-arrow-from-top"></span>
order by
</button>
<button type="button" class="btn btn-sm" data-search-option-add="limit" title="Limit number of results">
<span class="bx bx-stop"></span>
limit
</button>
<button type="button" class="btn btn-sm" data-search-option-add="debug" title="Debug will print extra debugging information into the console to aid in debugging complex queries">
<span class="bx bx-bug"></span>
debug
</button>
<div class="dropdown" style="display: inline-block;">
<button class="btn btn-sm dropdown-toggle action-add-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="bx bxs-zap"></span>
action
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#" data-action-add="deleteNote">
Delete note</a>
<a class="dropdown-item" href="#" data-action-add="deleteNoteRevisions">
Delete note revisions</a>
<a class="dropdown-item" href="#" data-action-add="deleteLabel">
Delete label</a>
<a class="dropdown-item" href="#" data-action-add="deleteRelation">
Delete relation</a>
<a class="dropdown-item" href="#" data-action-add="renameLabel">
Rename label</a>
<a class="dropdown-item" href="#" data-action-add="renameRelation">
Rename relation</a>
<a class="dropdown-item" href="#" data-action-add="setLabelValue">
Set label value</a>
<a class="dropdown-item" href="#" data-action-add="setRelationTarget">
Set relation target</a>
<a class="dropdown-item" href="#" data-action-add="executeScript">
Execute script</a>
</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>
Search
<kbd>enter</kbd>
</button>
<button type="button" class="btn btn-sm search-and-execute-button">
<span class="bx bxs-zap"></span>
Search & Execute actions
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>`;
const OPTION_CLASSES = [
SearchString,
SearchScript,
Ancestor,
FastSearch,
IncludeArchivedNotes,
OrderBy,
Limit,
Debug
];
const ACTION_CLASSES = {};
for (const clazz of [
DeleteNoteSearchAction,
DeleteNoteRevisionsSearchAction,
DeleteLabelSearchAction,
DeleteRelationSearchAction,
RenameLabelSearchAction,
RenameRelationSearchAction,
SetLabelValueSearchAction,
SetRelationTargetSearchAction,
ExecuteScriptSearchAction
]) {
ACTION_CLASSES[clazz.actionName] = clazz;
}
export default class SearchDefinitionWidget extends NoteContextAwareWidget {
static getType() { return "search"; }
isEnabled() {
return this.note && this.note.type === 'search';
}
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: 'Search parameters',
icon: 'bx bx-search'
};
}
doRender() {
this.$widget = $(TPL);
this.$component = this.$widget.find('.search-definition-widget');
this.contentSized();
this.overflowing();
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) {
await clazz.create(this.noteId);
}
else {
logError(`Unknown search option ${searchOptionName}`);
}
this.refresh();
});
this.$widget.on('click', '[data-action-add]', async event => {
const actionName = $(event.target).attr('data-action-add');
await server.post(`notes/${this.noteId}/attributes`, {
type: 'label',
name: 'action',
value: JSON.stringify({
name: actionName
})
});
this.$widget.find('.action-add-toggle').dropdown('toggle');
await ws.waitForMaxKnownEntityChangeId();
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());
}
async refreshResultsCommand() {
try {
await froca.loadSearchNote(this.noteId);
}
catch (e) {
toastService.showError(e.message);
}
this.triggerEvent('searchRefreshed', {ntxId: this.noteContext.ntxId});
}
async refreshSearchDefinitionCommand() {
await this.refresh();
}
async refreshWithNote(note) {
this.$component.show();
this.$searchOptions.empty();
for (const OptionClass of OPTION_CLASSES) {
const {attributeType, optionName} = OptionClass;
const attr = this.note.getAttribute(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);
this.$searchOptions.append(searchOption.render());
}
}
this.$actionOptions.empty();
const actionLabels = this.note.getLabels('action');
for (const actionAttr of actionLabels) {
let actionDef;
try {
actionDef = JSON.parse(actionAttr.value);
}
catch (e) {
logError(`Parsing of attribute: '${actionAttr.value}' failed with error: ${e.message}`);
continue;
}
const ActionClass = ACTION_CLASSES[actionDef.name];
if (!ActionClass) {
logError(`No action class for '${actionDef.name}' found.`);
continue;
}
const action = new ActionClass(actionAttr, actionDef).setParent(this);
this.child(action);
this.$actionOptions.append(action.render());
}
this.$searchAndExecuteButton.css('visibility', actionLabels.length > 0 ? 'visible' : 'hidden');
}
getContent() {
return '';
}
async searchAndExecute() {
await server.post(`search-and-execute-note/${this.noteId}`);
this.triggerCommand('refreshResults');
toastService.showMessage('Actions have been executed.', 3000);
}
}

View File

@@ -0,0 +1,98 @@
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";
const TPL = `
<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>
`;
export default class SimilarNotesWidget extends NoteContextAwareWidget {
static getType() { return "similar-notes"; }
isEnabled() {
return super.isEnabled()
&& this.note.type !== 'search'
&& !this.note.hasLabel('similarNotesWidgetDisabled');
}
getTitle() {
return {
show: this.isEnabled(),
title: 'Similar Notes',
icon: 'bx bx-bar-chart'
};
}
doRender() {
this.$widget = $(TPL);
this.overflowing();
this.$similarNotesWrapper = this.$widget.find(".similar-notes-wrapper");
}
async refreshWithNote() {
// remember which title was when we found the similar notes
this.title = this.note.title;
const similarNotes = await server.get('similar-notes/' + this.noteId);
if (similarNotes.length === 0) {
this.$similarNotesWrapper.empty().append("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.createNoteLink(similarNote.notePath.join("/")))
.css("font-size", 24 * (1 - 1 / (1 + similarNote.score)));
$list.append($item);
}
this.$similarNotesWrapper.empty().append($list);
}
entitiesReloadedEvent({loadResults}) {
if (this.note && this.title !== this.note.title) {
this.rendered = false;
this.refresh();
}
}
}