You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-02 22:49:09 +02:00
All: Refactored REST API to make it testable and to allow further extension
This commit is contained in:
@@ -1,19 +1,10 @@
|
||||
const { netUtils } = require('lib/net-utils');
|
||||
const urlParser = require("url");
|
||||
const Note = require('lib/models/Note');
|
||||
const Folder = require('lib/models/Folder');
|
||||
const Resource = require('lib/models/Resource');
|
||||
const Tag = require('lib/models/Tag');
|
||||
const Setting = require('lib/models/Setting');
|
||||
const { shim } = require('lib/shim');
|
||||
const md5 = require('md5');
|
||||
const { fileExtension, safeFileExtension, safeFilename, filename } = require('lib/path-utils');
|
||||
const HtmlToMd = require('lib/HtmlToMd');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const markdownUtils = require('lib/markdownUtils');
|
||||
const mimeUtils = require('lib/mime-utils.js').mime;
|
||||
const randomClipperPort = require('lib/randomClipperPort');
|
||||
const enableServerDestroy = require('server-destroy');
|
||||
const Api = require('lib/services/rest/Api');
|
||||
|
||||
class ClipperServer {
|
||||
|
||||
@@ -22,6 +13,7 @@ class ClipperServer {
|
||||
this.startState_ = 'idle';
|
||||
this.server_ = null;
|
||||
this.port_ = null;
|
||||
this.api_ = new Api();
|
||||
}
|
||||
|
||||
static instance() {
|
||||
@@ -32,6 +24,7 @@ class ClipperServer {
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
this.api_.setLogger(l);
|
||||
}
|
||||
|
||||
logger() {
|
||||
@@ -65,140 +58,6 @@ class ClipperServer {
|
||||
});
|
||||
}
|
||||
|
||||
htmlToMdParser() {
|
||||
if (this.htmlToMdParser_) return this.htmlToMdParser_;
|
||||
this.htmlToMdParser_ = new HtmlToMd();
|
||||
return this.htmlToMdParser_;
|
||||
}
|
||||
|
||||
async requestNoteToNote(requestNote) {
|
||||
const output = {
|
||||
title: requestNote.title ? requestNote.title : '',
|
||||
body: requestNote.body ? requestNote.body : '',
|
||||
};
|
||||
|
||||
if (requestNote.body_html) {
|
||||
// Parsing will not work if the HTML is not wrapped in a top level tag, which is not guaranteed
|
||||
// when getting the content from elsewhere. So here wrap it - it won't change anything to the final
|
||||
// rendering but it makes sure everything will be parsed.
|
||||
output.body = await this.htmlToMdParser().parse('<div>' + requestNote.body_html + '</div>', {
|
||||
baseUrl: requestNote.base_url ? requestNote.base_url : '',
|
||||
});
|
||||
}
|
||||
|
||||
if (requestNote.parent_id) {
|
||||
output.parent_id = requestNote.parent_id;
|
||||
} else {
|
||||
const folder = await Folder.defaultFolder();
|
||||
if (!folder) throw new Error('Cannot find folder for note');
|
||||
output.parent_id = folder.id;
|
||||
}
|
||||
|
||||
if (requestNote.source_url) output.source_url = requestNote.source_url;
|
||||
if (requestNote.author) output.author = requestNote.author;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Note must have been saved first
|
||||
async attachImageFromDataUrl_(note, imageDataUrl, cropRect) {
|
||||
const tempDir = Setting.value('tempDir');
|
||||
const mime = mimeUtils.fromDataUrl(imageDataUrl);
|
||||
let ext = mimeUtils.toFileExtension(mime) || '';
|
||||
if (ext) ext = '.' + ext;
|
||||
const tempFilePath = tempDir + '/' + md5(Math.random() + '_' + Date.now()) + ext;
|
||||
const imageConvOptions = {};
|
||||
if (cropRect) imageConvOptions.cropRect = cropRect;
|
||||
await shim.imageFromDataUrl(imageDataUrl, tempFilePath, imageConvOptions);
|
||||
return await shim.attachFileToNote(note, tempFilePath);
|
||||
}
|
||||
|
||||
async downloadImage_(url) {
|
||||
const tempDir = Setting.value('tempDir');
|
||||
|
||||
const isDataUrl = url && url.toLowerCase().indexOf('data:') === 0;
|
||||
|
||||
const name = isDataUrl ? md5(Math.random() + '_' + Date.now()) : filename(url);
|
||||
let fileExt = isDataUrl ? mimeUtils.toFileExtension(mimeUtils.fromDataUrl(url)) : safeFileExtension(fileExtension(url).toLowerCase());
|
||||
if (fileExt) fileExt = '.' + fileExt;
|
||||
let imagePath = tempDir + '/' + safeFilename(name) + fileExt;
|
||||
if (await shim.fsDriver().exists(imagePath)) imagePath = tempDir + '/' + safeFilename(name) + '_' + md5(Math.random() + '_' + Date.now()).substr(0,10) + fileExt;
|
||||
|
||||
try {
|
||||
if (isDataUrl) {
|
||||
await shim.imageFromDataUrl(url, imagePath);
|
||||
} else {
|
||||
await shim.fetchBlob(url, { path: imagePath });
|
||||
}
|
||||
return imagePath;
|
||||
} catch (error) {
|
||||
this.logger().warn('Cannot download image at ' + url, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async downloadImages_(urls) {
|
||||
const PromisePool = require('es6-promise-pool')
|
||||
|
||||
const output = {};
|
||||
|
||||
let urlIndex = 0;
|
||||
const promiseProducer = () => {
|
||||
if (urlIndex >= urls.length) return null;
|
||||
|
||||
const url = urls[urlIndex++];
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const imagePath = await this.downloadImage_(url);
|
||||
if (imagePath) output[url] = { path: imagePath };
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const concurrency = 3
|
||||
const pool = new PromisePool(promiseProducer, concurrency)
|
||||
await pool.start()
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async createResourcesFromPaths_(urls) {
|
||||
for (let url in urls) {
|
||||
if (!urls.hasOwnProperty(url)) continue;
|
||||
const urlInfo = urls[url];
|
||||
try {
|
||||
const resource = await shim.createResourceFromPath(urlInfo.path);
|
||||
urlInfo.resource = resource;
|
||||
} catch (error) {
|
||||
this.logger().warn('Cannot create resource for ' + url, error);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
async removeTempFiles_(urls) {
|
||||
for (let url in urls) {
|
||||
if (!urls.hasOwnProperty(url)) continue;
|
||||
const urlInfo = urls[url];
|
||||
try {
|
||||
await shim.fsDriver().remove(urlInfo.path);
|
||||
} catch (error) {
|
||||
this.logger().warn('Cannot remove ' + urlInfo.path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
replaceImageUrlsByResources_(md, urls) {
|
||||
let output = md.replace(/(!\[.*?\]\()([^\s\)]+)(.*?\))/g, (match, before, imageUrl, after) => {
|
||||
const urlInfo = urls[imageUrl];
|
||||
if (!urlInfo || !urlInfo.resource) return before + imageUrl + after;
|
||||
const resourceUrl = Resource.internalUrl(urlInfo.resource);
|
||||
return before + resourceUrl + after;
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async findAvailablePort() {
|
||||
const tcpPortUsed = require('tcp-port-used');
|
||||
|
||||
@@ -251,26 +110,33 @@ class ClipperServer {
|
||||
response.end();
|
||||
}
|
||||
|
||||
const requestId = Date.now();
|
||||
this.logger().info('Request (' + requestId + '): ' + request.method + ' ' + request.url);
|
||||
const writeResponse = (code, response) => {
|
||||
if (typeof response === 'string') {
|
||||
writeResponseText(code, response);
|
||||
} else {
|
||||
writeResponseJson(code, response);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger().info('Request: ' + request.method + ' ' + request.url);
|
||||
|
||||
const url = urlParser.parse(request.url, true);
|
||||
|
||||
if (request.method === 'GET') {
|
||||
if (url.pathname === '/ping') {
|
||||
return writeResponseText(200, 'JoplinClipperServer');
|
||||
const execRequest = async (request, body = '') => {
|
||||
try {
|
||||
const response = await this.api_.route(request.method, url.pathname, url.query, body);
|
||||
writeResponse(200, response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
writeResponse(error.httpCode ? error.httpCode : 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/folders') {
|
||||
const structure = await Folder.allAsTree({ fields: ['id', 'parent_id', 'title'] });
|
||||
return writeResponseJson(200, structure);
|
||||
}
|
||||
|
||||
if (url.pathname === '/tags') {
|
||||
return writeResponseJson(200, await Tag.all({ fields: ['id', 'title'] }));
|
||||
}
|
||||
} else if (request.method === 'POST') {
|
||||
if (url.pathname === '/notes') {
|
||||
if (request.method === 'OPTIONS') {
|
||||
writeCorsHeaders(200);
|
||||
response.end();
|
||||
} else {
|
||||
if (request.method === 'POST') {
|
||||
let body = '';
|
||||
|
||||
request.on('data', (data) => {
|
||||
@@ -278,57 +144,14 @@ class ClipperServer {
|
||||
});
|
||||
|
||||
request.on('end', async () => {
|
||||
try {
|
||||
const requestNote = JSON.parse(body);
|
||||
let note = await this.requestNoteToNote(requestNote);
|
||||
|
||||
const imageUrls = markdownUtils.extractImageUrls(note.body);
|
||||
|
||||
this.logger().info('Request (' + requestId + '): Downloading images: ' + imageUrls.length);
|
||||
|
||||
let result = await this.downloadImages_(imageUrls);
|
||||
|
||||
this.logger().info('Request (' + requestId + '): Creating resources from paths: ' + Object.getOwnPropertyNames(result).length);
|
||||
|
||||
result = await this.createResourcesFromPaths_(result);
|
||||
await this.removeTempFiles_(result);
|
||||
note.body = this.replaceImageUrlsByResources_(note.body, result);
|
||||
|
||||
this.logger().info('Request (' + requestId + '): Saving note...');
|
||||
|
||||
note = await Note.save(note);
|
||||
|
||||
if (requestNote.tags) {
|
||||
const tagTitles = requestNote.tags.split(',');
|
||||
await Tag.setNoteTagsByTitles(note.id, tagTitles);
|
||||
}
|
||||
|
||||
if (requestNote.image_data_url) {
|
||||
await this.attachImageFromDataUrl_(note, requestNote.image_data_url, requestNote.crop_rect);
|
||||
}
|
||||
|
||||
this.logger().info('Request (' + requestId + '): Created note ' + note.id);
|
||||
return writeResponseJson(200, note);
|
||||
} catch (error) {
|
||||
this.logger().error(error);
|
||||
return writeResponseJson(400, { errorCode: 'exception', errorMessage: error.message });
|
||||
}
|
||||
execRequest(request, body);
|
||||
});
|
||||
} else {
|
||||
return writeResponseJson(404, { errorCode: 'not_found' });
|
||||
execRequest(request);
|
||||
}
|
||||
} else if (request.method === 'OPTIONS') {
|
||||
writeCorsHeaders(200);
|
||||
response.end();
|
||||
} else {
|
||||
return writeResponseJson(405, { errorCode: 'method_not_allowed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.server_.on('close', () => {
|
||||
|
||||
});
|
||||
|
||||
enableServerDestroy(this.server_);
|
||||
|
||||
this.logger().info('Starting Clipper server on port ' + this.port_);
|
||||
|
||||
Reference in New Issue
Block a user