ETAPI delete/patch, refactoring

This commit is contained in:
zadam 2022-01-07 19:33:59 +01:00
parent 82b2871a08
commit 9ee1c9f3da
36 changed files with 1304 additions and 11678 deletions

View file

@ -3,7 +3,7 @@
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_16" project-jdk-name="openjdk-16" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "mig_api_tokens"
(
apiTokenId TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0);
INSERT INTO mig_api_tokens (apiTokenId, name, token, utcDateCreated, isDeleted)
SELECT apiTokenId, 'Trilium Sender', token, utcDateCreated, isDeleted FROM api_tokens;
DROP TABLE api_tokens;
ALTER TABLE mig_api_tokens RENAME TO api_tokens;

View file

@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS "entity_changes" (
CREATE TABLE IF NOT EXISTS "api_tokens"
(
apiTokenId TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0);

11453
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,12 +4,15 @@ const dateUtils = require('../../services/date_utils.js');
const AbstractEntity = require("./abstract_entity.js");
/**
* ApiToken is an entity representing token used to authenticate against Trilium API from client applications. Currently used only by Trilium Sender.
* ApiToken is an entity representing token used to authenticate against Trilium API from client applications.
* Used by:
* - Trilium Sender
* - ETAPI clients
*/
class ApiToken extends AbstractEntity {
static get entityName() { return "api_tokens"; }
static get primaryKeyName() { return "apiTokenId"; }
static get hashedProperties() { return ["apiTokenId", "token", "utcDateCreated"]; }
static get hashedProperties() { return ["apiTokenId", "name", "token", "utcDateCreated"]; }
constructor(row) {
super();
@ -17,6 +20,8 @@ class ApiToken extends AbstractEntity {
/** @type {string} */
this.apiTokenId = row.apiTokenId;
/** @type {string} */
this.name = row.name;
/** @type {string} */
this.token = row.token;
/** @type {string} */
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
@ -25,6 +30,7 @@ class ApiToken extends AbstractEntity {
getPojo() {
return {
apiTokenId: this.apiTokenId,
name: this.name,
token: this.token,
utcDateCreated: this.utcDateCreated
}

64
src/etapi/attributes.js Normal file
View file

@ -0,0 +1,64 @@
const becca = require("../becca/becca");
const ru = require("./route_utils");
const mappers = require("./mappers");
const attributeService = require("../services/attributes");
const validators = require("./validators.js");
function register(router) {
ru.route(router, 'get', '/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = ru.getAndCheckAttribute(req.params.attributeId);
res.json(mappers.mapAttributeToPojo(attribute));
});
ru.route(router, 'post' ,'/etapi/attributes', (req, res, next) => {
const params = req.body;
ru.getAndCheckNote(params.noteId);
if (params.type === 'relation') {
ru.getAndCheckNote(params.value);
}
if (params.type !== 'relation' && params.type !== 'label') {
throw new ru.EtapiError(400, ru.GENERIC_CODE, `Only "relation" and "label" are supported attribute types, "${params.type}" given.`);
}
try {
const attr = attributeService.createAttribute(params);
res.json(mappers.mapAttributeToPojo(attr));
}
catch (e) {
throw new ru.EtapiError(400, ru.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'value': validators.isString
};
ru.route(router, 'patch' ,'/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = ru.getAndCheckAttribute(req.params.attributeId);
ru.validateAndPatch(attribute, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapAttributeToPojo(attribute));
});
ru.route(router, 'delete' ,'/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = becca.getAttribute(req.params.attributeId);
if (!attribute) {
return res.sendStatus(204);
}
attribute.markAsDeleted();
res.sendStatus(204);
});
}
module.exports = {
register
};

78
src/etapi/branches.js Normal file
View file

@ -0,0 +1,78 @@
const becca = require("../becca/becca.js");
const ru = require("./route_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 validators = require("./validators.js");
function register(router) {
ru.route(router, 'get', '/etapi/branches/:branchId', (req, res, next) => {
const branch = ru.getAndCheckBranch(req.params.branchId);
res.json(mappers.mapBranchToPojo(branch));
});
ru.route(router, 'post' ,'/etapi/branches', (req, res, next) => {
const params = req.body;
ru.getAndCheckNote(params.noteId);
ru.getAndCheckNote(params.parentNoteId);
const existing = becca.getBranchFromChildAndParent(params.noteId, params.parentNoteId);
if (existing) {
existing.notePosition = params.notePosition;
existing.prefix = params.prefix;
existing.save();
return res.json(mappers.mapBranchToPojo(existing));
}
try {
const branch = new Branch(params).save();
res.json(mappers.mapBranchToPojo(branch));
}
catch (e) {
throw new ru.EtapiError(400, ru.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'notePosition': validators.isInteger,
'prefix': validators.isStringOrNull,
'isExpanded': validators.isBoolean
};
ru.route(router, 'patch' ,'/etapi/branches/:branchId', (req, res, next) => {
const branch = ru.getAndCheckBranch(req.params.branchId);
ru.validateAndPatch(branch, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapBranchToPojo(branch));
});
ru.route(router, 'delete' ,'/etapi/branches/:branchId', (req, res, next) => {
const branch = becca.getBranch(req.params.branchId);
if (!branch) {
return res.sendStatus(204);
}
noteService.deleteBranch(branch, null, new TaskContext('no-progress-reporting'));
res.sendStatus(204);
});
ru.route(router, 'post' ,'/etapi/refresh-note-ordering/:parentNoteId', (req, res, next) => {
ru.getAndCheckNote(req.params.parentNoteId);
entityChangesService.addNoteReorderingEntityChange(req.params.parentNoteId, "etapi");
});
}
module.exports = {
register
};

49
src/etapi/mappers.js Normal file
View file

@ -0,0 +1,49 @@
function mapNoteToPojo(note) {
return {
noteId: note.noteId,
isProtected: note.isProtected,
title: note.title,
type: note.type,
mime: note.mime,
dateCreated: note.dateCreated,
dateModified: note.dateModified,
utcDateCreated: note.utcDateCreated,
utcDateModified: note.utcDateModified,
parentNoteIds: note.getParentNotes().map(p => p.noteId),
childNoteIds: note.getChildNotes().map(ch => ch.noteId),
parentBranchIds: note.getParentBranches().map(p => p.branchId),
childBranchIds: note.getChildBranches().map(ch => ch.branchId),
attributes: note.getAttributes().map(attr => mapAttributeToPojo(attr))
};
}
function mapBranchToPojo(branch) {
return {
branchId: branch.branchId,
noteId: branch.noteId,
parentNoteId: branch.parentNoteId,
prefix: branch.prefix,
notePosition: branch.notePosition,
isExpanded: branch.isExpanded,
utcDateModified: branch.utcDateModified
};
}
function mapAttributeToPojo(attr) {
return {
attributeId: attr.attributeId,
noteId: attr.noteId,
type: attr.type,
name: attr.name,
value: attr.value,
position: attr.position,
isInheritable: attr.isInheritable,
utcDateModified: attr.utcDateModified
};
}
module.exports = {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo
};

82
src/etapi/notes.js Normal file
View file

@ -0,0 +1,82 @@
const becca = require("../becca/becca");
const utils = require("../services/utils");
const ru = require("./route_utils");
const mappers = require("./mappers");
const noteService = require("../services/notes");
const TaskContext = require("../services/task_context");
const validators = require("./validators");
function register(router) {
ru.route(router, 'get', '/etapi/notes/:noteId', (req, res, next) => {
const note = ru.getAndCheckNote(req.params.noteId);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'get', '/etapi/notes/:noteId/content', (req, res, next) => {
const note = ru.getAndCheckNote(req.params.noteId);
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader('Content-Type', note.mime);
res.send(note.getContent());
});
ru.route(router, 'post' ,'/etapi/create-note', (req, res, next) => {
const params = req.body;
ru.getAndCheckNote(params.parentNoteId);
try {
const resp = noteService.createNewNote(params);
res.json({
note: mappers.mapNoteToPojo(resp.note),
branch: mappers.mapBranchToPojo(resp.branch)
});
}
catch (e) {
return ru.sendError(res, 400, ru.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'title': validators.isString,
'type': validators.isString,
'mime': validators.isString
};
ru.route(router, 'patch' ,'/etapi/notes/:noteId', (req, res, next) => {
const note = ru.getAndCheckNote(req.params.noteId)
if (note.isProtected) {
throw new ru.EtapiError(404, "NOTE_IS_PROTECTED", `Note ${req.params.noteId} is protected and cannot be modified through ETAPI`);
}
ru.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'delete' ,'/etapi/notes/:noteId', (req, res, next) => {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
return res.sendStatus(204);
}
noteService.deleteNote(note, null, new TaskContext('no-progress-reporting'));
res.sendStatus(204);
});
}
module.exports = {
register
};

132
src/etapi/route_utils.js Normal file
View file

@ -0,0 +1,132 @@
const cls = require("../services/cls.js");
const sql = require("../services/sql.js");
const log = require("../services/log.js");
const becca = require("../becca/becca.js");
const GENERIC_CODE = "GENERIC";
class EtapiError extends Error {
constructor(statusCode, code, message) {
super();
this.statusCode = statusCode;
this.code = code;
this.message = message;
}
}
function sendError(res, statusCode, code, message) {
return res
.set('Content-Type', 'application/json')
.status(statusCode)
.send(JSON.stringify({
"status": statusCode,
"code": code,
"message": message
}));
}
function checkEtapiAuth(req, res, next) {
if (false) {
sendError(res, 401, "NOT_AUTHENTICATED", "Not authenticated");
}
else {
next();
}
}
function route(router, method, path, routeHandler) {
router[method](path, checkEtapiAuth, (req, res, next) => {
try {
cls.namespace.bindEmitter(req);
cls.namespace.bindEmitter(res);
cls.init(() => {
cls.set('sourceId', "etapi");
cls.set('localNowDateTime', req.headers['trilium-local-now-datetime']);
const cb = () => routeHandler(req, res, next);
return sql.transactional(cb);
});
}
catch (e) {
log.error(`${method} ${path} threw exception ${e.message} with stacktrace: ${e.stack}`);
if (e instanceof EtapiError) {
sendError(res, e.statusCode, e.code, e.message);
}
else {
sendError(res, 500, GENERIC_CODE, e.message);
}
}
});
}
function getAndCheckNote(noteId) {
const note = becca.getNote(noteId);
if (note) {
return note;
}
else {
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found`);
}
}
function getAndCheckBranch(branchId) {
const branch = becca.getBranch(branchId);
if (branch) {
return branch;
}
else {
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found`);
}
}
function getAndCheckAttribute(attributeId) {
const attribute = becca.getAttribute(attributeId);
if (attribute) {
return attribute;
}
else {
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found`);
}
}
function validateAndPatch(entity, props, allowedProperties) {
for (const key of Object.keys(props)) {
if (!(key in allowedProperties)) {
throw new EtapiError(400, "PROPERTY_NOT_ALLOWED_FOR_PATCH", `Property '${key}' is not allowed for PATCH.`);
}
else {
const validator = allowedProperties[key];
const validationResult = validator(props[key]);
if (validationResult) {
throw new EtapiError(400, "PROPERTY_VALIDATION_ERROR", `Validation failed on property '${key}': ${validationResult}`);
}
}
}
// validation passed, let's patch
for (const propName of Object.keys(props)) {
entity[propName] = props[propName];
}
entity.save();
}
module.exports = {
EtapiError,
sendError,
checkEtapiAuth,
route,
GENERIC_CODE,
validateAndPatch,
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute,
getNotAllowedPatchPropertyError: (propertyName, allowedProperties) => new EtapiError(400, "PROPERTY_NOT_ALLOWED_FOR_PATCH", `Property '${propertyName}' is not allowed to be patched, allowed properties are ${allowedProperties}.`),
}

View file

@ -0,0 +1,95 @@
openapi: "3.1.0"
info:
version: 1.0.0
title: ETAPI
description: External Trilium API
contact:
name: zadam
email: zadam.apps@gmail.com
url: https://github.com/zadam/trilium
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
servers:
- url: http://localhost:37740/etapi
- url: http://localhost:8080/etapi
paths:
/pets/{id}:
get:
description: Returns a user based on a single ID, if the user does not have access to the pet
operationId: find pet by id
parameters:
- name: id
in: path
description: ID of pet to fetch
required: true
schema:
type: integer
format: int64
responses:
'200':
description: pet response
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
description: deletes a single pet based on the ID supplied
operationId: deletePet
parameters:
- name: id
in: path
description: ID of pet to delete
required: true
schema:
type: integer
format: int64
responses:
'204':
description: pet deleted
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Pet:
allOf:
- $ref: '#/components/schemas/NewPet'
- type: object
required:
- id
properties:
id:
type: integer
format: int64
NewPet:
type: object
required:
- name
properties:
name:
type: string
tag:
type: string
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string

View file

@ -0,0 +1,77 @@
const specialNotesService = require("../services/special_notes");
const dateNotesService = require("../services/date_notes");
const ru = require("./route_utils");
const mappers = require("./mappers");
const getDateInvalidError = date => new ru.EtapiError(400, "DATE_INVALID", `Date "${date}" is not valid.`);
const getMonthInvalidError = month => new ru.EtapiError(400, "MONTH_INVALID", `Month "${month}" is not valid.`);
const getYearInvalidError = year => new ru.EtapiError(400, "YEAR_INVALID", `Year "${year}" is not valid.`);
function isValidDate(date) {
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date)) {
return false;
}
return !!Date.parse(date);
}
function register(router) {
ru.route(router, 'get', '/etapi/inbox/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = specialNotesService.getInboxNote(date);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'get', '/etapi/date/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = dateNotesService.getDateNote(date);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'get', '/etapi/week/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = dateNotesService.getWeekNote(date);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'get', '/etapi/month/:month', (req, res, next) => {
const {month} = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
throw getMonthInvalidError(res, month);
}
const note = dateNotesService.getMonthNote(month);
res.json(mappers.mapNoteToPojo(note));
});
ru.route(router, 'get', '/etapi/year/:year', (req, res, next) => {
const {year} = req.params;
if (!/[0-9]{4}/.test(year)) {
throw getYearInvalidError(res, year);
}
const note = dateNotesService.getYearNote(year);
res.json(mappers.mapNoteToPojo(note));
});
}
module.exports = {
register
};

30
src/etapi/validators.js Normal file
View file

@ -0,0 +1,30 @@
function isString(obj) {
if (typeof obj !== 'string') {
return `'${obj}' is not a string`;
}
}
function isStringOrNull(obj) {
if (obj) {
return isString(obj);
}
}
function isBoolean(obj) {
if (typeof obj !== 'boolean') {
return `'${obj}' is not a boolean`;
}
}
function isInteger(obj) {
if (!Number.isInteger(obj)) {
return `'${obj}' is not an integer`;
}
}
module.exports = {
isString,
isStringOrNull,
isBoolean,
isInteger
};

View file

@ -81,6 +81,7 @@ async function renderAttributes(attributes, renderIsInheritable) {
const HIDDEN_ATTRIBUTES = [
'originalFileName',
'fileSize',
'template',
'cssClass',
'iconClass',

View file

@ -3,16 +3,17 @@ const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]};
const CODE_MIRROR = {
js: [
"libraries/codemirror/codemirror.js",
"libraries/codemirror/addon/mode/loadmode.js",
"libraries/codemirror/addon/mode/simple.js",
"libraries/codemirror/addon/fold/xml-fold.js",
"libraries/codemirror/addon/display/placeholder.js",
"libraries/codemirror/addon/edit/matchbrackets.js",
"libraries/codemirror/addon/edit/matchtags.js",
"libraries/codemirror/addon/fold/xml-fold.js",
"libraries/codemirror/addon/lint/lint.js",
"libraries/codemirror/addon/lint/eslint.js",
"libraries/codemirror/addon/mode/loadmode.js",
"libraries/codemirror/addon/mode/simple.js",
"libraries/codemirror/addon/search/match-highlighter.js",
"libraries/codemirror/mode/meta.js",
"libraries/codemirror/keymap/vim.js",
"libraries/codemirror/addon/lint/lint.js",
"libraries/codemirror/addon/lint/eslint.js"
"libraries/codemirror/keymap/vim.js"
],
css: [
"libraries/codemirror/codemirror.css",

View file

@ -218,6 +218,13 @@ class NoteContext extends Component {
}
}
}
hasNoteList() {
return this.note.hasChildren()
&& ['book', 'text', 'code'].includes(this.note.type)
&& this.note.mime !== 'text/x-sqlite;schema=trilium'
&& !this.note.hasLabel('hideChildrenOverview');
}
}
export default NoteContext;

View file

@ -29,6 +29,10 @@ const TPL = `
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
}
.note-detail.full-height {
height: 100%;
}
</style>
</div>
`;
@ -128,7 +132,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
await typeWidget.handleEvent('setNoteContext', {noteContext: this.noteContext});
// this is happening in update() so note has been already set and we need to reflect this
// this is happening in update() so note has been already set, and we need to reflect this
await typeWidget.handleEvent('noteSwitched', {
noteContext: this.noteContext,
notePath: this.noteContext.notePath
@ -136,6 +140,15 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
this.child(typeWidget);
}
this.checkFullHeight();
}
checkFullHeight() {
// https://github.com/zadam/trilium/issues/2522
this.$widget.toggleClass("full-height",
!this.noteContext.hasNoteList()
&& ['editable-text', 'editable-code'].includes(this.type));
}
getTypeWidget() {

View file

@ -5,8 +5,6 @@ const TPL = `
<div class="note-list-widget">
<style>
.note-list-widget {
flex-grow: 100000;
flex-shrink: 100000;
min-height: 0;
overflow: auto;
}
@ -22,11 +20,7 @@ const TPL = `
export default class NoteListWidget extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& ['book', 'text', 'code'].includes(this.note.type)
&& this.note.mime !== 'text/x-sqlite;schema=trilium'
&& this.note.hasChildren()
&& !this.note.hasLabel('hideChildrenOverview');
return super.isEnabled() && this.noteContext.hasNoteList();
}
doRender() {

View file

@ -13,10 +13,12 @@ const TPL = `
<style>
.note-detail-code {
position: relative;
height: 100%;
}
.note-detail-code-editor {
min-height: 50px;
height: 100%;
}
</style>
@ -105,7 +107,8 @@ export default class EditableCodeTypeWidget extends TypeWidget {
// we linewrap partly also because without it horizontal scrollbar displays only when you scroll
// all the way to the bottom of the note. With line wrap there's no horizontal scrollbar so no problem
lineWrapping: true,
dragDrop: false // with true the editor inlines dropped files which is not what we expect
dragDrop: false, // with true the editor inlines dropped files which is not what we expect
placeholder: "Type the content of your code note here..."
});
this.codeEditor.on('change', () => this.spacedUpdate.scheduleUpdate());

View file

@ -36,6 +36,7 @@ const TPL = `
font-family: var(--detail-font-family);
padding-left: 14px;
padding-top: 10px;
height: 100%;
}
.note-detail-editable-text a:hover {
@ -73,6 +74,7 @@ const TPL = `
border: 0 !important;
box-shadow: none !important;
min-height: 50px;
height: 100%;
}
</style>

View file

@ -241,6 +241,10 @@ body .CodeMirror {
background-color: #eeeeee
}
.CodeMirror pre.CodeMirror-placeholder {
color: #999 !important;
}
#sql-console-query {
height: 150px;
width: 100%;

View file

@ -3,320 +3,17 @@ const utils = require("../../services/utils");
const noteService = require("../../services/notes");
const attributeService = require("../../services/attributes");
const Branch = require("../../becca/entities/branch");
const cls = require("../../services/cls");
const sql = require("../../services/sql");
const log = require("../../services/log");
const specialNotesService = require("../../services/special_notes");
const dateNotesService = require("../../services/date_notes");
const entityChangesService = require("../../services/entity_changes.js");
const GENERIC_CODE = "GENERIC";
function sendError(res, statusCode, code, message) {
return res
.set('Content-Type', 'application/json')
.status(statusCode)
.send(JSON.stringify({
"status": statusCode,
"code": code,
"message": message
}));
}
const sendNoteNotFoundError = (res, noteId) => sendError(res, 404, "NOTE_NOT_FOUND", `Note ${noteId} not found`);
const sendBranchNotFoundError = (res, branchId) => sendError(res, 404, "BRANCH_NOT_FOUND", `Branch ${branchId} not found`);
const sendAttributeNotFoundError = (res, attributeId) => sendError(res, 404, "ATTRIBUTE_NOT_FOUND", `Attribute ${attributeId} not found`);
const sendDateInvalidError = (res, date) => sendError(res, 400, "DATE_INVALID", `Date "${date}" is not valid.`);
const sendMonthInvalidError = (res, month) => sendError(res, 400, "MONTH_INVALID", `Month "${month}" is not valid.`);
const sendYearInvalidError = (res, year) => sendError(res, 400, "YEAR_INVALID", `Year "${year}" is not valid.`);
function isValidDate(date) {
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date)) {
return false;
}
return !!Date.parse(date);
}
function checkEtapiAuth(req, res, next) {
if (false) {
sendError(res, 401, "NOT_AUTHENTICATED", "Not authenticated");
}
else {
next();
}
}
const TaskContext = require("../../services/task_context.js");
function register(router) {
function route(method, path, routeHandler) {
router[method](path, checkEtapiAuth, (req, res, next) => {
try {
cls.namespace.bindEmitter(req);
cls.namespace.bindEmitter(res);
cls.init(() => {
cls.set('sourceId', "etapi");
cls.set('localNowDateTime', req.headers['trilium-local-now-datetime']);
const cb = () => routeHandler(req, res, next);
return sql.transactional(cb);
});
}
catch (e) {
log.error(`${method} ${path} threw exception: ` + e.stack);
res.status(500).send(e.message);
}
});
}
route('get', '/etapi/inbox/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
return sendDateInvalidError(res, date);
}
const note = specialNotesService.getInboxNote(date);
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/date/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
return sendDateInvalidError(res, date);
}
const note = dateNotesService.getDateNote(date);
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/week/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
return sendDateInvalidError(res, date);
}
const note = dateNotesService.getWeekNote(date);
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/month/:month', (req, res, next) => {
const {month} = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
return sendMonthInvalidError(res, month);
}
const note = dateNotesService.getMonthNote(month);
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/year/:year', (req, res, next) => {
const {year} = req.params;
if (!/[0-9]{4}/.test(year)) {
return sendYearInvalidError(res, year);
}
const note = dateNotesService.getYearNote(year);
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/notes/:noteId', (req, res, next) => {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
return sendNoteNotFoundError(res, noteId);
}
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/notes/:noteId', (req, res, next) => {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
return sendNoteNotFoundError(res, noteId);
}
res.json(mapNoteToPojo(note));
});
route('get', '/etapi/notes/:noteId/content', (req, res, next) => {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
return sendNoteNotFoundError(res, noteId);
}
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader('Content-Type', note.mime);
res.send(note.getContent());
});
route('get', '/etapi/branches/:branchId', (req, res, next) => {
const {branchId} = req.params;
const branch = becca.getBranch(branchId);
if (!branch) {
return sendBranchNotFoundError(res, branchId);
}
res.json(mapBranchToPojo(branch));
});
route('get', '/etapi/attributes/:attributeId', (req, res, next) => {
const {attributeId} = req.params;
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return sendAttributeNotFoundError(res, attributeId);
}
res.json(mapAttributeToPojo(attribute));
});
route('post' ,'/etapi/notes', (req, res, next) => {
const params = req.body;
if (!becca.getNote(params.parentNoteId)) {
return sendNoteNotFoundError(res, params.parentNoteId);
}
try {
const resp = noteService.createNewNote(params);
res.json({
note: mapNoteToPojo(resp.note),
branch: mapBranchToPojo(resp.branch)
});
}
catch (e) {
return sendError(res, 400, GENERIC_CODE, e.message);
}
});
route('post' ,'/etapi/branches', (req, res, next) => {
const params = req.body;
if (!becca.getNote(params.noteId)) {
return sendNoteNotFoundError(res, params.noteId);
}
if (!becca.getNote(params.parentNoteId)) {
return sendNoteNotFoundError(res, params.parentNoteId);
}
const existing = becca.getBranchFromChildAndParent(params.noteId, params.parentNoteId);
if (existing) {
existing.notePosition = params.notePosition;
existing.prefix = params.prefix;
existing.save();
return res.json(mapBranchToPojo(existing));
}
try {
const branch = new Branch(params).save();
res.json(mapBranchToPojo(branch));
}
catch (e) {
return sendError(res, 400, GENERIC_CODE, e.message);
}
});
route('post' ,'/etapi/attributes', (req, res, next) => {
const params = req.body;
if (!becca.getNote(params.noteId)) {
return sendNoteNotFoundError(res, params.noteId);
}
if (params.type === 'relation' && !becca.getNote(params.value)) {
return sendNoteNotFoundError(res, params.value);
}
if (params.type !== 'relation' && params.type !== 'label') {
return sendError(res, 400, GENERIC_CODE, `Only "relation" and "label" are supported attribute types, "${params.type}" given.`);
}
try {
const attr = attributeService.createAttribute(params);
res.json(mapAttributeToPojo(attr));
}
catch (e) {
return sendError(res, 400, GENERIC_CODE, e.message);
}
});
route('post' ,'/etapi/refresh-note-ordering/:parentNoteId', (req, res, next) => {
const {parentNoteId} = req.params;
if (!becca.getNote(parentNoteId)) {
return sendNoteNotFoundError(res, parentNoteId);
}
entityChangesService.addNoteReorderingEntityChange(parentNoteId, "etapi");
});
}
function mapNoteToPojo(note) {
return {
noteId: note.noteId,
isProtected: note.isProtected,
title: note.title,
type: note.type,
mime: note.mime,
dateCreated: note.dateCreated,
dateModified: note.dateModified,
utcDateCreated: note.utcDateCreated,
utcDateModified: note.utcDateModified,
parentNoteIds: note.getParentNotes().map(p => p.noteId),
childNoteIds: note.getChildNotes().map(ch => ch.noteId),
parentBranchIds: note.getParentBranches().map(p => p.branchId),
childBranchIds: note.getChildBranches().map(ch => ch.branchId),
attributes: note.getAttributes().map(attr => mapAttributeToPojo(attr))
};
}
function mapBranchToPojo(branch) {
return {
branchId: branch.branchId,
noteId: branch.noteId,
parentNoteId: branch.parentNoteId,
prefix: branch.prefix,
notePosition: branch.notePosition,
isExpanded: branch.isExpanded,
utcDateModified: branch.utcDateModified
};
}
function mapAttributeToPojo(attr) {
return {
attributeId: attr.attributeId,
noteId: attr.noteId,
type: attr.type,
name: attr.name,
value: attr.value,
position: attr.position,
isInheritable: attr.isInheritable,
utcDateModified: attr.utcDateModified
};
}
module.exports = {

View file

@ -10,7 +10,6 @@ const appInfo = require('../../services/app_info');
const eventService = require('../../services/events');
const sqlInit = require('../../services/sql_init');
const sql = require('../../services/sql');
const optionService = require('../../services/options');
const ApiToken = require('../../becca/entities/api_token');
const ws = require("../../services/ws");
@ -92,6 +91,8 @@ function token(req) {
}
const apiToken = new ApiToken({
// for backwards compatibility with Sender which does not send the name
name: req.body.tokenName || "Trilium Sender",
token: utils.randomSecureToken()
}).save();

View file

@ -73,9 +73,7 @@ function deleteNote(req) {
const taskContext = TaskContext.getInstance(taskId, 'delete-notes');
for (const branch of note.getParentBranches()) {
noteService.deleteBranch(branch, deleteId, taskContext);
}
noteService.deleteNote(note, deleteId, taskContext);
if (eraseNotes) {
noteService.eraseNotesWithDeleteId(deleteId);

View file

@ -40,7 +40,10 @@ const backendLogRoute = require('./api/backend_log');
const statsRoute = require('./api/stats');
const fontsRoute = require('./api/fonts');
const shareRoutes = require('../share/routes');
const etapiRoutes = require('./api/etapi');
const etapiAttributeRoutes = require('../etapi/attributes');
const etapiBranchRoutes = require('../etapi/branches');
const etapiNoteRoutes = require('../etapi/notes');
const etapiSpecialNoteRoutes = require('../etapi/special_notes');
const log = require('../services/log');
const express = require('express');
@ -376,7 +379,10 @@ function register(app) {
route(GET, '/api/fonts', [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
shareRoutes.register(router);
etapiRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBranchRoutes.register(router);
etapiNoteRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
app.use('', router);
}

View file

@ -4,8 +4,8 @@ const build = require('./build');
const packageJson = require('../../package');
const {TRILIUM_DATA_DIR} = require('./data_dir');
const APP_DB_VERSION = 189;
const SYNC_VERSION = 24;
const APP_DB_VERSION = 190;
const SYNC_VERSION = 25;
const CLIPPER_PROTOCOL_VERSION = "1.0";
module.exports = {

View file

@ -88,7 +88,7 @@ function getMonthNoteTitle(rootNote, monthNumber, dateObj) {
}
/** @returns {Note} */
function getMonthNote(dateStr, rootNote) {
function getMonthNote(dateStr, rootNote = null) {
if (!rootNote) {
rootNote = getRootCalendarNote();
}

View file

@ -105,6 +105,10 @@ function createNewNote(params) {
if (!params.title || params.title.trim().length === 0) {
throw new Error(`Note title must not be empty`);
}
if (params.content === null || params.content === undefined) {
throw new Error(`Note content must be set`);
}
return sql.transactional(() => {
const note = new Note({
@ -519,7 +523,7 @@ function updateNote(noteId, noteUpdates) {
/**
* @param {Branch} branch
* @param {string} deleteId
* @param {string|null} deleteId
* @param {TaskContext} taskContext
*
* @return {boolean} - true if note has been deleted, false otherwise
@ -569,6 +573,17 @@ function deleteBranch(branch, deleteId, taskContext) {
}
}
/**
* @param {Note} note
* @param {string|null} deleteId
* @param {TaskContext} taskContext
*/
function deleteNote(note, deleteId, taskContext) {
for (const branch of note.getParentBranches()) {
deleteBranch(branch, deleteId, taskContext);
}
}
/**
* @param {string} noteId
* @param {TaskContext} taskContext
@ -914,6 +929,7 @@ module.exports = {
createNewNoteWithTarget,
updateNote,
deleteBranch,
deleteNote,
undeleteNote,
protectNoteRecursively,
scanForLinks,

View file

@ -28,7 +28,15 @@ function initNotSyncedOptions(initialized, opts = {}) {
optionService.createOption('lastSyncedPull', '0', false);
optionService.createOption('lastSyncedPush', '0', false);
optionService.createOption('theme', opts.theme || 'white', false);
let theme = 'dark'; // default based on the poll in https://github.com/zadam/trilium/issues/2516
if (utils.isElectron()) {
const {nativeTheme} = require('electron');
theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
}
optionService.createOption('theme', theme, false);
optionService.createOption('syncServerHost', opts.syncServerHost || '', false);
optionService.createOption('syncServerTimeout', '120000', false);

View file

@ -1,4 +1,4 @@
POST {{triliumHost}}/etapi/notes
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
@ -15,12 +15,33 @@ Content-Type: application/json
client.assert(response.body.branch.parentNoteId == "root");
});
client.log(`Created note "${createdNoteId}" and branch ${createdBranchId}`);
client.log(`Created note ` + response.body.note.noteId + ` and branch ` + response.body.branch.branchId);
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "hidden"
}
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.body.parentNoteId == "hidden");
});
client.global.set("clonedBranchId", response.body.branchId);
client.log(`Created cloned branch ` + response.body.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
@ -30,6 +51,9 @@ GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.body.noteId == client.global.get("createdNoteId"));
client.assert(response.body.title == "Hello");
// order is not defined and may fail in the future
client.assert(response.body.parentBranchIds[0] == client.global.get("clonedBranchId"))
client.assert(response.body.parentBranchIds[1] == client.global.get("createdBranchId"));
});
%}
@ -58,6 +82,18 @@ GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.body.branchId == client.global.get("clonedBranchId"));
client.assert(response.body.parentNoteId == "hidden");
});
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
@ -74,7 +110,7 @@ Content-Type: application/json
client.assert(response.status === 200, "Response status is not 200");
});
client.log(`Created attribute ${response.body.attributeId}`);
client.log(`Created attribute ` + response.body.attributeId);
client.global.set("createdAttributeId", response.body.attributeId);
%}

View file

@ -0,0 +1,56 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": "true"
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "ATTRIBUTE_NOT_FOUND");
%}

View file

@ -0,0 +1,71 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}

View file

@ -0,0 +1,107 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": "true"
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
> {% client.assert(response.status === 200, "Response status is not 200"); %}
###
DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "NOTE_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "ATTRIBUTE_NOT_FOUND");
%}

View file

@ -0,0 +1,74 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": "true"
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Content-Type: application/json
{
"value": "CHANGED"
}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
> {%
client.assert(response.body.value === "CHANGED");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Content-Type: application/json
{
"noteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED_FOR_PATCH");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Content-Type: application/json
{
"value": null
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View file

@ -0,0 +1,61 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"type": "text",
"title": "Hello",
"content": ""
}
> {% client.global.set("createdBranchId", response.body.branch.branchId); %}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Content-Type: application/json
{
"prefix": "pref",
"notePosition": 666,
"isExpanded": true
}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
> {%
client.assert(response.status === 200);
client.assert(response.body.prefix === 'pref');
client.assert(response.body.notePosition === 666);
client.assert(response.body.isExpanded === true);
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Content-Type: application/json
{
"parentNoteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED_FOR_PATCH");
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Content-Type: application/json
{
"prefix": 123
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View file

@ -0,0 +1,73 @@
POST {{triliumHost}}/etapi/create-note
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "application/json",
"content": "{}"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Hello');
client.assert(response.body.type === 'code');
client.assert(response.body.mime === 'application/json');
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Content-Type: application/json
{
"title": "Wassup",
"type": "html",
"mime": "text/html"
}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Wassup');
client.assert(response.body.type === 'html');
client.assert(response.body.mime === 'text/html');
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Content-Type: application/json
{
"isProtected": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED_FOR_PATCH");
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Content-Type: application/json
{
"title": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}