Merge branch 'master' into trilium-docs-noformat

This commit is contained in:
Rai
2022-08-27 13:45:22 -07:00
397 changed files with 22756 additions and 12994 deletions

View File

@@ -5,6 +5,7 @@ const favicon = require('serve-favicon');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const session = require('express-session');
const compression = require('compression')
const FileStore = require('session-file-store')(session);
const sessionSecret = require('./services/session_secret');
const dataDir = require('./services/data_dir');
@@ -18,9 +19,14 @@ const app = express();
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
if (!utils.isElectron()) {
app.use(compression()); // HTTP compression
}
app.use(helmet({
hidePoweredBy: false, // errors out in electron
contentSecurityPolicy: false
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
}));
app.use(express.text({limit: '500mb'}));

View File

@@ -14,16 +14,19 @@ let becca = null;
* Base class for all backend entities.
*/
class AbstractEntity {
/** @protected */
beforeSaving() {
this.generateIdIfNecessary();
}
/** @protected */
generateIdIfNecessary() {
if (!this[this.constructor.primaryKeyName]) {
this[this.constructor.primaryKeyName] = utils.newEntityId();
}
}
/** @protected */
generateHash(isDeleted = false) {
let contentToHash = "";
@@ -38,10 +41,12 @@ class AbstractEntity {
return utils.hash(contentToHash).substr(0, 10);
}
/** @protected */
getUtcDateChanged() {
return this.utcDateModified || this.utcDateCreated;
}
/** @protected */
get becca() {
if (!becca) {
becca = require('../becca');
@@ -50,6 +55,7 @@ class AbstractEntity {
return becca;
}
/** @protected */
addEntityChange(isDeleted = false) {
entityChangesService.addEntityChange({
entityName: this.constructor.entityName,
@@ -61,6 +67,7 @@ class AbstractEntity {
});
}
/** @protected */
getPojoToSave() {
return this.getPojo();
}

View File

@@ -62,6 +62,7 @@ class Attribute extends AbstractEntity {
return this;
}
init() {
if (this.attributeId) {
this.becca.attributes[this.attributeId] = this;

View File

@@ -5,9 +5,9 @@ const AbstractEntity = require("./abstract_entity");
const sql = require("../../services/sql");
const dateUtils = require("../../services/date_utils");
const utils = require("../../services/utils.js");
const TaskContext = require("../../services/task_context.js");
const cls = require("../../services/cls.js");
const log = require("../../services/log.js");
const TaskContext = require("../../services/task_context");
const cls = require("../../services/cls");
const log = require("../../services/log");
/**
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
@@ -70,21 +70,22 @@ class Branch extends AbstractEntity {
this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
const childNote = this.childNote;
if (!childNote.parentBranches.includes(this)) {
childNote.parentBranches.push(this);
}
if (this.branchId === 'root') {
return;
}
const childNote = this.childNote;
const parentNote = this.parentNote;
if (!childNote.parents.includes(parentNote)) {
childNote.parents.push(parentNote);
}
if (!childNote.parentBranches.includes(this)) {
childNote.parentBranches.push(this);
}
if (!parentNote.children.includes(childNote)) {
parentNote.children.push(childNote);
}
@@ -104,9 +105,9 @@ class Branch extends AbstractEntity {
return this.childNote;
}
/** @returns {Note} */
/** @returns {Note|undefined} - root branch will have undefined parent, all other branches have to have a parent note */
get parentNote() {
if (!(this.parentNoteId in this.becca.notes)) {
if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== 'none') {
// entities can come out of order in sync/import, create skeleton which will be filled later
this.becca.addNote(this.parentNoteId, new Note({noteId: this.parentNoteId}));
}
@@ -137,6 +138,18 @@ class Branch extends AbstractEntity {
taskContext.increaseProgressCount();
const note = this.getNote();
if (!taskContext.noteDeletionHandlerTriggered) {
const parentBranches = note.getParentBranches();
if (parentBranches.length === 1 && parentBranches[0] === this) {
// needs to be run before branches and attributes are deleted and thus attached relations disappear
const handlers = require("../../services/handlers");
handlers.runAttachedRelations(note, 'runOnNoteDeletion', note);
}
}
if (this.branchId === 'root'
|| this.noteId === 'root'
|| this.noteId === cls.getHoistedNoteId()) {
@@ -146,7 +159,6 @@ class Branch extends AbstractEntity {
this.markAsDeleted(deleteId);
const note = this.getNote();
const notDeletedBranches = note.getParentBranches();
if (notDeletedBranches.length === 0) {

View File

@@ -8,7 +8,10 @@ const dateUtils = require('../../services/date_utils');
const entityChangesService = require('../../services/entity_changes');
const AbstractEntity = require("./abstract_entity");
const NoteRevision = require("./note_revision");
const TaskContext = require("../../services/task_context.js");
const TaskContext = require("../../services/task_context");
const dayjs = require("dayjs");
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)
const LABEL = 'label';
const RELATION = 'relation';
@@ -84,13 +87,17 @@ class Note extends AbstractEntity {
}
init() {
/** @type {Branch[]} */
/** @type {Branch[]}
* @private */
this.parentBranches = [];
/** @type {Note[]} */
/** @type {Note[]}
* @private */
this.parents = [];
/** @type {Note[]} */
/** @type {Note[]}
* @private*/
this.children = [];
/** @type {Attribute[]} */
/** @type {Attribute[]}
* @private */
this.ownedAttributes = [];
/** @type {Attribute[]|null}
@@ -100,7 +107,8 @@ class Note extends AbstractEntity {
* @private*/
this.inheritableAttributeCache = null;
/** @type {Attribute[]} */
/** @type {Attribute[]}
* @private*/
this.targetRelations = [];
this.becca.addNote(this.noteId, this);
@@ -114,16 +122,19 @@ class Note extends AbstractEntity {
/**
* size of the content in bytes
* @type {int|null}
* @private
*/
this.contentSize = null;
/**
* size of the content and note revision contents in bytes
* @type {int|null}
* @private
*/
this.noteSize = null;
/**
* number of note revisions for this note
* @type {int|null}
* @private
*/
this.revisionCount = null;
}
@@ -225,6 +236,22 @@ class Note extends AbstractEntity {
WHERE noteId = ?`, [this.noteId]);
}
get dateCreatedObj() {
return this.dateCreated === null ? null : dayjs(this.dateCreated);
}
get utcDateCreatedObj() {
return this.utcDateCreated === null ? null : dayjs.utc(this.utcDateCreated);
}
get dateModifiedObj() {
return this.dateModified === null ? null : dayjs(this.dateModified);
}
get utcDateModifiedObj() {
return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
}
/** @returns {*} */
getJsonContent() {
const content = this.getContent();
@@ -350,6 +377,7 @@ class Note extends AbstractEntity {
}
}
/** @private */
__getAttributes(path) {
if (path.includes(this.noteId)) {
return [];
@@ -372,7 +400,11 @@ class Note extends AbstractEntity {
const templateNote = this.becca.notes[ownedAttr.value];
if (templateNote) {
templateAttributes.push(...templateNote.__getAttributes(newPath));
templateAttributes.push(
...templateNote.__getAttributes(newPath)
// template attr is used as a marker for templates, but it's not meant to be inherited
.filter(attr => !(attr.type === 'label' && attr.name === 'template'))
);
}
}
}
@@ -401,7 +433,10 @@ class Note extends AbstractEntity {
return this.__attributeCache;
}
/** @returns {Attribute[]} */
/**
* @private
* @returns {Attribute[]}
*/
__getInheritableAttributes(path) {
if (path.includes(this.noteId)) {
return [];
@@ -414,8 +449,18 @@ class Note extends AbstractEntity {
return this.inheritableAttributeCache;
}
hasAttribute(type, name) {
return !!this.getAttributes().find(attr => attr.type === type && attr.name === name);
/**
* @param type
* @param name
* @param [value]
* @returns {boolean}
*/
hasAttribute(type, name, value) {
return !!this.getAttributes().find(attr =>
attr.type === type
&& attr.name === name
&& (value === undefined || value === null || attr.value === value)
);
}
getAttributeCaseInsensitive(type, name, value) {
@@ -436,27 +481,31 @@ class Note extends AbstractEntity {
/**
* @param {string} name - label name
* @param {string} [value] - label value
* @returns {boolean} true if label exists (including inherited)
*/
hasLabel(name) { return this.hasAttribute(LABEL, name); }
hasLabel(name, value) { return this.hasAttribute(LABEL, name, value); }
/**
* @param {string} name - label name
* @param {string} [value] - label value
* @returns {boolean} true if label exists (excluding inherited)
*/
hasOwnedLabel(name) { return this.hasOwnedAttribute(LABEL, name); }
hasOwnedLabel(name, value) { return this.hasOwnedAttribute(LABEL, name, value); }
/**
* @param {string} name - relation name
* @param {string} [value] - relation value
* @returns {boolean} true if relation exists (including inherited)
*/
hasRelation(name) { return this.hasAttribute(RELATION, name); }
hasRelation(name, value) { return this.hasAttribute(RELATION, name, value); }
/**
* @param {string} name - relation name
* @param {string} [value] - relation value
* @returns {boolean} true if relation exists (excluding inherited)
*/
hasOwnedRelation(name) { return this.hasOwnedAttribute(RELATION, name); }
hasOwnedRelation(name, value) { return this.hasOwnedAttribute(RELATION, name, value); }
/**
* @param {string} name - label name
@@ -509,10 +558,11 @@ class Note extends AbstractEntity {
/**
* @param {string} type - attribute type (label, relation, etc.)
* @param {string} name - attribute name
* @param {string} [value] - attribute value
* @returns {boolean} true if note has an attribute with given type and name (excluding inherited)
*/
hasOwnedAttribute(type, name) {
return !!this.getOwnedAttribute(type, name);
hasOwnedAttribute(type, name, value) {
return !!this.getOwnedAttribute(type, name, value);
}
/**
@@ -599,15 +649,19 @@ class Note extends AbstractEntity {
/**
* @param {string} [type] - (optional) attribute type to filter
* @param {string} [name] - (optional) attribute name to filter
* @param {string} [value] - (optional) attribute value to filter
* @returns {Attribute[]} note's "owned" attributes - excluding inherited ones
*/
getOwnedAttributes(type, name) {
getOwnedAttributes(type, name, value) {
// it's a common mistake to include # or ~ into attribute name
if (name && ["#", "~"].includes(name[0])) {
name = name.substr(1);
}
if (type && name) {
if (type && name && value !== undefined && value !== null) {
return this.ownedAttributes.filter(attr => attr.type === type && attr.name === name && attr.value === value);
}
else if (type && name) {
return this.ownedAttributes.filter(attr => attr.type === type && attr.name === name);
}
else if (type) {
@@ -626,8 +680,8 @@ class Note extends AbstractEntity {
*
* This method can be significantly faster than the getAttribute()
*/
getOwnedAttribute(type, name) {
const attrs = this.getOwnedAttributes(type, name);
getOwnedAttribute(type, name, value) {
const attrs = this.getOwnedAttributes(type, name, value);
return attrs.length > 0 ? attrs[0] : null;
}
@@ -645,9 +699,11 @@ class Note extends AbstractEntity {
sortParents() {
this.parentBranches.sort((a, b) =>
a.branchId.startsWith('virt-')
|| a.parentNote.hasInheritableOwnedArchivedLabel() ? 1 : -1);
|| a.parentNote?.hasInheritableOwnedArchivedLabel() ? 1 : -1);
this.parents = this.parentBranches.map(branch => branch.parentNote);
this.parents = this.parentBranches
.map(branch => branch.parentNote)
.filter(note => !!note);
}
/**
@@ -1142,6 +1198,10 @@ class Note extends AbstractEntity {
return this.searchNotesInSubtree(searchString)[0];
}
/**
* @param parentNoteId
* @returns {{success: boolean, message: string}}
*/
cloneTo(parentNoteId) {
const cloningService = require("../../services/cloning");
@@ -1157,6 +1217,10 @@ class Note extends AbstractEntity {
* @param {TaskContext} [taskContext]
*/
deleteNote(deleteId, taskContext) {
if (this.isDeleted) {
return;
}
if (!deleteId) {
deleteId = utils.randomString(10);
}
@@ -1165,6 +1229,11 @@ class Note extends AbstractEntity {
taskContext = new TaskContext('no-progress-reporting');
}
// needs to be run before branches and attributes are deleted and thus attached relations disappear
const handlers = require("../../services/handlers");
handlers.runAttachedRelations(this, 'runOnNoteDeletion', this);
taskContext.noteDeletionHandlerTriggered = true;
for (const branch of this.getParentBranches()) {
branch.deleteBranch(deleteId, taskContext);
}
@@ -1188,6 +1257,41 @@ class Note extends AbstractEntity {
return !(this.noteId in this.becca.notes);
}
/**
* @return {NoteRevision|null}
*/
saveNoteRevision() {
const content = this.getContent();
if (!content || (Buffer.isBuffer(content) && content.byteLength === 0)) {
return null;
}
const contentMetadata = this.getContentMetadata();
const noteRevision = new NoteRevision({
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
utcDateLastEdited: this.utcDateModified > contentMetadata.utcDateModified
? this.utcDateModified
: contentMetadata.utcDateModified,
utcDateCreated: dateUtils.utcNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime(),
dateLastEdited: this.dateModified > contentMetadata.dateModified
? this.dateModified
: contentMetadata.dateModified,
dateCreated: dateUtils.localNowDateTime()
}, true).save();
noteRevision.setContent(content);
return noteRevision;
}
beforeSaving() {
super.beforeSaving();

View File

@@ -19,7 +19,7 @@ class NoteRevision extends AbstractEntity {
static get primaryKeyName() { return "noteRevisionId"; }
static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified"]; }
constructor(row) {
constructor(row, titleDecrypted = false) {
super();
/** @type {string} */
@@ -47,13 +47,10 @@ class NoteRevision extends AbstractEntity {
/** @type {number} */
this.contentLength = row.contentLength;
if (this.isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
this.title = protectedSessionService.decryptString(this.title);
}
else {
this.title = "[protected]";
}
if (this.isProtected && !titleDecrypted) {
this.title = protectedSessionService.isProtectedSessionAvailable()
? protectedSessionService.decryptString(this.title)
: "[protected]";
}
}
@@ -68,8 +65,8 @@ class NoteRevision extends AbstractEntity {
/*
* Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
* part of NoteRevision entity with it's own sync. Reason behind this hybrid design is that
* content can be quite large and it's not necessary to load it / fill memory for any note access even
* part of NoteRevision entity with its own sync. Reason behind this hybrid design is that
* content can be quite large, and it's not necessary to load it / fill memory for any note access even
* if we don't need a content, especially for bulk operations like search.
*
* This is the same approach as is used for Note's content.

View File

@@ -281,7 +281,7 @@ async function findSimilarNotes(noteId) {
}
function gatherAncestorRewards(note) {
if (ancestorNoteIds.has(note.noteId)) {
if (!note || ancestorNoteIds.has(note.noteId)) {
return 0;
}

View File

@@ -3,8 +3,8 @@ const eu = require("./etapi_utils");
const passwordEncryptionService = require("../services/password_encryption");
const etapiTokenService = require("../services/etapi_tokens");
function register(router) {
eu.NOT_AUTHENTICATED_ROUTE(router, 'post', '/etapi/auth/login', (req, res, next) => {
function register(router, loginMiddleware) {
eu.NOT_AUTHENTICATED_ROUTE(router, 'post', '/etapi/auth/login', loginMiddleware, (req, res, next) => {
const {password, tokenName} = req.body;
if (!passwordEncryptionService.verifyPassword(password)) {

View File

@@ -2,8 +2,6 @@ const becca = require("../becca/becca");
const eu = require("./etapi_utils");
const mappers = require("./mappers");
const Branch = require("../becca/entities/branch");
const noteService = require("../services/notes");
const TaskContext = require("../services/task_context");
const entityChangesService = require("../services/entity_changes");
const v = require("./validators");

View File

@@ -228,6 +228,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/notes/{noteId}/export:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: '#/components/schemas/EntityId'
- name: format
in: query
required: false
schema:
enum:
- html
- markdown
default: html
get:
description: Exports ZIP file export of a given note subtree. To export whole document, use "root" for noteId
operationId: exportNoteSubtree
responses:
'200':
description: export ZIP file
content:
application/zip:
schema:
type: string
format: binary
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/branches/{branchId}:
parameters:
- name: branchId
@@ -570,6 +602,8 @@ paths:
authToken:
type: string
example: Bc4bFn0Ffiok_4NpbVCDnFz7B2WU+pdhW8B5Ne3DiR5wXrEyqdjgRIsk=
'429':
description: Client IP has been blacklisted because too many requests (possibly failed authentications) were made within a short time frame, try again later
default:
description: unexpected error
content:

View File

@@ -66,8 +66,8 @@ function route(router, method, path, routeHandler) {
router[method](path, checkEtapiAuth, (req, res, next) => processRequest(req, res, routeHandler, next, method, path));
}
function NOT_AUTHENTICATED_ROUTE(router, method, path, routeHandler) {
router[method](path, (req, res, next) => processRequest(req, res, routeHandler, next, method, path));
function NOT_AUTHENTICATED_ROUTE(router, method, path, middleware, routeHandler) {
router[method](path, ...middleware, (req, res, next) => processRequest(req, res, routeHandler, next, method, path));
}
function getAndCheckNote(noteId) {
@@ -106,7 +106,7 @@ function getAndCheckAttribute(attributeId) {
function validateAndPatch(target, source, allowedProperties) {
for (const key of Object.keys(source)) {
if (!(key in allowedProperties)) {
throw new EtapiError(400, "PROPERTY_NOT_ALLOWED", `Property '${key}' is not allowed for PATCH.`);
throw new EtapiError(400, "PROPERTY_NOT_ALLOWED", `Property '${key}' is not allowed for this method.`);
}
else {
for (const validator of allowedProperties[key]) {

View File

@@ -7,6 +7,7 @@ const TaskContext = require("../services/task_context");
const v = require("./validators");
const searchService = require("../services/search/services/search");
const SearchContext = require("../services/search/search_context");
const zipExportService = require("../services/export/zip");
function register(router) {
eu.route(router, 'get', '/etapi/notes', (req, res, next) => {
@@ -123,6 +124,25 @@ function register(router) {
return res.sendStatus(204);
});
eu.route(router, 'get' ,'/etapi/notes/:noteId/export', (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const format = req.query.format || "html";
if (!["html", "markdown"].includes(format)) {
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'`);
}
const taskContext = new TaskContext('no-progress-reporting');
// technically a branch is being exported (includes prefix), but it's such a minor difference yet usability pain
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
const branch = note.getParentBranches()[0];
console.log(note.getParentBranches());
zipExportService.exportToZip(taskContext, branch, format, res);
});
}
function parseSearchParams(req) {

View File

@@ -52,13 +52,13 @@ if (utils.isElectron()) {
title: suggestion,
command: "replaceMisspelling",
spellingSuggestion: suggestion,
uiIcon: "empty"
uiIcon: "bx bx-empty"
});
}
items.push({
title: `Add "${params.misspelledWord}" to dictionary`,
uiIcon: "plus",
uiIcon: "bx bx-plus",
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
});
@@ -69,7 +69,7 @@ if (utils.isElectron()) {
items.push({
enabled: editFlags.canCut && hasText,
title: `Cut <kbd>${platformModifier}+X`,
uiIcon: "cut",
uiIcon: "bx bx-cut",
handler: () => webContents.cut()
});
}
@@ -78,7 +78,7 @@ if (utils.isElectron()) {
items.push({
enabled: editFlags.canCopy && hasText,
title: `Copy <kbd>${platformModifier}+C`,
uiIcon: "copy",
uiIcon: "bx bx-copy",
handler: () => webContents.copy()
});
}
@@ -86,7 +86,7 @@ if (utils.isElectron()) {
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === 'none') {
items.push({
title: `Copy link`,
uiIcon: "copy",
uiIcon: "bx bx-copy",
handler: () => {
electron.clipboard.write({
bookmark: params.linkText,
@@ -100,7 +100,7 @@ if (utils.isElectron()) {
items.push({
enabled: editFlags.canPaste,
title: `Paste <kbd>${platformModifier}+V`,
uiIcon: "paste",
uiIcon: "bx bx-paste",
handler: () => webContents.paste()
});
}
@@ -109,7 +109,7 @@ if (utils.isElectron()) {
items.push({
enabled: editFlags.canPaste,
title: `Paste as plain text <kbd>${platformModifier}+Shift+V`,
uiIcon: "paste",
uiIcon: "bx bx-paste",
handler: () => webContents.pasteAndMatchStyle()
});
}
@@ -122,7 +122,7 @@ if (utils.isElectron()) {
items.push({
enabled: editFlags.canPaste,
title: `Search for "${shortenedSelection}" with DuckDuckGo`,
uiIcon: "search-alt",
uiIcon: "bx bx-search-alt",
handler: () => electron.shell.openExternal(`https://duckduckgo.com/?q=${encodeURIComponent(params.selectionText)}`)
});
}

View File

@@ -1,24 +0,0 @@
import server from "../services/server.js";
import utils from "../services/utils.js";
const $dialog = $("#about-dialog");
const $appVersion = $("#app-version");
const $dbVersion = $("#db-version");
const $syncVersion = $("#sync-version");
const $buildDate = $("#build-date");
const $buildRevision = $("#build-revision");
const $dataDirectory = $("#data-directory");
export async function showDialog() {
const appInfo = await server.get('app-info');
$appVersion.text(appInfo.appVersion);
$dbVersion.text(appInfo.dbVersion);
$syncVersion.text(appInfo.syncVersion);
$buildDate.text(appInfo.buildDate);
$buildRevision.text(appInfo.buildRevision);
$buildRevision.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.buildRevision);
$dataDirectory.text(appInfo.dataDirectory);
utils.openDialog($dialog);
}

View File

@@ -1,131 +0,0 @@
import treeService from '../services/tree.js';
import noteAutocompleteService from "../services/note_autocomplete.js";
import utils from "../services/utils.js";
const $dialog = $("#add-link-dialog");
const $form = $("#add-link-form");
const $autoComplete = $("#add-link-note-autocomplete");
const $linkTitle = $("#link-title");
const $addLinkTitleSettings = $("#add-link-title-settings");
const $addLinkTitleRadios = $(".add-link-title-radios");
const $addLinkTitleFormGroup = $("#add-link-title-form-group");
/** @var TextTypeWidget */
let textTypeWidget;
export async function showDialog(widget, text = '') {
textTypeWidget = widget;
$addLinkTitleSettings.toggle(!textTypeWidget.hasSelection());
$addLinkTitleSettings.find('input[type=radio]').on('change', updateTitleSettingsVisibility);
// with selection hyper link is implied
if (textTypeWidget.hasSelection()) {
$addLinkTitleSettings.find("input[value='hyper-link']").prop("checked", true);
}
else {
$addLinkTitleSettings.find("input[value='reference-link']").prop("checked", true);
}
updateTitleSettingsVisibility();
utils.openDialog($dialog);
$autoComplete.val('');
$linkTitle.val('');
async function setDefaultLinkTitle(noteId) {
const noteTitle = await treeService.getNoteTitle(noteId);
$linkTitle.val(noteTitle);
}
noteAutocompleteService.initNoteAutocomplete($autoComplete, {
allowExternalLinks: true,
allowCreatingNotes: true
});
$autoComplete.on('autocomplete:noteselected', (event, suggestion, dataset) => {
if (!suggestion.notePath) {
return false;
}
updateTitleSettingsVisibility();
const noteId = treeService.getNoteIdFromNotePath(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
});
$autoComplete.on('autocomplete:externallinkselected', (event, suggestion, dataset) => {
if (!suggestion.externalLink) {
return false;
}
updateTitleSettingsVisibility();
$linkTitle.val(suggestion.externalLink);
});
$autoComplete.on('autocomplete:cursorchanged', function(event, suggestion, dataset) {
if (suggestion.externalLink) {
$linkTitle.val(suggestion.externalLink)
}
else {
const noteId = treeService.getNoteIdFromNotePath(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
}
});
if (text && text.trim()) {
noteAutocompleteService.setText($autoComplete, text);
}
else {
noteAutocompleteService.showRecentNotes($autoComplete);
}
$autoComplete
.trigger('focus')
.trigger('select'); // to be able to quickly remove entered text
}
function getLinkType() {
if ($autoComplete.getSelectedExternalLink()) {
return 'external-link';
}
return $addLinkTitleSettings.find('input[type=radio]:checked').val();
}
function updateTitleSettingsVisibility() {
const linkType = getLinkType();
$addLinkTitleFormGroup.toggle(linkType !== 'reference-link');
$addLinkTitleRadios.toggle(linkType !== 'external-link')
}
$form.on('submit', () => {
if ($autoComplete.getSelectedNotePath()) {
$dialog.modal('hide');
const linkTitle = getLinkType() === 'reference-link' ? null : $linkTitle.val();
textTypeWidget.addLink($autoComplete.getSelectedNotePath(), linkTitle);
}
else if ($autoComplete.getSelectedExternalLink()) {
$dialog.modal('hide');
textTypeWidget.addLink($autoComplete.getSelectedExternalLink(), $linkTitle.val());
}
else {
logError("No link to add.");
}
return false;
});

View File

@@ -1,28 +0,0 @@
import server from "../services/server.js";
import utils from "../services/utils.js";
const $dialog = $("#backend-log-dialog");
const $backendLogTextArea = $("#backend-log-textarea");
const $refreshBackendLog = $("#refresh-backend-log-button");
export async function showDialog() {
utils.openDialog($dialog);
load();
}
function scrollToBottom() {
$backendLogTextArea.scrollTop($backendLogTextArea[0].scrollHeight);
}
async function load() {
const backendLog = await server.get('backend-log');
$backendLogTextArea.text(backendLog);
scrollToBottom();
}
$refreshBackendLog.on('click', load);
$dialog.on('shown.bs.modal', scrollToBottom);

View File

@@ -1,59 +0,0 @@
import treeService from '../services/tree.js';
import server from '../services/server.js';
import froca from "../services/froca.js";
import toastService from "../services/toast.js";
import utils from "../services/utils.js";
const $dialog = $("#branch-prefix-dialog");
const $form = $("#branch-prefix-form");
const $treePrefixInput = $("#branch-prefix-input");
const $noteTitle = $('#branch-prefix-note-title');
let branchId;
export async function showDialog(notePath) {
const {noteId, parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(notePath);
if (!noteId || !parentNoteId) {
return;
}
branchId = await froca.getBranchId(parentNoteId, noteId);
const branch = froca.getBranch(branchId);
if (!branch || branch.noteId === 'root') {
return;
}
const parentNote = await froca.getNote(branch.parentNoteId);
if (parentNote.type === 'search') {
return;
}
utils.openDialog($dialog);
$treePrefixInput.val(branch.prefix);
const noteTitle = await treeService.getNoteTitle(noteId);
$noteTitle.text(" - " + noteTitle);
}
async function savePrefix() {
const prefix = $treePrefixInput.val();
await server.put('branches/' + branchId + '/set-prefix', { prefix: prefix });
$dialog.modal('hide');
toastService.showMessage("Branch prefix has been saved.");
}
$form.on('submit', () => {
savePrefix();
return false;
});
$dialog.on('shown.bs.modal', () => $treePrefixInput.trigger('focus'));

View File

@@ -1,73 +0,0 @@
import noteAutocompleteService from "../services/note_autocomplete.js";
import utils from "../services/utils.js";
import treeService from "../services/tree.js";
import toastService from "../services/toast.js";
import froca from "../services/froca.js";
import branchService from "../services/branches.js";
import appContext from "../services/app_context.js";
const $dialog = $("#clone-to-dialog");
const $form = $("#clone-to-form");
const $noteAutoComplete = $("#clone-to-note-autocomplete");
const $clonePrefix = $("#clone-prefix");
const $noteList = $("#clone-to-note-list");
let clonedNoteIds;
export async function showDialog(noteIds) {
if (!noteIds || noteIds.length === 0) {
noteIds = [ appContext.tabManager.getActiveContextNoteId() ];
}
clonedNoteIds = [];
for (const noteId of noteIds) {
if (!clonedNoteIds.includes(noteId)) {
clonedNoteIds.push(noteId);
}
}
utils.openDialog($dialog);
$noteAutoComplete.val('').trigger('focus');
$noteList.empty();
for (const noteId of clonedNoteIds) {
const note = await froca.getNote(noteId);
$noteList.append($("<li>").text(note.title));
}
noteAutocompleteService.initNoteAutocomplete($noteAutoComplete);
noteAutocompleteService.showRecentNotes($noteAutoComplete);
}
async function cloneNotesTo(notePath) {
const {noteId, parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(notePath);
const targetBranchId = await froca.getBranchId(parentNoteId, noteId);
for (const cloneNoteId of clonedNoteIds) {
await branchService.cloneNoteToBranch(cloneNoteId, targetBranchId, $clonePrefix.val());
const clonedNote = await froca.getNote(cloneNoteId);
const targetNote = await froca.getBranch(targetBranchId).getNote();
toastService.showMessage(`Note "${clonedNote.title}" has been cloned into ${targetNote.title}`);
}
}
$form.on('submit', () => {
const notePath = $noteAutoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
cloneNotesTo(notePath);
}
else {
logError("No path to clone to.");
}
return false;
});

View File

@@ -1,80 +0,0 @@
const $dialog = $("#confirm-dialog");
const $confirmContent = $("#confirm-dialog-content");
const $okButton = $("#confirm-dialog-ok-button");
const $cancelButton = $("#confirm-dialog-cancel-button");
const $custom = $("#confirm-dialog-custom");
const DELETE_NOTE_BUTTON_ID = "confirm-dialog-delete-note";
let resolve;
let $originallyFocused; // element focused before the dialog was opened so we can return to it afterwards
export function confirm(message) {
$originallyFocused = $(':focus');
$custom.hide();
glob.activeDialog = $dialog;
if (typeof message === 'string') {
message = $("<div>").text(message);
}
$confirmContent.empty().append(message);
$dialog.modal();
return new Promise((res, rej) => { resolve = res; });
}
export function confirmDeleteNoteBoxWithNote(title) {
glob.activeDialog = $dialog;
$confirmContent.text(`Are you sure you want to remove the note "${title}" from relation map?`);
$custom.empty()
.append("<br/>")
.append($("<div>").addClass("form-check")
.append($("<input>")
.attr("id", DELETE_NOTE_BUTTON_ID)
.attr("type", "checkbox")
.addClass("form-check-input"))
.append($("<label>")
.attr("for", DELETE_NOTE_BUTTON_ID)
.addClass("form-check-label")
.attr("style", "text-decoration: underline dotted black")
.attr("title", "If you don't check this, note will be only removed from relation map, but will stay as a note.")
.html("Also delete note")));
$custom.show();
$dialog.modal();
return new Promise((res, rej) => { resolve = res; });
}
export function isDeleteNoteChecked() {
return $("#" + DELETE_NOTE_BUTTON_ID + ":checked").length > 0;
}
$dialog.on('shown.bs.modal', () => $okButton.trigger("focus"));
$dialog.on("hidden.bs.modal", () => {
if (resolve) {
resolve(false);
}
if ($originallyFocused) {
$originallyFocused.trigger('focus');
$originallyFocused = null;
}
});
function doResolve(ret) {
resolve(ret);
resolve = null;
$dialog.modal("hide");
}
$cancelButton.on('click', () => doResolve(false));
$okButton.on('click', () => doResolve(true));

View File

@@ -1,99 +0,0 @@
import server from "../services/server.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import utils from "../services/utils.js";
const $dialog = $("#delete-notes-dialog");
const $okButton = $("#delete-notes-dialog-ok-button");
const $cancelButton = $("#delete-notes-dialog-cancel-button");
const $deleteNotesList = $("#delete-notes-list");
const $brokenRelationsList = $("#broken-relations-list");
const $deletedNotesCount = $("#deleted-notes-count");
const $noNoteToDeleteWrapper = $("#no-note-to-delete-wrapper");
const $deleteNotesListWrapper = $("#delete-notes-list-wrapper");
const $brokenRelationsListWrapper = $("#broken-relations-wrapper");
const $brokenRelationsCount = $("#broke-relations-count");
const $deleteAllClones = $("#delete-all-clones");
const $eraseNotes = $("#erase-notes");
let branchIds = null;
let resolve = null;
async function renderDeletePreview() {
const response = await server.post('delete-notes-preview', {
branchIdsToDelete: branchIds,
deleteAllClones: isDeleteAllClonesChecked()
});
$deleteNotesList.empty();
$brokenRelationsList.empty();
$deleteNotesListWrapper.toggle(response.noteIdsToBeDeleted.length > 0);
$noNoteToDeleteWrapper.toggle(response.noteIdsToBeDeleted.length === 0);
for (const note of await froca.getNotes(response.noteIdsToBeDeleted)) {
$deleteNotesList.append(
$("<li>").append(
await linkService.createNoteLink(note.noteId, {showNotePath: true})
)
);
}
$deletedNotesCount.text(response.noteIdsToBeDeleted.length);
$brokenRelationsListWrapper.toggle(response.brokenRelations.length > 0);
$brokenRelationsCount.text(response.brokenRelations.length);
await froca.getNotes(response.brokenRelations.map(br => br.noteId));
for (const attr of response.brokenRelations) {
$brokenRelationsList.append(
$("<li>")
.append(`Note `)
.append(await linkService.createNoteLink(attr.value))
.append(` (to be deleted) is referenced by relation <code>${attr.name}</code> originating from `)
.append(await linkService.createNoteLink(attr.noteId))
);
}
}
export async function showDialog(branchIdsToDelete) {
branchIds = branchIdsToDelete;
await renderDeletePreview();
utils.openDialog($dialog);
$deleteAllClones.prop("checked", false);
$eraseNotes.prop("checked", false);
return new Promise((res, rej) => resolve = res);
}
export function isDeleteAllClonesChecked() {
return $deleteAllClones.is(":checked");
}
export function isEraseNotesChecked() {
return $eraseNotes.is(":checked");
}
$dialog.on('shown.bs.modal', () => $okButton.trigger("focus"));
$cancelButton.on('click', () => {
utils.closeActiveDialog();
resolve({proceed: false});
});
$okButton.on('click', () => {
utils.closeActiveDialog();
resolve({
proceed: true,
deleteAllClones: isDeleteAllClonesChecked(),
eraseNotes: isEraseNotesChecked()
});
});
$deleteAllClones.on('click', () => renderDeletePreview());

View File

@@ -1,137 +0,0 @@
import treeService from "../services/tree.js";
import utils from "../services/utils.js";
import ws from "../services/ws.js";
import toastService from "../services/toast.js";
import froca from "../services/froca.js";
import openService from "../services/open.js";
const $dialog = $("#export-dialog");
const $form = $("#export-form");
const $noteTitle = $dialog.find(".export-note-title");
const $subtreeFormats = $("#export-subtree-formats");
const $singleFormats = $("#export-single-formats");
const $subtreeType = $("#export-type-subtree");
const $singleType = $("#export-type-single");
const $exportButton = $("#export-button");
const $opmlVersions = $("#opml-versions");
let taskId = '';
let branchId = null;
export async function showDialog(notePath, defaultType) {
// each opening of the dialog resets the taskId so we don't associate it with previous exports anymore
taskId = '';
$exportButton.removeAttr("disabled");
if (defaultType === 'subtree') {
$subtreeType.prop("checked", true).trigger('change');
// to show/hide OPML versions
$("input[name=export-subtree-format]:checked").trigger('change');
}
else if (defaultType === 'single') {
$singleType.prop("checked", true).trigger('change');
}
else {
throw new Error("Unrecognized type " + defaultType);
}
$("#opml-v2").prop("checked", true); // setting default
utils.openDialog($dialog);
const {noteId, parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(notePath);
branchId = await froca.getBranchId(parentNoteId, noteId);
const noteTitle = await treeService.getNoteTitle(noteId);
$noteTitle.html(noteTitle);
}
$form.on('submit', () => {
$dialog.modal('hide');
const exportType = $dialog.find("input[name='export-type']:checked").val();
if (!exportType) {
// this shouldn't happen as we always choose default export type
alert("Choose export type first please");
return;
}
const exportFormat = exportType === 'subtree'
? $("input[name=export-subtree-format]:checked").val()
: $("input[name=export-single-format]:checked").val();
const exportVersion = exportFormat === 'opml' ? $dialog.find("input[name='opml-version']:checked").val() : "1.0";
exportBranch(branchId, exportType, exportFormat, exportVersion);
return false;
});
function exportBranch(branchId, type, format, version) {
taskId = utils.randomString(10);
const url = openService.getUrlForDownload(`api/notes/${branchId}/export/${type}/${format}/${version}/${taskId}`);
openService.download(url);
}
$('input[name=export-type]').on('change', function () {
if (this.value === 'subtree') {
if ($("input[name=export-subtree-format]:checked").length === 0) {
$("input[name=export-subtree-format]:first").prop("checked", true);
}
$subtreeFormats.slideDown();
$singleFormats.slideUp();
}
else {
if ($("input[name=export-single-format]:checked").length === 0) {
$("input[name=export-single-format]:first").prop("checked", true);
}
$subtreeFormats.slideUp();
$singleFormats.slideDown();
}
});
$('input[name=export-subtree-format]').on('change', function () {
if (this.value === 'opml') {
$opmlVersions.slideDown();
}
else {
$opmlVersions.slideUp();
}
});
function makeToast(id, message) {
return {
id: id,
title: "Export status",
message: message,
icon: "arrow-square-up-right"
};
}
ws.subscribeToMessages(async message => {
if (message.taskType !== 'export') {
return;
}
if (message.type === 'taskError') {
toastService.closePersistent(message.taskId);
toastService.showError(message.message);
}
else if (message.type === 'taskProgressCount') {
toastService.showPersistent(makeToast(message.taskId, "Export in progress: " + message.progressCount));
}
else if (message.type === 'taskSucceeded') {
const toast = makeToast(message.taskId, "Export finished successfully.");
toast.closeAfter = 5000;
toastService.showPersistent(toast);
}
});

View File

@@ -1,7 +0,0 @@
import utils from "../services/utils.js";
const $dialog = $("#help-dialog");
export async function showDialog() {
utils.openDialog($dialog);
}

View File

@@ -1,76 +0,0 @@
import utils from '../services/utils.js';
import treeService from "../services/tree.js";
import importService from "../services/import.js";
import options from "../services/options.js";
const $dialog = $("#import-dialog");
const $form = $("#import-form");
const $noteTitle = $dialog.find(".import-note-title");
const $fileUploadInput = $("#import-file-upload-input");
const $importButton = $("#import-button");
const $safeImportCheckbox = $("#safe-import-checkbox");
const $shrinkImagesWrapper = $("shrink-images-wrapper");
const $shrinkImagesCheckbox = $("#shrink-images-checkbox");
const $textImportedAsTextCheckbox = $("#text-imported-as-text-checkbox");
const $codeImportedAsCodeCheckbox = $("#code-imported-as-code-checkbox");
const $explodeArchivesCheckbox = $("#explode-archives-checkbox");
const $replaceUnderscoresWithSpacesCheckbox = $("#replace-underscores-with-spaces-checkbox");
const $csrf = $("#import-csrf");
let parentNoteId = null;
export async function showDialog(noteId) {
$fileUploadInput.val('').trigger('change'); // to trigger Import button disabling listener below
$safeImportCheckbox.prop("checked", true);
$shrinkImagesCheckbox.prop("checked", options.is('compressImages'));
$textImportedAsTextCheckbox.prop("checked", true);
$codeImportedAsCodeCheckbox.prop("checked", true);
$explodeArchivesCheckbox.prop("checked", true);
$replaceUnderscoresWithSpacesCheckbox.prop("checked", true);
parentNoteId = noteId;
$noteTitle.text(await treeService.getNoteTitle(parentNoteId));
utils.openDialog($dialog);
}
$form.on('submit', () => {
// disabling so that import is not triggered again.
$importButton.attr("disabled", "disabled");
importIntoNote(parentNoteId);
return false;
});
async function importIntoNote(parentNoteId) {
const files = Array.from($fileUploadInput[0].files); // shallow copy since we're resetting the upload button below
const options = {
safeImport: boolToString($safeImportCheckbox),
shrinkImages: boolToString($shrinkImagesCheckbox),
textImportedAsText: boolToString($textImportedAsTextCheckbox),
codeImportedAsCode: boolToString($codeImportedAsCodeCheckbox),
explodeArchives: boolToString($explodeArchivesCheckbox),
replaceUnderscoresWithSpaces: boolToString($replaceUnderscoresWithSpacesCheckbox)
};
$dialog.modal('hide');
await importService.uploadFiles(parentNoteId, files, options);
}
function boolToString($el) {
return $el.is(":checked") ? "true" : "false";
}
$fileUploadInput.on('change', () => {
if ($fileUploadInput.val()) {
$importButton.removeAttr("disabled");
}
else {
$importButton.attr("disabled", "disabled");
}
});

View File

@@ -1,56 +0,0 @@
import treeService from '../services/tree.js';
import noteAutocompleteService from '../services/note_autocomplete.js';
import utils from "../services/utils.js";
import froca from "../services/froca.js";
const $dialog = $("#include-note-dialog");
const $form = $("#include-note-form");
const $autoComplete = $("#include-note-autocomplete");
/** @var TextTypeWidget */
let textTypeWidget;
export async function showDialog(widget) {
textTypeWidget = widget;
$autoComplete.val('');
utils.openDialog($dialog);
noteAutocompleteService.initNoteAutocomplete($autoComplete, {
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true
});
noteAutocompleteService.showRecentNotes($autoComplete);
}
async function includeNote(notePath) {
const noteId = treeService.getNoteIdFromNotePath(notePath);
const note = await froca.getNote(noteId);
const boxSize = $("input[name='include-note-box-size']:checked").val();
if (note.type === 'image') {
// there's no benefit to use insert note functionlity for images
// so we'll just add an IMG tag
textTypeWidget.addImage(noteId);
}
else {
textTypeWidget.addIncludeNote(noteId, boxSize);
}
}
$form.on('submit', () => {
const notePath = $autoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
includeNote(notePath);
}
else {
logError("No noteId to include.");
}
return false;
});

View File

@@ -1,33 +0,0 @@
import utils from "../services/utils.js";
const $dialog = $("#info-dialog");
const $infoContent = $("#info-dialog-content");
const $okButton = $("#info-dialog-ok-button");
let resolve;
let $originallyFocused; // element focused before the dialog was opened so we can return to it afterwards
export function info(message) {
$originallyFocused = $(':focus');
$infoContent.text(message);
utils.openDialog($dialog);
return new Promise((res, rej) => { resolve = res; });
}
$dialog.on('shown.bs.modal', () => $okButton.trigger("focus"));
$dialog.on("hidden.bs.modal", () => {
if (resolve) {
resolve();
}
if ($originallyFocused) {
$originallyFocused.trigger('focus');
$originallyFocused = null;
}
});
$okButton.on('click', () => $dialog.modal("hide"));

View File

@@ -1,60 +0,0 @@
import noteAutocompleteService from '../services/note_autocomplete.js';
import utils from "../services/utils.js";
import appContext from "../services/app_context.js";
const $dialog = $("#jump-to-note-dialog");
const $autoComplete = $("#jump-to-note-autocomplete");
const $showInFullTextButton = $("#show-in-full-text-button");
let lastOpenedTs = 0;
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
export async function showDialog() {
utils.openDialog($dialog);
noteAutocompleteService.initNoteAutocomplete($autoComplete, { hideGoToSelectedNoteButton: true })
// clear any event listener added in previous invocation of this function
.off('autocomplete:noteselected')
.on('autocomplete:noteselected', function(event, suggestion, dataset) {
if (!suggestion.notePath) {
return false;
}
appContext.tabManager.getActiveContext().setNote(suggestion.notePath);
});
// if you open the Jump To dialog soon after using it previously it can often mean that you
// actually want to search for the same thing (e.g. you opened the wrong note at first try)
// so we'll keep the content.
// if it's outside of this time limit then we assume it's a completely new search and show recent notes instead.
if (Date.now() - lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
noteAutocompleteService.showRecentNotes($autoComplete);
}
else {
$autoComplete
// hack, the actual search value is stored in <pre> element next to the search input
// this is important because the search input value is replaced with the suggestion note's title
.autocomplete("val", $autoComplete.next().text())
.trigger('focus')
.trigger('select');
}
lastOpenedTs = Date.now();
}
function showInFullText(e) {
// stop from propagating upwards (dangerous especially with ctrl+enter executable javascript notes)
e.preventDefault();
e.stopPropagation();
const searchString = $autoComplete.val();
appContext.triggerCommand('searchNotes', {searchString});
$dialog.modal('hide');
}
$showInFullTextButton.on('click', showInFullText);
utils.bindElShortcut($dialog, 'ctrl+return', showInFullText);

View File

@@ -1,61 +0,0 @@
import libraryLoader from "../services/library_loader.js";
import toastService from "../services/toast.js";
import utils from "../services/utils.js";
import appContext from "../services/app_context.js";
const $dialog = $('#markdown-import-dialog');
const $importTextarea = $('#markdown-import-textarea');
const $importButton = $('#markdown-import-button');
async function convertMarkdownToHtml(text) {
await libraryLoader.requireLibrary(libraryLoader.COMMONMARK);
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
const parsed = reader.parse(text);
const result = writer.render(parsed);
appContext.triggerCommand('executeInActiveTextEditor', {
callback: textEditor => {
const viewFragment = textEditor.data.processor.toView(result);
const modelFragment = textEditor.data.toModel(viewFragment);
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
toastService.showMessage("Markdown content has been imported into the document.");
}
});
}
export async function importMarkdownInline() {
if (appContext.tabManager.getActiveContextNoteType() !== 'text') {
return;
}
if (utils.isElectron()) {
const {clipboard} = utils.dynamicRequire('electron');
const text = clipboard.readText();
convertMarkdownToHtml(text);
}
else {
utils.openDialog($dialog);
}
}
async function sendForm() {
const text = $importTextarea.val();
$dialog.modal('hide');
await convertMarkdownToHtml(text);
$importTextarea.val('');
}
$importButton.on('click', sendForm);
$dialog.on('shown.bs.modal', () => $importTextarea.trigger('focus'));
utils.bindElShortcut($dialog, 'ctrl+return', sendForm);

View File

@@ -1,58 +0,0 @@
import noteAutocompleteService from "../services/note_autocomplete.js";
import utils from "../services/utils.js";
import toastService from "../services/toast.js";
import froca from "../services/froca.js";
import branchService from "../services/branches.js";
import treeService from "../services/tree.js";
const $dialog = $("#move-to-dialog");
const $form = $("#move-to-form");
const $noteAutoComplete = $("#move-to-note-autocomplete");
const $noteList = $("#move-to-note-list");
let movedBranchIds;
export async function showDialog(branchIds) {
movedBranchIds = branchIds;
utils.openDialog($dialog);
$noteAutoComplete.val('').trigger('focus');
$noteList.empty();
for (const branchId of movedBranchIds) {
const branch = froca.getBranch(branchId);
const note = await froca.getNote(branch.noteId);
$noteList.append($("<li>").text(note.title));
}
noteAutocompleteService.initNoteAutocomplete($noteAutoComplete);
noteAutocompleteService.showRecentNotes($noteAutoComplete);
}
async function moveNotesTo(parentBranchId) {
await branchService.moveToParentNote(movedBranchIds, parentBranchId);
const parentBranch = froca.getBranch(parentBranchId);
const parentNote = await parentBranch.getNote();
toastService.showMessage(`Selected notes have been moved into ${parentNote.title}`);
}
$form.on('submit', () => {
const notePath = $noteAutoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
const {noteId, parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(notePath);
froca.getBranchId(parentNoteId, noteId).then(branchId => moveNotesTo(branchId));
}
else {
logError("No path to move to.");
}
return false;
});

View File

@@ -1,225 +0,0 @@
import utils from '../services/utils.js';
import server from '../services/server.js';
import toastService from "../services/toast.js";
import appContext from "../services/app_context.js";
import libraryLoader from "../services/library_loader.js";
import openService from "../services/open.js";
const $dialog = $("#note-revisions-dialog");
const $list = $("#note-revision-list");
const $listDropdown = $("#note-revision-list-dropdown");
const $content = $("#note-revision-content");
const $title = $("#note-revision-title");
const $titleButtons = $("#note-revision-title-buttons");
const $eraseAllRevisionsButton = $("#note-revisions-erase-all-revisions-button");
$listDropdown.dropdown();
$listDropdown.parent().on('hide.bs.dropdown', e => {
// prevent closing dropdown by clicking outside
if (e.clickEvent) {
e.preventDefault();
}
});
let revisionItems = [];
let note;
let noteRevisionId;
export async function showCurrentNoteRevisions() {
await showNoteRevisionsDialog(appContext.tabManager.getActiveContextNoteId());
}
export async function showNoteRevisionsDialog(noteId, noteRevisionId) {
utils.openDialog($dialog);
await loadNoteRevisions(noteId, noteRevisionId);
}
async function loadNoteRevisions(noteId, noteRevId) {
$list.empty();
$content.empty();
$titleButtons.empty();
note = appContext.tabManager.getActiveContextNote();
revisionItems = await server.get(`notes/${noteId}/revisions`);
for (const item of revisionItems) {
$list.append(
$('<a class="dropdown-item" tabindex="0">')
.text(item.dateLastEdited.substr(0, 16) + ` (${item.contentLength} bytes)`)
.attr('data-note-revision-id', item.noteRevisionId)
.attr('title', 'This revision was last edited on ' + item.dateLastEdited)
);
}
$listDropdown.dropdown('show');
noteRevisionId = noteRevId;
if (revisionItems.length > 0) {
if (!noteRevisionId) {
noteRevisionId = revisionItems[0].noteRevisionId;
}
} else {
$title.text("No revisions for this note yet...");
noteRevisionId = null;
}
$eraseAllRevisionsButton.toggle(revisionItems.length > 0);
}
$dialog.on('shown.bs.modal', () => {
$list.find(`[data-note-revision-id="${noteRevisionId}"]`)
.trigger('focus');
});
async function setContentPane() {
const noteRevisionId = $list.find(".active").attr('data-note-revision-id');
const revisionItem = revisionItems.find(r => r.noteRevisionId === noteRevisionId);
$titleButtons.empty();
$content.empty();
$title.html(revisionItem.title);
const $restoreRevisionButton = $('<button class="btn btn-sm" type="button">Restore this revision</button>');
$restoreRevisionButton.on('click', async () => {
const confirmDialog = await import('../dialogs/confirm.js');
const text = 'Do you want to restore this revision? This will overwrite current title/content of the note with this revision.';
if (await confirmDialog.confirm(text)) {
await server.put(`notes/${revisionItem.noteId}/restore-revision/${revisionItem.noteRevisionId}`);
$dialog.modal('hide');
toastService.showMessage('Note revision has been restored.');
}
});
const $eraseRevisionButton = $('<button class="btn btn-sm" type="button">Delete this revision</button>');
$eraseRevisionButton.on('click', async () => {
const confirmDialog = await import('../dialogs/confirm.js');
const text = 'Do you want to delete this revision? This action will delete revision title and content, but still preserve revision metadata.';
if (await confirmDialog.confirm(text)) {
await server.remove(`notes/${revisionItem.noteId}/revisions/${revisionItem.noteRevisionId}`);
loadNoteRevisions(revisionItem.noteId);
toastService.showMessage('Note revision has been deleted.');
}
});
$titleButtons
.append($restoreRevisionButton)
.append(' &nbsp; ')
.append($eraseRevisionButton)
.append(' &nbsp; ');
const $downloadButton = $('<button class="btn btn-sm btn-primary" type="button">Download</button>');
$downloadButton.on('click', () => openService.downloadNoteRevision(revisionItem.noteId, revisionItem.noteRevisionId));
$titleButtons.append($downloadButton);
const fullNoteRevision = await server.get(`notes/${revisionItem.noteId}/revisions/${revisionItem.noteRevisionId}`);
if (revisionItem.type === 'text') {
$content.html(fullNoteRevision.content);
if ($content.find('span.math-tex').length > 0) {
await libraryLoader.requireLibrary(libraryLoader.KATEX);
renderMathInElement($content[0], {trust: true});
}
}
else if (revisionItem.type === 'code') {
$content.html($("<pre>").text(fullNoteRevision.content));
}
else if (revisionItem.type === 'image') {
$content.html($("<img>")
// reason why we put this inline as base64 is that we do not want to let user to copy this
// as a URL to be used in a note. Instead if they copy and paste it into a note, it will be a uploaded as a new note
.attr("src", `data:${note.mime};base64,` + fullNoteRevision.content)
.css("max-width", "100%")
.css("max-height", "100%"));
}
else if (revisionItem.type === 'file') {
const $table = $("<table cellpadding='10'>")
.append($("<tr>").append(
$("<th>").text("MIME: "),
$("<td>").text(revisionItem.mime)
))
.append($("<tr>").append(
$("<th>").text("File size:"),
$("<td>").text(revisionItem.contentLength + " bytes")
));
if (fullNoteRevision.content) {
$table.append($("<tr>").append(
$('<td colspan="2">').append(
$('<div style="font-weight: bold;">').text("Preview:"),
$('<pre class="file-preview-content"></pre>')
.text(fullNoteRevision.content)
)
));
}
$content.html($table);
}
else if (revisionItem.type === 'canvas') {
/**
* FIXME: We load a font called Virgil.wof2, which originates from excalidraw.com
* REMOVE external dependency!!!! This is defined in the svg in defs.style
*/
const content = fullNoteRevision.content;
try {
const data = JSON.parse(content)
const svg = data.svg || "no svg present."
/**
* maxWidth: 100% use full width of container but do not enlarge!
* height:auto to ensure that height scales with width
*/
const $svgHtml = $(svg).css({maxWidth: "100%", height: "auto"});
$content.html($('<div>').append($svgHtml));
} catch(err) {
console.error("error parsing fullNoteRevision.content as JSON", fullNoteRevision.content, err);
$content.html($("<div>").text("Error parsing content. Please check console.error() for more details."));
}
}
else {
$content.text("Preview isn't available for this note type.");
}
}
$eraseAllRevisionsButton.on('click', async () => {
const confirmDialog = await import('../dialogs/confirm.js');
const text = 'Do you want to delete all revisions of this note? This action will erase revision title and content, but still preserve revision metadata.';
if (await confirmDialog.confirm(text)) {
await server.remove(`notes/${note.noteId}/revisions`);
$dialog.modal('hide');
toastService.showMessage('Note revisions has been deleted.');
}
});
$list.on('click', '.dropdown-item', e => {
e.preventDefault();
return false;
});
$list.on('focus', '.dropdown-item', e => {
$list.find('.dropdown-item').each((i, el) => {
$(el).toggleClass('active', el === e.target);
});
setContentPane();
});

View File

@@ -1,40 +0,0 @@
import appContext from "../services/app_context.js";
import utils from "../services/utils.js";
const $dialog = $("#note-source-dialog");
const $noteSource = $("#note-source");
export async function showDialog() {
utils.openDialog($dialog);
const noteCompletement = await appContext.tabManager.getActiveContext().getNoteComplement();
$noteSource.text(formatHtml(noteCompletement.content));
}
function formatHtml(str) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return formatNode(div, 0).innerHTML.trim();
}
function formatNode(node, level) {
const indentBefore = new Array(level++ + 1).join(' ');
const indentAfter = new Array(level - 1).join(' ');
let textNode;
for (let i = 0; i < node.children.length; i++) {
textNode = document.createTextNode('\n' + indentBefore);
node.insertBefore(textNode, node.children[i]);
formatNode(node.children[i], level);
if (node.lastElementChild === node.children[i]) {
textNode = document.createTextNode('\n' + indentAfter);
node.appendChild(textNode);
}
}
return node;
}

View File

@@ -1,34 +0,0 @@
"use strict";
import server from '../services/server.js';
import utils from "../services/utils.js";
const $dialog = $("#options-dialog");
export async function showDialog(openTab) {
const options = await server.get('options');
utils.openDialog($dialog);
(await Promise.all([
import('./options/appearance.js'),
import('./options/shortcuts.js'),
import('./options/code_notes.js'),
import('./options/password.js'),
import('./options/etapi.js'),
import('./options/backup.js'),
import('./options/sync.js'),
import('./options/other.js'),
import('./options/advanced.js')
]))
.map(m => new m.default)
.forEach(tab => {
if (tab.optionsLoaded) {
tab.optionsLoaded(options)
}
});
if (openTab) {
$(`.nav-link[href='#options-${openTab}']`).trigger("click");
}
}

View File

@@ -1,13 +0,0 @@
import utils from "../services/utils.js";
import appContext from "../services/app_context.js";
export function show() {
const $dialog = $("#password-not-set-dialog");
const $openPasswordOptionsButton = $("#open-password-options-button");
utils.openDialog($dialog);
$openPasswordOptionsButton.on("click", () => {
appContext.triggerCommand("showOptions", { openTab: 'password' });
});
}

View File

@@ -1,59 +0,0 @@
import utils from "../services/utils.js";
const $dialog = $("#prompt-dialog");
const $dialogBody = $dialog.find(".modal-body");
let $question;
let $answer;
const $form = $("#prompt-dialog-form");
let resolve;
let shownCb;
export function ask({ title, message, defaultValue, shown }) {
shownCb = shown;
$("#prompt-title").text(title || "Prompt");
$question = $("<label>")
.prop("for", "prompt-dialog-answer")
.text(message);
$answer = $("<input>")
.prop("type", "text")
.prop("id", "prompt-dialog-answer")
.addClass("form-control")
.val(defaultValue || "");
$dialogBody.empty().append(
$("<div>")
.addClass("form-group")
.append($question)
.append($answer));
utils.openDialog($dialog, false);
return new Promise((res, rej) => { resolve = res; });
}
$dialog.on('shown.bs.modal', () => {
if (shownCb) {
shownCb({ $dialog, $question, $answer, $form });
}
$answer.trigger('focus').select();
});
$dialog.on("hidden.bs.modal", () => {
if (resolve) {
resolve(null);
}
});
$form.on('submit', e => {
e.preventDefault();
resolve($answer.val());
$dialog.modal('hide');
});

View File

@@ -1,29 +0,0 @@
import protectedSessionService from "../services/protected_session.js";
import utils from "../services/utils.js";
const $dialog = $("#protected-session-password-dialog");
const $passwordForm = $dialog.find(".protected-session-password-form");
const $passwordInput = $dialog.find(".protected-session-password");
export function show() {
utils.openDialog($dialog);
$passwordInput.trigger('focus');
}
export function close() {
// this may fail if the dialog has not been previously opened (not sure if still true with Bootstrap modal)
try {
$dialog.modal('hide');
}
catch (e) {}
}
$passwordForm.on('submit', () => {
const password = $passwordInput.val();
$passwordInput.val("");
protectedSessionService.setupProtectedSession(password);
return false;
});

View File

@@ -1,112 +0,0 @@
import linkService from '../services/link.js';
import utils from '../services/utils.js';
import server from '../services/server.js';
import treeService from "../services/tree.js";
import froca from "../services/froca.js";
import appContext from "../services/app_context.js";
import hoistedNoteService from "../services/hoisted_note.js";
const $dialog = $("#recent-changes-dialog");
const $content = $("#recent-changes-content");
export async function showDialog(ancestorNoteId) {
utils.openDialog($dialog);
if (!ancestorNoteId) {
ancestorNoteId = hoistedNoteService.getHoistedNoteId();
}
const recentChangesRows = await server.get('recent-changes/' + ancestorNoteId);
// preload all notes into cache
await froca.getNotes(recentChangesRows.map(r => r.noteId), true);
$content.empty();
if (recentChangesRows.length === 0) {
$content.append("No changes yet ...");
}
const groupedByDate = groupByDate(recentChangesRows);
for (const [dateDay, dayChanges] of groupedByDate) {
const $changesList = $('<ul>');
const dayEl = $('<div>').append($('<b>').text(dateDay)).append($changesList);
for (const change of dayChanges) {
const formattedTime = change.date.substr(11, 5);
let $noteLink;
if (change.current_isDeleted) {
$noteLink = $("<span>").text(change.current_title);
if (change.canBeUndeleted) {
const $undeleteLink = $(`<a href="javascript:">`)
.text("undelete")
.on('click', async () => {
const confirmDialog = await import('../dialogs/confirm.js');
const text = 'Do you want to undelete this note and its sub-notes?';
if (await confirmDialog.confirm(text)) {
await server.put(`notes/${change.noteId}/undelete`);
$dialog.modal('hide');
await froca.reloadNotes([change.noteId]);
appContext.tabManager.getActiveContext().setNote(change.noteId);
}
});
$noteLink
.append(' (')
.append($undeleteLink)
.append(')');
}
}
else {
const note = await froca.getNote(change.noteId);
const notePath = treeService.getSomeNotePath(note);
if (notePath) {
$noteLink = await linkService.createNoteLink(notePath, {
title: change.title,
showNotePath: true
});
}
else {
$noteLink = $("<span>").text(note.title);
}
}
$changesList.append($('<li>')
.append(
$("<span>")
.text(formattedTime)
.attr("title", change.date)
)
.append(' - ')
.append($noteLink));
}
$content.append(dayEl);
}
}
function groupByDate(rows) {
const groupedByDate = new Map();
for (const row of rows) {
const dateDay = row.date.substr(0, 10);
if (!groupedByDate.has(dateDay)) {
groupedByDate.set(dateDay, []);
}
groupedByDate.get(dateDay).push(row);
}
return groupedByDate;
}

View File

@@ -1,24 +0,0 @@
import server from "../services/server.js";
import utils from "../services/utils.js";
const $dialog = $("#sort-child-notes-dialog");
const $form = $("#sort-child-notes-form");
let parentNoteId = null;
$form.on('submit', async () => {
const sortBy = $form.find("input[name='sort-by']:checked").val();
const sortDirection = $form.find("input[name='sort-direction']:checked").val();
await server.put(`notes/${parentNoteId}/sort-children`, {sortBy, sortDirection});
utils.closeActiveDialog();
});
export async function showDialog(noteId) {
parentNoteId = noteId;
utils.openDialog($dialog);
$form.find('input:first').focus();
}

View File

@@ -33,8 +33,19 @@ class Attribute {
return this.froca.notes[this.noteId];
}
/** @returns {Promise<NoteShort>} */
async getTargetNote() {
const targetNoteId = this.targetNoteId;
return await this.froca.getNote(targetNoteId, true);
}
get targetNoteId() { // alias
return this.type === 'relation' ? this.value : undefined;
if (this.type !== 'relation') {
throw new Error(`Attribute ${this.attributeId} is not a relation`);
}
return this.value;
}
get isAutoLink() {

View File

@@ -3,6 +3,7 @@ import noteAttributeCache from "../services/note_attribute_cache.js";
import ws from "../services/ws.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
const LABEL = 'label';
const RELATION = 'relation';
@@ -17,7 +18,8 @@ const NOTE_TYPE_ICONS = {
"book": "bx bx-book",
"note-map": "bx bx-map-alt",
"mermaid": "bx bx-selection",
"canvas": "bx bx-pen"
"canvas": "bx bx-pen",
"web-view": "bx bx-globe-alt"
};
/**
@@ -262,7 +264,11 @@ class NoteShort {
const templateNote = this.froca.notes[templateAttr.value];
if (templateNote && templateNote.noteId !== this.noteId) {
attrArrs.push(templateNote.__getCachedAttributes(newPath));
attrArrs.push(
templateNote.__getCachedAttributes(newPath)
// template attr is used as a marker for templates, but it's not meant to be inherited
.filter(attr => !(attr.type === 'label' && attr.name === 'template'))
);
}
}
@@ -635,13 +641,18 @@ class NoteShort {
return [];
}
return this.getAttributes()
const promotedAttrs = this.getAttributes()
.filter(attr => attr.isDefinition())
.filter(attr => {
const def = attr.getDefinition();
return def && def.isPromoted;
});
// attrs are not resorted if position changes after initial load
promotedAttrs.sort((a, b) => a.position < b.position ? -1 : 1);
return promotedAttrs;
}
hasAncestor(ancestorNoteId, visitedNoteIds = null) {
@@ -802,6 +813,10 @@ class NoteShort {
return false;
}
isContentAvailable() {
return !this.isProtected || protectedSessionHolder.isProtectedSessionAvailable()
}
}
export default NoteShort;

View File

@@ -46,8 +46,38 @@ import OpenNoteButtonWidget from "../widgets/buttons/open_note_button_widget.js"
import MermaidWidget from "../widgets/mermaid.js";
import BookmarkButtons from "../widgets/bookmark_buttons.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import BacklinksWidget from "../widgets/backlinks.js";
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
import SharedInfoWidget from "../widgets/shared_info.js";
import FindWidget from "../widgets/find.js";
import TocWidget from "../widgets/toc.js";
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
import AboutDialog from "../widgets/dialogs/about.js";
import NoteSourceDialog from "../widgets/dialogs/note_source.js";
import HelpDialog from "../widgets/dialogs/help.js";
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
import BackendLogDialog from "../widgets/dialogs/backend_log.js";
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
import AddLinkDialog from "../widgets/dialogs/add_link.js";
import CloneToDialog from "../widgets/dialogs/clone_to.js";
import MoveToDialog from "../widgets/dialogs/move_to.js";
import ImportDialog from "../widgets/dialogs/import.js";
import ExportDialog from "../widgets/dialogs/export.js";
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
import NoteRevisionsDialog from "../widgets/dialogs/note_revisions.js";
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
import InfoDialog from "../widgets/dialogs/info.js";
import ConfirmDialog from "../widgets/dialogs/confirm.js";
import PromptDialog from "../widgets/dialogs/prompt.js";
import OptionsDialog from "../widgets/dialogs/options.js";
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
import MermaidExportButton from "../widgets/floating_buttons/mermaid_export_button.js";
export default class DesktopLayout {
constructor(customWidgets) {
@@ -150,7 +180,11 @@ export default class DesktopLayout {
)
.child(new SharedInfoWidget())
.child(new NoteUpdateStatusWidget())
.child(new BacklinksWidget())
.child(new FloatingButtons()
.child(new RelationMapButtons())
.child(new MermaidExportButton())
.child(new BacklinksWidget())
)
.child(new MermaidWidget())
.child(
new ScrollingContainer()
@@ -161,15 +195,45 @@ export default class DesktopLayout {
.child(new SearchResultWidget())
.child(new SqlResultWidget())
)
.child(...this.customWidgets.get('node-detail-pane'))
.child(new FindWidget())
.child(
...this.customWidgets.get('node-detail-pane'), // typo, let's keep it for a while as BC
...this.customWidgets.get('note-detail-pane')
)
)
)
.child(...this.customWidgets.get('center-pane'))
)
.child(new RightPaneContainer()
.child(new TocWidget())
.child(...this.customWidgets.get('right-pane'))
)
)
);
)
.child(new BulkActionsDialog())
.child(new AboutDialog())
.child(new NoteSourceDialog())
.child(new HelpDialog())
.child(new RecentChangesDialog())
.child(new BackendLogDialog())
.child(new BranchPrefixDialog())
.child(new SortChildNotesDialog())
.child(new PasswordNoteSetDialog())
.child(new IncludeNoteDialog())
.child(new NoteTypeChooserDialog())
.child(new JumpToNoteDialog())
.child(new AddLinkDialog())
.child(new CloneToDialog())
.child(new MoveToDialog())
.child(new ImportDialog())
.child(new ExportDialog())
.child(new MarkdownImportDialog())
.child(new ProtectedSessionPasswordDialog())
.child(new NoteRevisionsDialog())
.child(new DeleteNotesDialog())
.child(new InfoDialog())
.child(new ConfirmDialog())
.child(new PromptDialog())
.child(new OptionsDialog());
}
}

View File

@@ -8,6 +8,8 @@ import CloseDetailButtonWidget from "../widgets/mobile_widgets/close_detail_butt
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
import ConfirmDialog from "../widgets/dialogs/confirm.js";
const MOBILE_CSS = `
<style>
@@ -128,6 +130,8 @@ export default class MobileLayout {
.css('padding', '5px 20px 10px 0')
)
)
);
)
.child(new ProtectedSessionPasswordDialog())
.child(new ConfirmDialog());
}
}

View File

@@ -11,7 +11,6 @@ import Component from "../widgets/component.js";
import keyboardActionsService from "./keyboard_actions.js";
import MobileScreenSwitcherExecutor from "../widgets/mobile_widgets/mobile_screen_switcher.js";
import MainTreeExecutors from "./main_tree_executors.js";
import protectedSessionHolder from "./protected_session_holder.js";
import toast from "./toast.js";
class AppContext extends Component {

View File

@@ -4,13 +4,14 @@ import toastService from "./toast.js";
import froca from "./froca.js";
import hoistedNoteService from "./hoisted_note.js";
import ws from "./ws.js";
import appContext from "./app_context.js";
async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
branchIdsToMove = filterRootNote(branchIdsToMove);
branchIdsToMove = filterSearchBranches(branchIdsToMove);
if (beforeBranchId === 'root') {
alert('Cannot move notes before root note.');
toastService.showError('Cannot move notes before root note.');
return;
}
@@ -18,7 +19,7 @@ async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
const resp = await server.put(`branches/${branchIdToMove}/move-before/${beforeBranchId}`);
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
return;
}
}
@@ -31,7 +32,7 @@ async function moveAfterBranch(branchIdsToMove, afterBranchId) {
const afterNote = await froca.getBranch(afterBranchId).getNote();
if (afterNote.noteId === 'root' || afterNote.noteId === hoistedNoteService.getHoistedNoteId()) {
alert('Cannot move notes after root note.');
toastService.showError('Cannot move notes after root note.');
return;
}
@@ -41,7 +42,7 @@ async function moveAfterBranch(branchIdsToMove, afterBranchId) {
const resp = await server.put(`branches/${branchIdToMove}/move-after/${afterBranchId}`);
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
return;
}
}
@@ -61,7 +62,7 @@ async function moveToParentNote(branchIdsToMove, newParentBranchId) {
const resp = await server.put(`branches/${branchIdToMove}/move-to/${newParentBranchId}`);
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
return;
}
}
@@ -81,8 +82,8 @@ async function deleteNotes(branchIdsToDelete) {
deleteAllClones = false;
}
else {
const deleteNotesDialog = await import("../dialogs/delete_notes.js");
({proceed, deleteAllClones, eraseNotes} = await deleteNotesDialog.showDialog(branchIdsToDelete));
({proceed, deleteAllClones, eraseNotes} = await new Promise(res =>
appContext.triggerCommand('showDeleteNotesDialog', {branchIdsToDelete, callback: res})));
}
if (!proceed) {
@@ -126,7 +127,7 @@ async function moveNodeUpInHierarchy(node) {
const resp = await server.put('branches/' + node.data.branchId + '/move-after/' + node.getParent().data.branchId);
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
return;
}
@@ -202,7 +203,7 @@ async function cloneNoteToBranch(childNoteId, parentBranchId, prefix) {
});
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
}
}
@@ -212,7 +213,7 @@ async function cloneNoteToNote(childNoteId, parentNoteId, prefix) {
});
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
}
}
@@ -221,7 +222,7 @@ async function cloneNoteAfter(noteId, afterBranchId) {
const resp = await server.put('notes/' + noteId + '/clone-after/' + afterBranchId);
if (!resp.success) {
alert(resp.message);
toastService.showError(resp.message);
}
}

View File

@@ -0,0 +1,94 @@
import server from "./server.js";
import ws from "./ws.js";
import MoveNoteBulkAction from "../widgets/bulk_actions/note/move_note.js";
import DeleteNoteBulkAction from "../widgets/bulk_actions/note/delete_note.js";
import DeleteNoteRevisionsBulkAction from "../widgets/bulk_actions/note/delete_note_revisions.js";
import DeleteLabelBulkAction from "../widgets/bulk_actions/label/delete_label.js";
import DeleteRelationBulkAction from "../widgets/bulk_actions/relation/delete_relation.js";
import RenameLabelBulkAction from "../widgets/bulk_actions/label/rename_label.js";
import RenameRelationBulkAction from "../widgets/bulk_actions/relation/rename_relation.js";
import UpdateLabelValueBulkAction from "../widgets/bulk_actions/label/update_label_value.js";
import UpdateRelationTargetBulkAction from "../widgets/bulk_actions/relation/update_relation_target.js";
import ExecuteScriptBulkAction from "../widgets/bulk_actions/execute_script.js";
import AddLabelBulkAction from "../widgets/bulk_actions/label/add_label.js";
import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation.js";
import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
const ACTION_GROUPS = [
{
title: 'Labels',
actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction]
},
{
title: 'Relations',
actions: [AddRelationBulkAction, UpdateRelationTargetBulkAction, RenameRelationBulkAction, DeleteRelationBulkAction]
},
{
title: 'Notes',
actions: [RenameNoteBulkAction, MoveNoteBulkAction, DeleteNoteBulkAction, DeleteNoteRevisionsBulkAction],
},
{
title: 'Other',
actions: [ExecuteScriptBulkAction]
}
];
const ACTION_CLASSES = [
RenameNoteBulkAction,
MoveNoteBulkAction,
DeleteNoteBulkAction,
DeleteNoteRevisionsBulkAction,
DeleteLabelBulkAction,
DeleteRelationBulkAction,
RenameLabelBulkAction,
RenameRelationBulkAction,
AddLabelBulkAction,
AddRelationBulkAction,
UpdateLabelValueBulkAction,
UpdateRelationTargetBulkAction,
ExecuteScriptBulkAction
];
async function addAction(noteId, actionName) {
await server.post(`notes/${noteId}/attributes`, {
type: 'label',
name: 'action',
value: JSON.stringify({
name: actionName
})
});
await ws.waitForMaxKnownEntityChangeId();
}
function parseActions(note) {
const actionLabels = note.getLabels('action');
return actionLabels.map(actionAttr => {
let actionDef;
try {
actionDef = JSON.parse(actionAttr.value);
} catch (e) {
logError(`Parsing of attribute: '${actionAttr.value}' failed with error: ${e.message}`);
return null;
}
const ActionClass = ACTION_CLASSES.find(actionClass => actionClass.actionName === actionDef.name);
if (!ActionClass) {
logError(`No action class for '${actionDef.name}' found.`);
return null;
}
return new ActionClass(actionAttr, actionDef);
})
.filter(action => !!action);
}
export default {
addAction,
parseActions,
ACTION_CLASSES,
ACTION_GROUPS
};

View File

@@ -82,7 +82,7 @@ class ContextMenu {
const $icon = $("<span>");
if (item.uiIcon) {
$icon.addClass("bx bx-" + item.uiIcon);
$icon.addClass(item.uiIcon);
} else {
$icon.append("&nbsp;");
}

View File

@@ -0,0 +1,70 @@
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds. If `immediate` is passed, trigger the function on the
* leading edge, instead of the trailing. The function also has a property 'clear'
* that is a function which will clear the timer to prevent previously scheduled executions.
*
* @source underscore.js
* @see http://unscriptable.com/2009/03/20/debouncing-javascript-methods/
* @param {Function} function to wrap
* @param {Number} timeout in ms (`100`)
* @param {Boolean} whether to execute at the beginning (`false`)
* @api public
*/
function debounce(func, wait_ms, immediate){
var timeout, args, context, timestamp, result;
if (null == wait_ms) wait_ms = 100;
function later() {
var last = Date.now() - timestamp;
if (last < wait_ms && last >= 0) {
timeout = setTimeout(later, wait_ms - last);
} else {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
context = args = null;
}
}
};
var debounced = function(){
context = this;
args = arguments;
timestamp = Date.now();
var callNow = immediate && !timeout;
if (!timeout) timeout = setTimeout(later, wait_ms);
if (callNow) {
result = func.apply(context, args);
context = args = null;
}
return result;
};
debounced.clear = function() {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
debounced.flush = function() {
if (timeout) {
result = func.apply(context, args);
context = args = null;
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
};
// Adds compatibility for ES modules
debounce.debounce = debounce;
export default debounce;

View File

@@ -5,7 +5,6 @@ import server from "./server.js";
import appContext from "./app_context.js";
import Component from "../widgets/component.js";
import toastService from "./toast.js";
import noteCreateService from "./note_create.js";
import ws from "./ws.js";
import bundleService from "./bundle.js";
@@ -19,18 +18,6 @@ export default class Entrypoints extends Component {
jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
}
$(document).on('click', "a[data-action='note-revision']", async event => {
const linkEl = $(event.target);
const noteId = linkEl.attr('data-note-path');
const noteRevisionId = linkEl.attr('data-note-revision-id');
const attributesDialog = await import("../dialogs/note_revisions.js");
attributesDialog.showNoteRevisionsDialog(noteId, noteRevisionId);
return false;
});
}
openDevToolsCommand() {
@@ -39,29 +26,6 @@ export default class Entrypoints extends Component {
}
}
findInTextCommand() {
if (!utils.isElectron()) {
return;
}
const remote = utils.dynamicRequire('@electron/remote');
const {FindInPage} = utils.dynamicRequire('electron-find');
const findInPage = new FindInPage(remote.getCurrentWebContents(), {
offsetTop: 10,
offsetRight: 10,
boxBgColor: 'var(--main-background-color)',
boxShadowColor: '#000',
inputColor: 'var(--input-text-color)',
inputBgColor: 'var(--input-background-color)',
inputFocusColor: '#555',
textColor: 'var(--main-text-color)',
textHoverBgColor: '#555',
caseSelectedColor: 'var(--main-border-color)'
});
findInPage.openFindWindow();
}
async createNoteIntoInboxCommand() {
const inboxNote = await dateNoteService.getInboxNote();
@@ -215,7 +179,7 @@ export default class Entrypoints extends Component {
const resp = await server.post("sql/execute/" + note.noteId);
if (!resp.success) {
alert("Error occurred while executing SQL query: " + resp.message);
toastService.showError("Error occurred while executing SQL query: " + resp.message);
}
await appContext.triggerEvent('sqlQueryResults', {ntxId: ntxId, results: resp.results});

View File

@@ -176,7 +176,7 @@ class Froca {
return;
}
const searchResultNoteIds = await server.get('search-note/' + note.noteId);
const {searchResultNoteIds, highlightedTokens} = await server.get('search-note/' + note.noteId);
if (!Array.isArray(searchResultNoteIds)) {
throw new Error(`Search note '${note.noteId}' failed: ${searchResultNoteIds}`);
@@ -207,6 +207,7 @@ class Froca {
});
froca.notes[note.noteId].searchResultsLoaded = true;
froca.notes[note.noteId].highlightedTokens = highlightedTokens;
}
/** @returns {NoteShort[]} */

View File

@@ -101,6 +101,26 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
}
};
/**
* Open a note in a new split.
*
* @param {string} notePath (or noteId)
* @param {boolean} activate - set to true to activate the new split, false to stay on the current split
* @return {Promise<void>}
*/
this.openSplitWithNote = async (notePath, activate) => {
await ws.waitForMaxKnownEntityChangeId();
const subContexts = appContext.tabManager.getActiveContext().getSubContexts();
const {ntxId} = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", {ntxId, notePath});
if (activate) {
appContext.triggerEvent('focusAndSelectTitle');
}
};
/**
* @typedef {Object} ToolbarButtonOptions
* @property {string} title
@@ -339,25 +359,61 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
/**
* Adds given text to the editor cursor
*
* @deprecated use addTextToActiveContextEditor() instead
* @param {string} text - this must be clear text, HTML is not supported.
* @method
*/
this.addTextToActiveTabEditor = text => appContext.triggerCommand('addTextToActiveEditor', {text});
this.addTextToActiveTabEditor = text => {
console.warn("api.addTextToActiveTabEditor() is deprecated, use addTextToActiveContextEditor() instead.");
return appContext.triggerCommand('addTextToActiveEditor', {text});
};
/**
* Adds given text to the editor cursor
*
* @param {string} text - this must be clear text, HTML is not supported.
* @method
*/
this.addTextToActiveContextEditor = text => appContext.triggerCommand('addTextToActiveEditor', {text});
/**
* @method
* @deprecated use getActiveContextNote() instead
* @returns {NoteShort} active note (loaded into right pane)
*/
this.getActiveTabNote = () => {
console.warn("api.getActiveTabNote() is deprecated, use getActiveContextNote() instead.");
return appContext.tabManager.getActiveContextNote();
};
/**
* @method
* @returns {NoteShort} active note (loaded into right pane)
*/
this.getActiveTabNote = () => appContext.tabManager.getActiveContextNote();
this.getActiveContextNote = () => appContext.tabManager.getActiveContextNote();
/**
* See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for a documentation on the returned instance.
*
* @deprecated use getActiveContextTextEditor()
* @method
* @param [callback] - callback receiving "textEditor" instance
*/
this.getActiveTabTextEditor = callback => {
console.warn("api.getActiveTabTextEditor() is deprecated, use getActiveContextTextEditor() instead.");
return appContext.tabManager.getActiveContext()?.getTextEditor(callback);
};
/**
* See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for a documentation on the returned instance.
*
* @method
* @param [callback] - deprecated (use returned promise): callback receiving "textEditor" instance
* @returns {Promise<CKEditor>} instance of CKEditor
*/
this.getActiveTabTextEditor = callback => new Promise(resolve => appContext.triggerCommand('executeInActiveTextEditor', {callback, resolve}));
this.getActiveContextTextEditor = () => appContext.tabManager.getActiveContext()?.getTextEditor();
/**
* See https://codemirror.net/doc/manual.html#api
@@ -365,7 +421,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
* @method
* @returns {Promise<CodeMirror>} instance of CodeMirror
*/
this.getActiveTabCodeEditor = () => new Promise(resolve => appContext.triggerCommand('executeInActiveCodeEditor', {callback: resolve}));
this.getActiveContextCodeEditor = () => appContext.tabManager.getActiveContext()?.getCodeEditor();
/**
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
@@ -378,9 +434,20 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
/**
* @method
* @deprecated use getActiveContextNotePath() instead
* @returns {Promise<string|null>} returns note path of active note or null if there isn't active note
*/
this.getActiveTabNotePath = () => appContext.tabManager.getActiveContextNotePath();
this.getActiveTabNotePath = () => {
console.warn("api.getActiveTabNotePath() is deprecated, use getActiveContextNotePath() instead.");
return appContext.tabManager.getActiveContextNotePath();
};
/**
* @method
* @returns {Promise<string|null>} returns note path of active note or null if there isn't active note
*/
this.getActiveContextNotePath = () => appContext.tabManager.getActiveContextNotePath();
/**
* Returns component which owns given DOM element (the nearest parent component in DOM tree)

View File

@@ -23,11 +23,7 @@ function setupGlobs() {
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
// for CKEditor integration (button on block toolbar)
window.glob.importMarkdownInline = async () => {
const dialog = await import("../dialogs/markdown_import.js");
dialog.importMarkdownInline();
};
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
window.glob.SEARCH_HELP_TEXT = `
<strong>Search tips</strong> - also see <button class="btn btn-sm" type="button" data-help-page="Search">complete help on search</button>

View File

@@ -1,5 +1,6 @@
import appContext from "./app_context.js";
import treeService from "./tree.js";
import dialogService from "../widgets/dialog.js";
function getHoistedNoteId() {
const activeNoteContext = appContext.tabManager.getActiveContext();
@@ -36,9 +37,7 @@ async function checkNoteAccess(notePath, noteContext) {
const hoistedNoteId = noteContext.hoistedNoteId;
if (!resolvedNotePath.includes(hoistedNoteId) && !resolvedNotePath.includes("hidden")) {
const confirmDialog = await import('../dialogs/confirm.js');
if (!await confirmDialog.confirm("Requested note is outside of hoisted note subtree and you must unhoist to access the note. Do you want to proceed with unhoisting?")) {
if (!await dialogService.confirm("Requested note is outside of hoisted note subtree and you must unhoist to access the note. Do you want to proceed with unhoisting?")) {
return false;
}

View File

@@ -61,10 +61,13 @@ const EXCALIDRAW = {
"node_modules/react/umd/react.production.min.js",
"node_modules/react-dom/umd/react-dom.production.min.js",
"node_modules/@excalidraw/excalidraw/dist/excalidraw.production.min.js",
],
// css: [
// "stylesheets/somestyle.css"
// ]
]
};
const MARKJS = {
js: [
"libraries/jquery.mark.es6.min.js"
]
};
async function requireLibrary(library) {
@@ -118,5 +121,6 @@ export default {
WHEEL_ZOOM,
FORCE_GRAPH,
MERMAID,
EXCALIDRAW
EXCALIDRAW,
MARKJS
}

View File

@@ -85,11 +85,16 @@ function getNotePathFromLink($link) {
}
function goToLink(e) {
const $link = $(e.target).closest("a,.block-link");
const address = $link.attr('href');
if (address?.startsWith("data:")) {
return true;
}
e.preventDefault();
e.stopPropagation();
const $link = $(e.target).closest("a,.block-link");
const notePath = getNotePathFromLink($link);
if (notePath) {
@@ -115,8 +120,6 @@ function goToLink(e) {
|| $link.hasClass("ck-link-actions__preview") // within edit link dialog single click suffices
|| $link.closest("[contenteditable]").length === 0 // outside of CKEditor single click suffices
) {
const address = $link.attr('href');
if (address) {
if (address.toLowerCase().startsWith('http')) {
window.open(address, '_blank');
@@ -178,6 +181,16 @@ $(document).on('dblclick', "a", e => {
}
});
$(document).on('mousedown', 'a', e => {
if (e.which === 2) {
// prevent paste on middle click
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
e.preventDefault();
return false;
}
});
export default {
getNotePathFromUrl,
createNoteLink,

View File

@@ -6,9 +6,9 @@ function openContextMenu(notePath, e) {
x: e.pageX,
y: e.pageY,
items: [
{title: "Open note in a new tab", command: "openNoteInNewTab", uiIcon: "empty"},
{title: "Open note in a new split", command: "openNoteInNewSplit", uiIcon: "dock-right"},
{title: "Open note in a new window", command: "openNoteInNewWindow", uiIcon: "window-open"}
{title: "Open note in a new tab", command: "openNoteInNewTab", uiIcon: "bx bx-empty"},
{title: "Open note in a new split", command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right"},
{title: "Open note in a new window", command: "openNoteInNewWindow", uiIcon: "bx bx-window-open"}
],
selectMenuItemHandler: ({command}) => {
if (command === 'openNoteInNewTab') {

View File

@@ -5,7 +5,7 @@ import noteCreateService from './note_create.js';
import treeService from './tree.js';
import froca from "./froca.js";
// this key needs to have this value so it's hit by the tooltip
// this key needs to have this value, so it's hit by the tooltip
const SELECTED_NOTE_PATH_KEY = "data-note-path";
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
@@ -89,6 +89,11 @@ function showRecentNotes($el) {
$el.setSelectedNotePath("");
$el.autocomplete("val", "");
$el.trigger('focus');
// simulate pressing down arrow to trigger autocomplete
const e = $.Event('keydown');
e.which = 40; // arrow down
$el.trigger(e);
}
function initNoteAutocomplete($el, options) {
@@ -140,7 +145,9 @@ function initNoteAutocomplete($el, options) {
appendTo: document.querySelector('body'),
hint: false,
autoselect: true,
openOnFocus: true,
// openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces
// re-querying of the autocomplete source which then changes currently selected suggestion
openOnFocus: false,
minLength: 0,
tabAutocomplete: false
}, [
@@ -170,9 +177,17 @@ function initNoteAutocomplete($el, options) {
}
if (suggestion.action === 'create-note') {
const {success, noteType, templateNoteId} = await noteCreateService.chooseNoteType();
if (!success) {
return;
}
const {note} = await noteCreateService.createNote(suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false
activate: false,
type: noteType,
templateNoteId: templateNoteId
});
suggestion.notePath = treeService.getSomeNotePath(note);
@@ -261,7 +276,6 @@ function init() {
}
export default {
autocompleteSource,
autocompleteSourceForCKEditor,
initNoteAutocomplete,
showRecentNotes,

View File

@@ -61,9 +61,11 @@ async function getRenderedContent(note, options = {}) {
$renderedContent.append($("<pre>").text(trim(fullNote.content, options.trim)));
}
else if (type === 'image') {
const sanitizedTitle = note.title.replace(/[^a-z0-9-.]/gi, "");
$renderedContent.append(
$("<img>")
.attr("src", `api/images/${note.noteId}/${note.title}`)
.attr("src", `api/images/${note.noteId}/${sanitizedTitle}`)
.css("max-width", "100%")
);
}
@@ -144,7 +146,7 @@ async function getRenderedContent(note, options = {}) {
else if (type === 'canvas') {
// make sure surrounding container has size of what is visible. Then image is shrinked to its boundaries
$renderedContent.css({height: "100%", width:"100%"});
const noteComplement = await froca.getNoteComplement(note.noteId);
const content = noteComplement.content || "";

View File

@@ -226,6 +226,35 @@ class NoteContext extends Component {
&& this.note.mime !== 'text/x-sqlite;schema=trilium'
&& !this.note.hasLabel('hideChildrenOverview');
}
async getTextEditor(callback) {
return new Promise(resolve => appContext.triggerCommand('executeWithTextEditor', {
callback,
resolve,
ntxId: this.ntxId
}));
}
async getCodeEditor() {
return new Promise(resolve => appContext.triggerCommand('executeWithCodeEditor', {
resolve,
ntxId: this.ntxId
}));
}
async getContentElement() {
return new Promise(resolve => appContext.triggerCommand('executeWithContentElement', {
resolve,
ntxId: this.ntxId
}));
}
async getTypeWidget() {
return new Promise(resolve => appContext.triggerCommand('executeWithTypeWidget', {
resolve,
ntxId: this.ntxId
}));
}
}
export default NoteContext;

View File

@@ -24,8 +24,8 @@ async function createNote(parentNotePath, options = {}) {
options.saveSelection = false;
}
if (options.saveSelection && utils.isCKEditorInitialized()) {
[options.title, options.content] = parseSelectedHtml(window.cutToNote.getSelectedHtml());
if (options.saveSelection) {
[options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml());
}
const parentNoteId = treeService.getNoteIdFromNotePath(parentNotePath);
@@ -43,12 +43,13 @@ async function createNote(parentNotePath, options = {}) {
content: options.content || "",
isProtected: options.isProtected,
type: options.type,
mime: options.mime
mime: options.mime,
templateNoteId: options.templateNoteId
});
if (options.saveSelection && utils.isCKEditorInitialized()) {
if (options.saveSelection) {
// we remove the selection only after it was saved to server to make sure we don't lose anything
window.cutToNote.removeSelection();
options.textEditor.removeSelection();
}
await ws.waitForMaxKnownEntityChangeId();
@@ -74,6 +75,25 @@ async function createNote(parentNotePath, options = {}) {
};
}
async function chooseNoteType() {
return new Promise(res => {
appContext.triggerCommand("chooseNoteType", {callback: res});
});
}
async function createNoteWithTypePrompt(parentNotePath, options = {}) {
const {success, noteType, templateNoteId} = await chooseNoteType();
if (!success) {
return;
}
options.type = noteType;
options.templateNoteId = templateNoteId;
return await createNote(parentNotePath, options);
}
/* If first element is heading, parse it out and use it as a new heading. */
function parseSelectedHtml(selectedHtml) {
const dom = $.parseHTML(selectedHtml);
@@ -105,5 +125,7 @@ async function duplicateSubtree(noteId, parentNotePath) {
export default {
createNote,
duplicateSubtree
createNoteWithTypePrompt,
duplicateSubtree,
chooseNoteType
};

View File

@@ -2,6 +2,7 @@ import linkService from "./link.js";
import noteContentRenderer from "./note_content_renderer.js";
import froca from "./froca.js";
import attributeRenderer from "./attribute_renderer.js";
import libraryLoader from "./library_loader.js";
const TPL = `
<div class="note-list">
@@ -60,27 +61,27 @@ const TPL = `
padding-top: 10px;
}
.note-book-title {
.note-book-header {
margin-bottom: 0;
word-break: break-all;
}
/* not-expanded title is limited to one line only */
.note-book-card:not(.expanded) .note-book-title {
.note-book-card:not(.expanded) .note-book-header {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.note-book-title .rendered-note-attributes {
.note-book-header .rendered-note-attributes {
font-size: medium;
}
.note-book-title .rendered-note-attributes:before {
.note-book-header .rendered-note-attributes:before {
content: "\\00a0\\00a0";
}
.note-book-title .note-icon {
.note-book-header .note-icon {
font-size: 100%;
display: inline-block;
padding-right: 7px;
@@ -112,7 +113,7 @@ const TPL = `
max-height: 100%;
}
.note-book-title {
.note-book-header {
flex-grow: 0;
}
@@ -198,6 +199,15 @@ class NoteListRenderer {
return;
}
const highlightedTokens = this.parentNote.highlightedTokens || [];
if (highlightedTokens.length > 0) {
await libraryLoader.requireLibrary(libraryLoader.MARKJS);
this.highlightRegex = new RegExp(highlightedTokens.join("|"), 'gi');
} else {
this.highlightRegex = null;
}
this.$noteList.show();
const $container = this.$noteList.find('.note-list-container').empty();
@@ -262,12 +272,13 @@ class NoteListRenderer {
const $card = $('<div class="note-book-card">')
.attr('data-note-id', note.noteId)
.append(
$('<h5 class="note-book-title">')
$('<h5 class="note-book-header">')
.append($expander)
.append($('<span class="note-icon">').addClass(note.getIcon()))
.append(this.viewType === 'grid'
? note.title
: await linkService.createNoteLink(notePath, {showTooltip: false, showNotePath: this.showNotePath})
? $('<span class="note-book-title">').text(note.title)
: (await linkService.createNoteLink(notePath, {showTooltip: false, showNotePath: this.showNotePath}))
.addClass("note-book-title")
)
.append($renderedAttributes)
);
@@ -281,6 +292,15 @@ class NoteListRenderer {
$expander.on('click', () => this.toggleContent($card, note, !$card.hasClass("expanded")));
if (this.highlightRegex) {
$card.find(".note-book-title").markRegExp(this.highlightRegex, {
element: "span",
className: "ck-find-result",
separateWordSearch: false,
caseSensitive: false
});
}
await this.toggleContent($card, note, expand);
return $card;
@@ -291,7 +311,7 @@ class NoteListRenderer {
return;
}
const $expander = $card.find('> .note-book-title .note-expander');
const $expander = $card.find('> .note-book-header .note-expander');
if (expand || this.viewType === 'grid') {
$card.addClass("expanded");
@@ -315,6 +335,15 @@ class NoteListRenderer {
trim: this.viewType === 'grid' // for grid only short content is needed
});
if (this.highlightRegex) {
$renderedContent.markRegExp(this.highlightRegex, {
element: "span",
className: "ck-find-result",
separateWordSearch: false,
caseSensitive: false
});
}
$content.append($renderedContent);
$content.addClass("type-" + type);
} catch (e) {

View File

@@ -22,8 +22,7 @@ async function mouseEnterHandler() {
const $link = $(this);
if ($link.hasClass("no-tooltip-preview")
|| $link.hasClass("disabled")
|| $link.attr("data-action") === 'note-revision') {
|| $link.hasClass("disabled")) {
return;
}
@@ -60,10 +59,11 @@ async function mouseEnterHandler() {
$(this).tooltip({
delay: {"show": 300, "hide": 100},
container: 'body',
placement: 'auto',
// https://github.com/zadam/trilium/issues/2794 https://github.com/zadam/trilium/issues/2988
// with bottom this flickering happens a bit less
placement: 'bottom',
trigger: 'manual',
boundary: 'window',
offset: "0, 20", // workaround for https://github.com/zadam/trilium/issues/2794
title: html,
html: true,
template: '<div class="tooltip note-tooltip" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>',

View File

@@ -0,0 +1,40 @@
import server from "./server.js";
import froca from "./froca.js";
async function getNoteTypeItems(command) {
const items = [
{ title: "Text", command: command, type: "text", uiIcon: "bx bx-note" },
{ title: "Code", command: command, type: "code", uiIcon: "bx bx-code" },
{ title: "Saved Search", command: command, type: "search", uiIcon: "bx bx-file-find" },
{ title: "Relation Map", command: command, type: "relation-map", uiIcon: "bx bx-map-alt" },
{ title: "Note Map", command: command, type: "note-map", uiIcon: "bx bx-map-alt" },
{ title: "Render Note", command: command, type: "render", uiIcon: "bx bx-extension" },
{ title: "Book", command: command, type: "book", uiIcon: "bx bx-book" },
{ title: "Mermaid Diagram", command: command, type: "mermaid", uiIcon: "bx bx-selection" },
{ title: "Canvas", command: command, type: "canvas", uiIcon: "bx bx-pen" },
{ title: "Web View", command: command, type: "web-view", uiIcon: "bx bx-globe-alt" },
];
const templateNoteIds = await server.get("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds);
if (templateNotes.length > 0) {
items.push({ title: "----" });
for (const templateNote of templateNotes) {
items.push({
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
}
}
return items;
}
export default {
getNoteTypeItems
}

View File

@@ -20,7 +20,7 @@ function enterProtectedSession() {
const dfd = $.Deferred();
if (!options.is("isPasswordSet")) {
import("../dialogs/password_not_set.js").then(dialog => dialog.show());
appContext.triggerCommand("showPasswordNotSet");
return dfd;
}
@@ -31,7 +31,7 @@ function enterProtectedSession() {
// using deferred instead of promise because it allows resolving from outside
protectedSessionDeferred = dfd;
import("../dialogs/protected_session.js").then(dialog => dialog.show());
appContext.triggerCommand("showProtectedSessionPasswordDialog");
}
return dfd.promise();
@@ -61,13 +61,13 @@ ws.subscribeToMessages(async message => {
if (message.type === 'protectedSessionLogin') {
await reloadData();
await appContext.triggerEvent('frocaReloaded');
await appContext.triggerEvent('frocaReloaded');
appContext.triggerEvent('protectedSessionStarted');
if (protectedSessionDeferred !== null) {
import("../dialogs/protected_session.js").then(dialog => dialog.close());
appContext.triggerCommand("closeProtectedSessionPasswordDialog");
if (protectedSessionDeferred !== null) {
protectedSessionDeferred.resolve(true);
protectedSessionDeferred = null;
}

View File

@@ -8,35 +8,6 @@ import options from "./options.js";
import froca from "./froca.js";
export default class RootCommandExecutor extends Component {
jumpToNoteCommand() {
import("../dialogs/jump_to_note.js").then(d => d.showDialog());
}
showRecentChangesCommand() {
import("../dialogs/recent_changes.js").then(d => d.showDialog());
}
showNoteRevisionsCommand() {
import("../dialogs/note_revisions.js").then(d => d.showCurrentNoteRevisions());
}
showNoteSourceCommand() {
import("../dialogs/note_source.js").then(d => d.showDialog());
}
pasteMarkdownIntoTextCommand() {
import("../dialogs/markdown_import.js").then(d => d.importMarkdownInline());
}
async editBranchPrefixCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
const editBranchPrefixDialog = await import("../dialogs/branch_prefix.js");
editBranchPrefixDialog.showDialog(notePath);
}
}
editReadOnlyNoteCommand() {
const noteContext = appContext.tabManager.getActiveContext();
noteContext.readOnlyTemporarilyDisabled = true;
@@ -44,24 +15,6 @@ export default class RootCommandExecutor extends Component {
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
}
async cloneNoteIdsToCommand({noteIds}) {
const d = await import("../dialogs/clone_to.js");
d.showDialog(noteIds);
}
async moveBranchIdsToCommand({branchIds}) {
const d = await import("../dialogs/move_to.js");
d.showDialog(branchIds);
}
showOptionsCommand({openTab}) {
import("../dialogs/options.js").then(d => d.showDialog(openTab));
}
showHelpCommand() {
import("../dialogs/help.js").then(d => d.showDialog());
}
async showSQLConsoleCommand() {
const sqlConsoleNote = await dateNoteService.createSqlConsole();
@@ -90,10 +43,6 @@ export default class RootCommandExecutor extends Component {
this.searchNotesCommand({ancestorNoteId: noteId});
}
showBackendLogCommand() {
import("../dialogs/backend_log.js").then(d => d.showDialog());
}
openNoteExternallyCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();

View File

@@ -131,10 +131,10 @@ function ajax(url, method, data, headers) {
headers: respHeaders
});
},
error: async (jqXhr, status, error) => {
await reportError(method, url, status, error);
error: async (jqXhr, status) => {
await reportError(method, url, status, jqXhr.responseText);
rej(error);
rej(jqXhr.responseText);
}
};

View File

@@ -142,6 +142,11 @@ export default class TabManager extends Component {
return this.noteContexts;
}
/** @returns {NoteContext[]} */
getMainNoteContexts() {
return this.noteContexts.filter(nc => nc.isMainContext());
}
/** @returns {NoteContext} */
getNoteContextById(ntxId) {
const noteContext = this.noteContexts.find(nc => nc.ntxId === ntxId);
@@ -294,11 +299,23 @@ export default class TabManager extends Component {
this.setCurrentNotePathToHash();
}
/**
* @param ntxId
* @returns {Promise<boolean>} true if note context has been removed, false otherwise
*/
async removeNoteContext(ntxId) {
// removing note context is async process which can take some time, if users presses CTRL-W quickly, two
// close events could interleave which would then lead to attempting to activate already removed context.
await this.mutex.runExclusively(async () => {
const noteContextToRemove = this.getNoteContextById(ntxId);
return await this.mutex.runExclusively(async () => {
let noteContextToRemove;
try {
noteContextToRemove = this.getNoteContextById(ntxId);
}
catch {
// note context not found
return false;
}
if (noteContextToRemove.isMainContext()) {
// forbid removing last main note context
@@ -306,8 +323,9 @@ export default class TabManager extends Component {
const mainNoteContexts = this.getNoteContexts().filter(nc => nc.isMainContext());
if (mainNoteContexts.length === 1) {
mainNoteContexts[0].setEmpty();
return;
await this.clearLastMainNoteContext(noteContextToRemove);
return false;
}
}
@@ -317,7 +335,7 @@ export default class TabManager extends Component {
const noteContextsToRemove = noteContextToRemove.getSubContexts();
const ntxIdsToRemove = noteContextsToRemove.map(nc => nc.ntxId);
await this.triggerEvent('beforeTabRemove', { ntxIds: ntxIdsToRemove });
await this.triggerEvent('beforeNoteContextRemove', { ntxIds: ntxIdsToRemove });
if (!noteContextToRemove.isMainContext()) {
await this.activateNoteContext(noteContextToRemove.getMainContext().ntxId);
@@ -336,16 +354,41 @@ export default class TabManager extends Component {
}
}
this.children = this.children.filter(nc => !ntxIdsToRemove.includes(nc.ntxId));
this.removeNoteContexts(noteContextsToRemove);
this.recentlyClosedTabs.push(noteContextsToRemove);
this.triggerEvent('noteContextRemoved', {ntxIds: ntxIdsToRemove});
this.tabsUpdate.scheduleUpdate();
return true;
});
}
async clearLastMainNoteContext(noteContextToClear) {
noteContextToClear.setEmpty();
// activate main split
await this.activateNoteContext(noteContextToClear.ntxId);
// remove all other splits
const noteContextsToRemove = noteContextToClear.getSubContexts()
.filter(ntx => ntx.ntxId !== noteContextToClear.ntxId);
const ntxIdsToRemove = noteContextsToRemove.map(ntx => ntx.ntxId);
await this.triggerEvent('beforeNoteContextRemove', {ntxIds: ntxIdsToRemove});
this.removeNoteContexts(noteContextsToRemove);
}
removeNoteContexts(noteContextsToRemove) {
const ntxIdsToRemove = noteContextsToRemove.map(nc => nc.ntxId);
this.children = this.children.filter(nc => !ntxIdsToRemove.includes(nc.ntxId));
this.recentlyClosedTabs.push(noteContextsToRemove);
this.triggerEvent('noteContextRemoved', {ntxIds: ntxIdsToRemove});
this.tabsUpdate.scheduleUpdate();
}
tabReorderEvent({ntxIdsInOrder}) {
const order = {};
@@ -421,12 +464,14 @@ export default class TabManager extends Component {
}
}
moveTabToNewWindowCommand({ntxId}) {
async moveTabToNewWindowCommand({ntxId}) {
const {notePath, hoistedNoteId} = this.getNoteContextById(ntxId);
this.removeNoteContext(ntxId);
const removed = await this.removeNoteContext(ntxId);
this.triggerCommand('openInWindow', {notePath, hoistedNoteId});
if (removed) {
this.triggerCommand('openInWindow', {notePath, hoistedNoteId});
}
}
async reopenLastTabCommand() {
@@ -458,7 +503,7 @@ export default class TabManager extends Component {
updateDocumentTitle(activeNoteContext) {
const titleFragments = [
// it helps navigating in history if note title is included in the title
// it helps to navigate in history if note title is included in the title
activeNoteContext.note?.title,
"Trilium Notes"
].filter(Boolean);

View File

@@ -4,16 +4,17 @@ import utils from "./utils.js";
function toast(options) {
const $toast = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="mr-auto"><span class="bx bx-${options.icon}"></span> ${options.title}</strong>
<strong class="mr-auto"><span class="bx bx-${options.icon}"></span> <span class="toast-title"></span></strong>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">
${options.message}
</div>
<div class="toast-body"></div>
</div>`);
$toast.find('.toast-title').text(options.title);
$toast.find('.toast-body').text(options.message);
if (options.id) {
$toast.attr("id", "toast-" + options.id);
}

View File

@@ -4,6 +4,7 @@ import clipboard from './clipboard.js';
import noteCreateService from "./note_create.js";
import contextMenu from "./context_menu.js";
import appContext from "./app_context.js";
import noteTypesService from "./note_types.js";
class TreeContextMenu {
/**
@@ -24,20 +25,6 @@ class TreeContextMenu {
})
}
getNoteTypeItems(command) {
return [
{ title: "Text", command: command, type: "text", uiIcon: "note" },
{ title: "Code", command: command, type: "code", uiIcon: "code" },
{ title: "Saved search", command: command, type: "search", uiIcon: "file-find" },
{ title: "Relation Map", command: command, type: "relation-map", uiIcon: "map-alt" },
{ title: "Note Map", command: command, type: "note-map", uiIcon: "map-alt" },
{ title: "Render HTML note", command: command, type: "render", uiIcon: "extension" },
{ title: "Book", command: command, type: "book", uiIcon: "book" },
{ title: "Mermaid diagram", command: command, type: "mermaid", uiIcon: "selection" },
{ title: "Canvas", command: command, type: "canvas", uiIcon: "pen" },
];
}
async getMenuItems() {
const note = await froca.getNote(this.node.data.noteId);
const branch = froca.getBranch(this.node.data.branchId);
@@ -57,58 +44,59 @@ class TreeContextMenu {
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
return [
{ title: 'Open in a new tab <kbd>Ctrl+Click</kbd>', command: "openInTab", uiIcon: "empty", enabled: noSelectedNotes },
{ title: 'Open in a new split', command: "openNoteInSplit", uiIcon: "dock-right", enabled: noSelectedNotes },
{ title: 'Insert note after <kbd data-command="createNoteAfter"></kbd>', command: "insertNoteAfter", uiIcon: "plus",
items: insertNoteAfterEnabled ? this.getNoteTypeItems("insertNoteAfter") : null,
{ title: 'Open in a new tab <kbd>Ctrl+Click</kbd>', command: "openInTab", uiIcon: "bx bx-empty", enabled: noSelectedNotes },
{ title: 'Open in a new split', command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
{ title: 'Insert note after <kbd data-command="createNoteAfter"></kbd>', command: "insertNoteAfter", uiIcon: "bx bx-plus",
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
enabled: insertNoteAfterEnabled && noSelectedNotes },
{ title: 'Insert child note <kbd data-command="createNoteInto"></kbd>', command: "insertChildNote", uiIcon: "plus",
items: notSearch ? this.getNoteTypeItems("insertChildNote") : null,
{ title: 'Insert child note <kbd data-command="createNoteInto"></kbd>', command: "insertChildNote", uiIcon: "bx bx-plus",
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
enabled: notSearch && noSelectedNotes },
{ title: 'Delete <kbd data-command="deleteNotes"></kbd>', command: "deleteNotes", uiIcon: "trash",
{ title: 'Delete <kbd data-command="deleteNotes"></kbd>', command: "deleteNotes", uiIcon: "bx bx-trash",
enabled: isNotRoot && !isHoisted && parentNotSearch },
{ title: "----" },
{ title: 'Search in subtree <kbd data-command="searchInSubtree"></kbd>', command: "searchInSubtree", uiIcon: "search",
{ title: 'Search in subtree <kbd data-command="searchInSubtree"></kbd>', command: "searchInSubtree", uiIcon: "bx bx-search",
enabled: notSearch && noSelectedNotes },
isHoisted ? null : { title: 'Hoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "empty", enabled: noSelectedNotes && notSearch },
!isHoisted || !isNotRoot ? null : { title: 'Unhoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "door-open" },
{ title: 'Edit branch prefix <kbd data-command="editBranchPrefix"></kbd>', command: "editBranchPrefix", uiIcon: "empty",
isHoisted ? null : { title: 'Hoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "bx bx-empty", enabled: noSelectedNotes && notSearch },
!isHoisted || !isNotRoot ? null : { title: 'Unhoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
{ title: 'Edit branch prefix <kbd data-command="editBranchPrefix"></kbd>', command: "editBranchPrefix", uiIcon: "bx bx-empty",
enabled: isNotRoot && parentNotSearch && noSelectedNotes},
{ title: "Advanced", uiIcon: "empty", enabled: true, items: [
{ title: 'Expand subtree <kbd data-command="expandSubtree"></kbd>', command: "expandSubtree", uiIcon: "expand", enabled: noSelectedNotes },
{ title: 'Collapse subtree <kbd data-command="collapseSubtree"></kbd>', command: "collapseSubtree", uiIcon: "collapse", enabled: noSelectedNotes },
{ title: "Force note sync", command: "forceNoteSync", uiIcon: "refresh", enabled: noSelectedNotes },
{ title: 'Sort by ... <kbd data-command="sortChildNotes"></kbd>', command: "sortChildNotes", uiIcon: "empty", enabled: noSelectedNotes && notSearch },
{ title: 'Recent changes in subtree', command: "recentChangesInSubtree", uiIcon: "history", enabled: noSelectedNotes }
{ title: "Advanced", uiIcon: "bx bx-empty", enabled: true, items: [
{ title: 'Expand subtree <kbd data-command="expandSubtree"></kbd>', command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
{ title: 'Collapse subtree <kbd data-command="collapseSubtree"></kbd>', command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
{ title: "Force note sync", command: "forceNoteSync", uiIcon: "bx bx-refresh", enabled: noSelectedNotes },
{ title: 'Sort by ... <kbd data-command="sortChildNotes"></kbd>', command: "sortChildNotes", uiIcon: "bx bx-empty", enabled: noSelectedNotes && notSearch },
{ title: 'Recent changes in subtree', command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes }
] },
{ title: "----" },
{ title: "Protect subtree", command: "protectSubtree", uiIcon: "check-shield", enabled: noSelectedNotes },
{ title: "Unprotect subtree", command: "unprotectSubtree", uiIcon: "shield", enabled: noSelectedNotes },
{ title: "Protect subtree", command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
{ title: "Unprotect subtree", command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
{ title: "----" },
{ title: 'Copy / clone <kbd data-command="copyNotesToClipboard"></kbd>', command: "copyNotesToClipboard", uiIcon: "copy",
{ title: 'Copy / clone <kbd data-command="copyNotesToClipboard"></kbd>', command: "copyNotesToClipboard", uiIcon: "bx bx-copy",
enabled: isNotRoot && !isHoisted },
{ title: 'Clone to ... <kbd data-command="cloneNotesTo"></kbd>', command: "cloneNotesTo", uiIcon: "empty",
{ title: 'Clone to ... <kbd data-command="cloneNotesTo"></kbd>', command: "cloneNotesTo", uiIcon: "bx bx-empty",
enabled: isNotRoot && !isHoisted },
{ title: 'Cut <kbd data-command="cutNotesToClipboard"></kbd>', command: "cutNotesToClipboard", uiIcon: "cut",
{ title: 'Cut <kbd data-command="cutNotesToClipboard"></kbd>', command: "cutNotesToClipboard", uiIcon: "bx bx-cut",
enabled: isNotRoot && !isHoisted && parentNotSearch },
{ title: 'Move to ... <kbd data-command="moveNotesTo"></kbd>', command: "moveNotesTo", uiIcon: "empty",
{ title: 'Move to ... <kbd data-command="moveNotesTo"></kbd>', command: "moveNotesTo", uiIcon: "bx bx-empty",
enabled: isNotRoot && !isHoisted && parentNotSearch },
{ title: 'Paste into <kbd data-command="pasteNotesFromClipboard"></kbd>', command: "pasteNotesFromClipboard", uiIcon: "paste",
{ title: 'Paste into <kbd data-command="pasteNotesFromClipboard"></kbd>', command: "pasteNotesFromClipboard", uiIcon: "bx bx-paste",
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes },
{ title: 'Paste after', command: "pasteNotesAfterFromClipboard", uiIcon: "paste",
{ title: 'Paste after', command: "pasteNotesAfterFromClipboard", uiIcon: "bx bx-paste",
enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes },
{ title: `Duplicate subtree <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "empty",
{ title: `Duplicate subtree <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "bx bx-empty",
enabled: parentNotSearch && isNotRoot && !isHoisted },
{ title: "----" },
{ title: "Export", command: "exportNote", uiIcon: "empty",
{ title: "Export", command: "exportNote", uiIcon: "bx bx-empty",
enabled: notSearch && noSelectedNotes },
{ title: "Import into note", command: "importIntoNote", uiIcon: "empty",
enabled: notSearch && noSelectedNotes }
{ title: "Import into note", command: "importIntoNote", uiIcon: "bx bx-empty",
enabled: notSearch && noSelectedNotes },
{ title: "Apply bulk actions", command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus",
enabled: true }
].filter(row => row !== null);
}
async selectMenuItemHandler({command, type}) {
const noteId = this.node.data.noteId;
async selectMenuItemHandler({command, type, templateNoteId}) {
const notePath = treeService.getNotePath(this.node);
if (command === 'openInTab') {
@@ -122,7 +110,8 @@ class TreeContextMenu {
target: 'after',
targetBranchId: this.node.data.branchId,
type: type,
isProtected: isProtected
isProtected: isProtected,
templateNoteId: templateNoteId
});
}
else if (command === "insertChildNote") {
@@ -130,7 +119,8 @@ class TreeContextMenu {
noteCreateService.createNote(parentNotePath, {
type: type,
isProtected: this.node.data.isProtected
isProtected: this.node.data.isProtected,
templateNoteId: templateNoteId
});
}
else if (command === 'openNoteInSplit') {
@@ -140,7 +130,12 @@ class TreeContextMenu {
this.treeWidget.triggerCommand("openNewNoteSplit", {ntxId, notePath});
}
else {
this.treeWidget.triggerCommand(command, {node: this.node, notePath: notePath});
this.treeWidget.triggerCommand(command, {
node: this.node,
notePath: notePath,
selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node),
selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node)
});
}
}
}

View File

@@ -280,7 +280,7 @@ function isHtmlEmpty(html) {
async function clearBrowserCache() {
if (isElectron()) {
const win = utils.dynamicRequire('@electron/remote').getCurrentWindow();
const win = dynamicRequire('@electron/remote').getCurrentWindow();
await win.webContents.session.clearCache();
}
}
@@ -292,10 +292,6 @@ function copySelectionToClipboard() {
}
}
function isCKEditorInitialized() {
return !!(window && window.cutToNote);
}
function dynamicRequire(moduleName) {
if (typeof __non_webpack_require__ !== 'undefined') {
return __non_webpack_require__(moduleName);
@@ -365,6 +361,10 @@ function sleep(time_ms) {
});
}
function escapeRegExp(str) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
export default {
reloadFrontendApp,
parseDate,
@@ -401,7 +401,6 @@ export default {
clearBrowserCache,
normalizeShortcut,
copySelectionToClipboard,
isCKEditorInitialized,
dynamicRequire,
timeLimit,
initHelpDropdown,
@@ -410,4 +409,5 @@ export default {
filterAttributeName,
isValidAttributeName,
sleep,
escapeRegExp
};

View File

@@ -175,7 +175,7 @@ async function consumeFrontendUpdateData() {
else {
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
alert(`Encountered error "${e.message}", check out the console.`);
toastService.showError(`Encountered error "${e.message}", check out the console.`);
}
}

View File

@@ -17,5 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
const toggleMenuButton = document.getElementById('toggleMenuButton');
const layout = document.getElementById('layout');
toggleMenuButton.addEventListener('click', () => layout.classList.toggle('showMenu'));
if (toggleMenuButton && layout) {
toggleMenuButton.addEventListener('click', () => layout.classList.toggle('showMenu'));
}
}, false);

View File

@@ -19,7 +19,7 @@ export default class Mutex {
const unlock = await this.lock();
try {
await cb();
return await cb();
}
finally {
unlock();

View File

@@ -204,6 +204,7 @@ const ATTR_HELP = {
"workspace": "marks this note as a workspace which allows easy hoisting",
"workspaceIconClass": "defines box icon CSS class which will be used in tab when hoisted to this note",
"workspaceTabBackgroundColor": "CSS color used in the note tab when hoisted to this note",
"workspaceCalendarRoot": "Defines per-workspace calendar root",
"searchHome": "new search notes will be created as children of this note",
"hoistedSearchHome": "new search notes will be created as children of this note when hoisted to some ancestor of this note",
"inbox": "default inbox location for new notes",
@@ -215,8 +216,10 @@ const ATTR_HELP = {
"shareAlias": "define an alias using which the note will be available under https://your_trilium_host/share/[your_alias]",
"shareOmitDefaultCss": "default share page CSS will be omitted. Use when you make extensive styling changes.",
"shareRoot": "marks note which is served on /share root.",
"shareDescription": "define text to be added to the HTML meta tag for description",
"shareRaw": "note will be served in its raw format, without HTML wrapper",
"shareDisallowRobotIndexing": `will forbid robot indexing of this note via <code>X-Robots-Tag: noindex</code> header`,
"shareCredentials": "require credentials to access this shared note. Value is expected to be in format 'username:password'. Don't forget to make this inheritable to apply to child-notes/images.",
"displayRelations": "comma delimited names of relations which should be displayed. All other ones will be hidden.",
"hideRelations": "comma delimited names of relations which should be hidden. All other ones will be displayed.",
"titleTemplate": `default title of notes created as children of this note. The value is evaluated as JavaScript string
@@ -227,12 +230,17 @@ const ATTR_HELP = {
<li><code>Log for \${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>
</ul>
See <a href="https://github.com/zadam/trilium/wiki/Default-note-title">wiki with details</a>, API docs for <a href="https://zadam.github.io/trilium/backend_api/Note.html">parentNote</a> and <a href="https://day.js.org/docs/en/display/format">now</a> for details.`
See <a href="https://github.com/zadam/trilium/wiki/Default-note-title">wiki with details</a>, API docs for <a href="https://zadam.github.io/trilium/backend_api/Note.html">parentNote</a> and <a href="https://day.js.org/docs/en/display/format">now</a> for details.`,
"template": "This note will appear in the selection of available template when creating new note",
"toc": "<code>#toc</code> or <code>#toc=show</code> will force the Table of Contents to be shown, <code>#toc=hide</code> will force hiding it. If the label doesn't exist, the global setting is observed"
},
"relation": {
"runOnNoteCreation": "executes when note is created on backend",
"runOnNoteTitleChange": "executes when note title is changed (includes note creation as well)",
"runOnNoteChange": "executes when note is changed (includes note creation as well)",
"runOnNoteDeletion": "executes when note is being deleted",
"runOnBranchCreation": "executes when a branch is created. Branch is a link between parent note and child note and is created e.g. when cloning or moving note.",
"runOnBranchDeletion": "executes when a branch is deleted. Branch is a link between parent note and child note and is deleted e.g. when moving note (old branch/link is deleted).",
"runOnChildNoteCreation": "executes when new note is created under this note",
"runOnAttributeCreation": "executes when new attribute is created under this note",
"runOnAttributeChange": "executes when attribute is changed under this note",

View File

@@ -217,11 +217,11 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
y: e.pageY,
orientation: 'left',
items: [
{title: `Add new label <kbd data-command="addNewLabel"></kbd>`, command: "addNewLabel", uiIcon: "hash"},
{title: `Add new relation <kbd data-command="addNewRelation"></kbd>`, command: "addNewRelation", uiIcon: "transfer"},
{title: `Add new label <kbd data-command="addNewLabel"></kbd>`, command: "addNewLabel", uiIcon: "bx bx-hash"},
{title: `Add new relation <kbd data-command="addNewRelation"></kbd>`, command: "addNewRelation", uiIcon: "bx bx-transfer"},
{title: "----"},
{title: "Add new label definition", command: "addNewLabelDefinition", uiIcon: "empty"},
{title: "Add new relation definition", command: "addNewRelationDefinition", uiIcon: "empty"},
{title: "Add new label definition", command: "addNewLabelDefinition", uiIcon: "bx bx-empty"},
{title: "Add new relation definition", command: "addNewRelationDefinition", uiIcon: "bx bx-empty"},
],
selectMenuItemHandler: ({command}) => this.handleAddNewAttributeCommand(command)
});
@@ -297,6 +297,12 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
}
async save() {
if (this.lastUpdatedNoteId !== this.noteId) {
// https://github.com/zadam/trilium/issues/3090
console.warn("Ignoring blur event because a different note is loaded.");
return;
}
const attributes = this.parseAttributes();
if (attributes) {
@@ -354,6 +360,8 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
}
dataChanged() {
this.lastUpdatedNoteId = this.noteId;
if (this.lastSavedContent === this.textEditor.getData()) {
this.$saveAttributesButton.fadeOut();
}
@@ -468,6 +476,8 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
async renderOwnedAttributes(ownedAttributes, saved) {
ownedAttributes = ownedAttributes.filter(oa => !oa.isDeleted);
// attrs are not resorted if position changes after initial load
ownedAttributes.sort((a, b) => a.position < b.position ? -1 : 1);
let htmlAttrs = (await attributeRenderer.renderAttributes(ownedAttributes, true)).html();
@@ -485,7 +495,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
}
async createNoteForReferenceLink(title) {
const {note} = await noteCreateService.createNote(this.notePath, {
const {note} = await noteCreateService.createNoteWithTypePrompt(this.notePath, {
activate: false,
title: title
});

View File

@@ -103,10 +103,22 @@ class BasicWidget extends Component {
this.$widget.toggleClass('hidden-int', !show);
}
isHiddenInt() {
return this.$widget.hasClass('hidden-int');
}
toggleExt(show) {
this.$widget.toggleClass('hidden-ext', !show);
}
isHiddenExt() {
return this.$widget.hasClass('hidden-ext');
}
canBeShown() {
return !this.isHiddenInt() && !this.isHiddenExt();
}
isVisible() {
return this.$widget.is(":visible");
}
@@ -121,7 +133,7 @@ class BasicWidget extends Component {
}
}
getNtxId() {
getClosestNtxId() {
if (this.$widget) {
return this.$widget.closest("[data-ntx-id]").attr("data-ntx-id");
}

View File

@@ -1,12 +1,9 @@
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import Component from "../component.js";
import utils from "../../services/utils.js";
export default class AbstractSearchAction extends Component {
export default class AbstractBulkAction {
constructor(attribute, actionDef) {
super();
this.attribute = attribute;
this.actionDef = actionDef;
}
@@ -50,6 +47,6 @@ export default class AbstractSearchAction extends Component {
await ws.waitForMaxKnownEntityChangeId();
await this.triggerCommand('refreshSearchDefinition');
//await this.triggerCommand('refreshSearchDefinition');
}
}

View File

@@ -1,5 +1,5 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import AbstractBulkAction from "./abstract_bulk_action.js";
const TPL = `
<tr>
@@ -33,8 +33,9 @@ const TPL = `
</td>
</tr>`;
export default class ExecuteScriptSearchAction extends AbstractSearchAction {
export default class ExecuteScriptBulkAction extends AbstractBulkAction {
static get actionName() { return "executeScript"; }
static get actionTitle() { return "Execute script"; }
doRender() {
const $action = $(TPL);
@@ -43,7 +44,7 @@ export default class ExecuteScriptSearchAction extends AbstractSearchAction {
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({ script: $script.val() });
}, 1000)
}, 1000);
$script.on('input', () => spacedUpdate.scheduleUpdate());

View File

@@ -1,11 +1,11 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">Set label</div>
<div style="margin-right: 10px;" class="text-nowrap">Add label</div>
<input type="text"
class="form-control label-name"
@@ -37,8 +37,9 @@ const TPL = `
</td>
</tr>`;
export default class SetLabelValueSearchAction extends AbstractSearchAction {
static get actionName() { return "setLabelValue"; }
export default class AddLabelBulkAction extends AbstractBulkAction {
static get actionName() { return "addLabel"; }
static get actionTitle() { return "Add label"; }
doRender() {
const $action = $(TPL);
@@ -54,7 +55,7 @@ export default class SetLabelValueSearchAction extends AbstractSearchAction {
labelName: $labelName.val(),
labelValue: $labelValue.val()
});
}, 1000)
}, 1000);
$labelName.on('input', () => spacedUpdate.scheduleUpdate());
$labelValue.on('input', () => spacedUpdate.scheduleUpdate());

View File

@@ -1,5 +1,5 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
@@ -18,8 +18,9 @@ const TPL = `
</td>
</tr>`;
export default class DeleteLabelSearchAction extends AbstractSearchAction {
export default class DeleteLabelBulkAction extends AbstractBulkAction {
static get actionName() { return "deleteLabel"; }
static get actionTitle() { return "Delete label"; }
doRender() {
const $action = $(TPL);

View File

@@ -1,11 +1,11 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;">Rename label from:</div>
<div style="margin-right: 10px; flex-shrink: 0;">Rename label from:</div>
<input type="text"
class="form-control old-label-name"
@@ -27,8 +27,9 @@ const TPL = `
</td>
</tr>`;
export default class RenameLabelSearchAction extends AbstractSearchAction {
export default class RenameLabelBulkAction extends AbstractBulkAction {
static get actionName() { return "renameLabel"; }
static get actionTitle() { return "Rename label"; }
doRender() {
const $action = $(TPL);
@@ -44,7 +45,7 @@ export default class RenameLabelSearchAction extends AbstractSearchAction {
oldLabelName: $oldLabelName.val(),
newLabelName: $newLabelName.val()
});
}, 1000)
}, 1000);
$oldLabelName.on('input', () => spacedUpdate.scheduleUpdate());
$newLabelName.on('input', () => spacedUpdate.scheduleUpdate());

View File

@@ -0,0 +1,60 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">Update label value</div>
<input type="text"
class="form-control label-name"
placeholder="label name"
pattern="[\\p{L}\\p{N}_:]+"
title="Alphanumeric characters, underscore and colon are allowed characters."/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">to value</div>
<input type="text" class="form-control label-value" placeholder="new value"/>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>On all matched notes, change value of the existing label.</p>
<p>You can also call this method without value, in such case label will be assigned to the note without value.</p>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class UpdateLabelValueBulkAction extends AbstractBulkAction {
static get actionName() { return "updateLabelValue"; }
static get actionTitle() { return "Update label value"; }
doRender() {
const $action = $(TPL);
const $labelName = $action.find('.label-name');
$labelName.val(this.actionDef.labelName || "");
const $labelValue = $action.find('.label-value');
$labelValue.val(this.actionDef.labelValue || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
labelName: $labelName.val(),
labelValue: $labelValue.val()
});
}, 1000)
$labelName.on('input', () => spacedUpdate.scheduleUpdate());
$labelValue.on('input', () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,33 @@
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<span class="bx bx-trash"></span>
Delete matched notes
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>This will delete matched notes.</p>
<p>After the deletion, it's possible to undelete them from <span class="bx bx-history"></span> Recent Notes dialog.</p>
<p>To erase notes permanently, you can go after the deletion to the Option -> Other and click the "Erase deleted notes now" button.</p>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class DeleteNoteBulkAction extends AbstractBulkAction {
static get actionName() { return "deleteNote"; }
static get actionTitle() { return "Delete note"; }
doRender() {
return $(TPL);
}
}

View File

@@ -1,4 +1,4 @@
import AbstractSearchAction from "./abstract_search_action.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
@@ -19,8 +19,9 @@ const TPL = `
</td>
</tr>`;
export default class DeleteNoteRevisionsSearchAction extends AbstractSearchAction {
export default class DeleteNoteRevisionsBulkAction extends AbstractBulkAction {
static get actionName() { return "deleteNoteRevisions"; }
static get actionTitle() { return "Delete note revisions"; }
doRender() {
return $(TPL);

View File

@@ -1,6 +1,6 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
const TPL = `
<tr>
@@ -33,8 +33,9 @@ const TPL = `
</td>
</tr>`;
export default class MoveNoteSearchAction extends AbstractSearchAction {
export default class MoveNoteBulkAction extends AbstractBulkAction {
static get actionName() { return "moveNote"; }
static get actionTitle() { return "Move note"; }
doRender() {
const $action = $(TPL);

View File

@@ -0,0 +1,56 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px; flex-shrink: 0;">Rename note title to:</div>
<input type="text"
class="form-control new-title"
placeholder="new note title"
title="Click help icon on the right to see all the options"/>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>The given value is evaluated as JavaScript string and thus can be enriched with dynamic content via the injected <code>note</code> variable (note being renamed). Examples:</p>
<ul>
<li><code>Note</code> - all matched notes are renamed to "Note"</li>
<li><code>NEW: \${note.title}</code> - matched notes titles are prefixed with "NEW: "</li>
<li><code>\${note.dateCreatedObj.format('MM-DD:')}: \${note.title}</code> - matched notes are prefixed with note's creation month-date</li>
</ul>
See API docs for <a href="https://zadam.github.io/trilium/backend_api/Note.html">note</a> and its <a href="https://day.js.org/docs/en/display/format">dateCreatedObj / utcDateCreatedObj properties</a> for details.
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class RenameNoteBulkAction extends AbstractBulkAction {
static get actionName() { return "renameNote"; }
static get actionTitle() { return "Rename note"; }
doRender() {
const $action = $(TPL);
const $newTitle = $action.find('.new-title');
$newTitle.val(this.actionDef.newTitle || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
newTitle: $newTitle.val(),
});
}, 1000);
$newTitle.on('input', () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,65 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">Add relation</div>
<input type="text"
class="form-control relation-name"
placeholder="relation name"
pattern="[\\p{L}\\p{N}_:]+"
style="flex-shrink: 3"
title="Alphanumeric characters, underscore and colon are allowed characters."/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">to</div>
<div class="input-group" style="flex-shrink: 2">
<input type="text" class="form-control target-note" placeholder="target note"/>
</div>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>On all matched notes create given relation.</p>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class AddRelationBulkAction extends AbstractBulkAction {
static get actionName() { return "addRelation"; }
static get actionTitle() { return "Add relation"; }
doRender() {
const $action = $(TPL);
const $relationName = $action.find('.relation-name');
$relationName.val(this.actionDef.relationName || "");
const $targetNote = $action.find('.target-note');
noteAutocompleteService.initNoteAutocomplete($targetNote);
$targetNote.setNote(this.actionDef.targetNoteId);
$targetNote.on('autocomplete:closed', () => spacedUpdate.scheduleUpdate());
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
relationName: $relationName.val(),
targetNoteId: $targetNote.getSelectedNoteId()
});
}, 1000)
$relationName.on('input', () => spacedUpdate.scheduleUpdate());
$targetNote.on('input', () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -1,5 +1,5 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
@@ -20,8 +20,9 @@ const TPL = `
</td>
</tr>`;
export default class DeleteRelationSearchAction extends AbstractSearchAction {
export default class DeleteRelationBulkAction extends AbstractBulkAction {
static get actionName() { return "deleteRelation"; }
static get actionTitle() { return "Delete relation"; }
doRender() {
const $action = $(TPL);

View File

@@ -1,11 +1,11 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;">Rename relation from:</div>
<div style="margin-right: 10px; flex-shrink: 0;">Rename relation from:</div>
<input type="text"
class="form-control old-relation-name"
@@ -27,8 +27,9 @@ const TPL = `
</td>
</tr>`;
export default class RenameRelationSearchAction extends AbstractSearchAction {
export default class RenameRelationBulkAction extends AbstractBulkAction {
static get actionName() { return "renameRelation"; }
static get actionTitle() { return "Rename relation"; }
doRender() {
const $action = $(TPL);

View File

@@ -1,12 +1,12 @@
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractSearchAction from "./abstract_search_action.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
const TPL = `
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">Set relation</div>
<div style="margin-right: 10px;" class="text-nowrap">Update relation</div>
<input type="text"
class="form-control relation-name"
@@ -39,8 +39,9 @@ const TPL = `
</td>
</tr>`;
export default class SetRelationTargetSearchAction extends AbstractSearchAction {
static get actionName() { return "setRelationTarget"; }
export default class UpdateRelationTargetBulkAction extends AbstractBulkAction {
static get actionName() { return "updateRelationTarget"; }
static get actionTitle() { return "Update relation target"; }
doRender() {
const $action = $(TPL);

View File

@@ -4,6 +4,7 @@ import dateNoteService from "../../services/date_notes.js";
import server from "../../services/server.js";
import appContext from "../../services/app_context.js";
import RightDropdownButtonWidget from "./right_dropdown_button.js";
import toastService from "../../services/toast.js";
const DROPDOWN_TPL = `
<div class="calendar-dropdown-widget">
@@ -62,7 +63,7 @@ export default class CalendarWidget extends RightDropdownButtonWidget {
this.hideDropdown();
}
else {
alert("Cannot find day note");
toastService.showError("Cannot find day note");
}
});
}

View File

@@ -18,7 +18,7 @@ export default class ClosePaneButton extends ButtonWidget {
// pane (which is being removed)
e.stopPropagation();
widget.triggerCommand("closeThisNoteSplit", { ntxId: widget.getNtxId() });
widget.triggerCommand("closeThisNoteSplit", { ntxId: widget.getClosestNtxId() });
});
}
}

View File

@@ -7,6 +7,6 @@ export default class CreatePaneButton extends ButtonWidget {
this.icon("bx-dock-right")
.title("Create new split")
.titlePlacement("bottom")
.onClick(widget => widget.triggerCommand("openNewNoteSplit", { ntxId: widget.getNtxId() }));
.onClick(widget => widget.triggerCommand("openNewNoteSplit", { ntxId: widget.getClosestNtxId() }));
}
}

View File

@@ -1,6 +1,7 @@
import BasicWidget from "../basic_widget.js";
import utils from "../../services/utils.js";
import UpdateAvailableWidget from "./update_available.js";
import options from "../../services/options.js";
const TPL = `
<div class="dropdown global-menu dropright">
@@ -37,6 +38,10 @@ const TPL = `
pointer-events: none;
}
.update-to-latest-version-button {
display: none;
}
</style>
<button type="button" data-toggle="dropdown" data-placement="right"
@@ -132,8 +137,7 @@ export default class GlobalMenuWidget extends BasicWidget {
$button.tooltip({ trigger: "hover" });
$button.on("click", () => $button.tooltip("hide"));
this.$widget.find(".show-about-dialog-button").on('click',
() => import("../../dialogs/about.js").then(d => d.showDialog()));
this.$widget.find(".show-about-dialog-button").on('click', () => this.triggerCommand("openAboutDialog"));
const isElectron = utils.isElectron();
@@ -157,10 +161,12 @@ export default class GlobalMenuWidget extends BasicWidget {
}
async updateVersionStatus() {
if (options.get("checkForUpdates") !== 'true') {
return;
}
const latestVersion = await this.fetchLatestVersion();
this.updateAvailableWidget.updateVersionStatus(latestVersion);
this.$updateToLatestVersionButton.toggle(latestVersion > glob.triliumVersion);
this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`);
}

View File

@@ -11,8 +11,8 @@ export default class LeftPaneToggleWidget extends ButtonWidget {
: "bx-chevrons-right";
this.settings.title = isLeftPaneVisible
? "Hide panel."
: "Open panel.";
? "Hide panel"
: "Open panel";
this.settings.command = isLeftPaneVisible
? "hideLeftPane"

View File

@@ -26,7 +26,7 @@ const TPL = `
<div class="dropdown-menu dropdown-menu-right">
<a data-trigger-command="renderActiveNote" class="dropdown-item render-note-button"><kbd data-command="renderActiveNote"></kbd> Re-render note</a>
<a data-trigger-command="findInText" class="dropdown-item find-in-text-button">Search in note <kbd data-command="findInText"></a>
<a data-trigger-command="showNoteSource" class="dropdown-item show-source-button"><kbd data-command="showNoteSource"></kbd> Note source</a>
<a data-trigger-command="openNoteSourceDialog" class="dropdown-item show-source-button"><kbd data-command="showNoteSource"></kbd> Note source</a>
<a data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button"><kbd data-command="openNoteExternally"></kbd> Open note externally</a>
<a class="dropdown-item import-files-button">Import files</a>
<a class="dropdown-item export-note-button">Export note</a>
@@ -53,11 +53,14 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
return;
}
import('../../dialogs/export.js').then(d => d.showDialog(this.noteContext.notePath, 'single'));
this.triggerCommand("showExportDialog", {
notePath: this.noteContext.notePath,
defaultType: "single"
});
});
this.$importNoteButton = this.$widget.find('.import-files-button');
this.$importNoteButton.on("click", () => import('../../dialogs/import.js').then(d => d.showDialog(this.noteId)));
this.$importNoteButton.on("click", () => this.triggerCommand("showImportDialog", {noteId: this.noteId}));
this.$widget.on('click', '.dropdown-item', () => this.$widget.find("[data-toggle='dropdown']").dropdown('toggle'));

View File

@@ -10,7 +10,7 @@ export default class ShowNoteSourceButton extends ButtonWidget {
this.icon('bx bx-code')
.title("Show Note Source")
.command("showNoteSource")
.command("openNoteSourceDialog")
.titlePlacement("bottom");
}
}

View File

@@ -9,6 +9,9 @@ const WIDGET_TPL = `
</div>
</div>`;
/**
* TODO: rename, it's not collapsible anymore
*/
export default class CollapsibleWidget extends NoteContextAwareWidget {
get widgetTitle() { return "Untitled widget"; }
@@ -32,8 +35,4 @@ export default class CollapsibleWidget extends NoteContextAwareWidget {
/** for overriding */
async doRenderBody() {}
isExpanded() {
return this.$bodyWrapper.hasClass("show");
}
}

View File

@@ -16,7 +16,7 @@ export default class Component {
this.componentId = `comp-` + this.sanitizedClassName + '-' + utils.randomString(8);
/** @type Component[] */
this.children = [];
this.initialized = Promise.resolve();
this.initialized = null;
}
get sanitizedClassName() {
@@ -42,10 +42,16 @@ export default class Component {
/** @returns {Promise} */
handleEvent(name, data) {
return Promise.all([
this.initialized.then(() => this.callMethod(this[name + 'Event'], data)),
this.handleEventInChildren(name, data)
]);
const callMethodPromise = this.initialized
? this.initialized.then(() => this.callMethod(this[name + 'Event'], data))
: this.callMethod(this[name + 'Event'], data);
const childrenPromise = this.handleEventInChildren(name, data);
// don't create promises if not needed (optimization)
return callMethodPromise && childrenPromise
? Promise.all([callMethodPromise, childrenPromise])
: (callMethodPromise || childrenPromise);
}
/** @returns {Promise} */
@@ -58,10 +64,15 @@ export default class Component {
const promises = [];
for (const child of this.children) {
promises.push(child.handleEvent(name, data));
const ret = child.handleEvent(name, data);
if (ret) {
promises.push(ret);
}
}
return Promise.all(promises);
// don't create promises if not needed (optimization)
return promises.length > 0 ? Promise.all(promises) : null;
}
/** @returns {Promise} */
@@ -76,9 +87,9 @@ export default class Component {
}
}
async callMethod(fun, data) {
callMethod(fun, data) {
if (typeof fun !== 'function') {
return false;
return;
}
const startTime = Date.now();
@@ -91,14 +102,10 @@ export default class Component {
console.log(`Call to ${fun.name} in ${this.componentId} took ${took}ms`);
}
if (glob.isDev) {
await utils.timeLimit(promise, 20000, `Time limit failed on ${this.constructor.name} with ${fun.name}`);
}
else {
// cheaper and in non-dev the extra reporting is lost anyway through reload
await promise;
if (glob.isDev && promise) {
return utils.timeLimit(promise, 20000, `Time limit failed on ${this.constructor.name} with ${fun.name}`);
}
return true;
return promise;
}
}

View File

@@ -17,7 +17,7 @@ export default class Container extends BasicWidget {
super.child(...components);
for (const component of components) {
if (!component.position) {
if (component.position === undefined) {
component.position = this.positionCounter;
this.positionCounter += 10;
}

View File

@@ -186,15 +186,27 @@ export default class RibbonContainer extends NoteContextAwareWidget {
const activeChild = this.getActiveRibbonWidget();
if (activeChild && (refreshActiveTab || !wasAlreadyActive)) {
activeChild.handleEvent('noteSwitched', {noteContext: this.noteContext, notePath: this.notePath}).then(() => {
activeChild.focus?.();
});
const handleEventPromise = activeChild.handleEvent('noteSwitched', {noteContext: this.noteContext, notePath: this.notePath});
if (refreshActiveTab) {
if (handleEventPromise) {
handleEventPromise.then(() => activeChild.focus?.());
} else {
activeChild.focus?.();
}
}
}
} else {
this.lastActiveComponentId = null;
}
}
async noteSwitched() {
this.lastActiveComponentId = null;
await super.noteSwitched();
}
async refreshWithNote(note, noExplicitActivation = false) {
this.lastNoteType = note.type;

View File

@@ -11,7 +11,9 @@ export default class RightPaneContainer extends FlexContainer {
}
isEnabled() {
return super.isEnabled() && this.children.length > 0 && !!this.children.find(ch => ch.isEnabled());
return super.isEnabled()
&& this.children.length > 0
&& !!this.children.find(ch => ch.isEnabled() && ch.canBeShown());
}
handleEventInChildren(name, data) {
@@ -21,13 +23,25 @@ export default class RightPaneContainer extends FlexContainer {
// right pane is displayed only if some child widget is active
// we'll reevaluate the visibility based on events which are probable to cause visibility change
// but these events needs to be finished and only then we check
promise.then(() => {
this.toggleInt(this.isEnabled());
splitService.setupRightPaneResizer();
});
if (promise) {
promise.then(() => this.reevaluateIsEnabledCommand());
}
else {
this.reevaluateIsEnabledCommand();
}
}
return promise;
}
reevaluateIsEnabledCommand() {
const oldToggle = !this.isHiddenInt();
const newToggle = this.isEnabled();
if (oldToggle !== newToggle) {
this.toggleInt(newToggle);
splitService.setupRightPaneResizer();
}
}
}

View File

@@ -1,6 +1,4 @@
import FlexContainer from "./flex_container.js";
import utils from "../../services/utils.js";
import appContext from "../../services/app_context.js";
export default class RootContainer extends FlexContainer {
constructor() {
@@ -9,38 +7,4 @@ export default class RootContainer extends FlexContainer {
this.id('root-widget');
this.css('height', '100%');
}
refresh() {
this.$widget.removeClass(); // remove all classes
const note = appContext.tabManager.getActiveContextNote();
if (note) {
this.$widget.addClass(note.getCssClass());
this.$widget.addClass(utils.getNoteTypeClass(note.type));
this.$widget.addClass(utils.getMimeTypeClass(note.mime));
this.$widget.toggleClass("protected", note.isProtected);
}
}
noteSwitchedEvent() {
this.refresh();
}
activeContextChangedEvent() {
this.refresh();
}
noteSwitchedAndActivatedEvent() {
this.refresh();
}
entitiesReloadedEvent({loadResults}) {
const note = appContext.tabManager.getActiveContextNote();
if (note && loadResults.isNoteReloaded(note.noteId)) {
this.refresh();
}
}
}

Some files were not shown because too many files have changed in this diff Show More