text: const { ltrimSlashes } = require('lib/path-utils.js');
  const { Database } = require('lib/database.js');
  const Folder = require('lib/models/Folder');
  const Note = require('lib/models/Note');
  const Tag = require('lib/models/Tag');
  const BaseItem = require('lib/models/BaseItem');
  const Resource = require('lib/models/Resource');
  const BaseModel = require('lib/BaseModel');
  const Setting = require('lib/models/Setting');
  const htmlUtils = require('lib/htmlUtils');
  const markupLanguageUtils = require('lib/markupLanguageUtils');
  const mimeUtils = require('lib/mime-utils.js').mime;
  const { Logger } = require('lib/logger.js');
  const md5 = require('md5');
  const { shim } = require('lib/shim');
  const HtmlToMd = require('lib/HtmlToMd');
  const urlUtils = require('lib/urlUtils.js');
  const ArrayUtils = require('lib/ArrayUtils.js');
  const { netUtils } = require('lib/net-utils');
  const { fileExtension, safeFileExtension, safeFilename, filename } = require('lib/path-utils');
  const ApiResponse = require('lib/services/rest/ApiResponse');
  const SearchEngineUtils = require('lib/services/SearchEngineUtils');
  const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
  const uri2path = require('file-uri-to-path');
  const { MarkupToHtml } = require('lib/joplin-renderer');
  const { uuid } = require('lib/uuid');
  const { ErrorMethodNotAllowed, ErrorForbidden, ErrorBadRequest, ErrorNotFound } = require('./errors');
  
  class Api {
  	constructor(token = null, actionApi = null) {
  		this.token_ = token;
  		this.knownNounces_ = {};
  		this.logger_ = new Logger();
  		this.actionApi_ = actionApi;
  	}
  
  	get token() {
  		return typeof this.token_ === 'function' ? this.token_() : this.token_;
  	}
  
  	parsePath(path) {
  		path = ltrimSlashes(path);
  		if (!path) return { callName: '', params: [] };
  
  		const pathParts = path.split('/');
  		const callSuffix = pathParts.splice(0, 1)[0];
  		const callName = `action_${callSuffix}`;
  		return {
  			callName: callName,
  			params: pathParts,
  		};
  	}
  
  	async route(method, path, query = null, body = null, files = null) {
  		if (!files) files = [];
  		if (!query) query = {};
  
  		const parsedPath = this.parsePath(path);
  		if (!parsedPath.callName) throw new ErrorNotFound(); // Nothing at the root yet
  
  		if (query && query.nounce) {
  			const requestMd5 = md5(JSON.stringify([method, path, body, query, files.length]));
  			if (this.knownNounces_[query.nounce] === requestMd5) {
  				throw new ErrorBadRequest('Duplicate Nounce');
  			}
  			this.knownNounces_[query.nounce] = requestMd5;
  		}
  
  		const request = {
  			method: method,
  			path: ltrimSlashes(path),
  			query: query ? query : {},
  			body: body,
  			bodyJson_: null,
  			bodyJson: function(disallowedProperties = null) {
  				if (!this.bodyJson_) this.bodyJson_ = JSON.parse(this.body);
  
  				if (disallowedProperties) {
  					const filteredBody = Object.assign({}, this.bodyJson_);
  					for (let i = 0; i < disallowedProperties.length; i++) {
  						const n = disallowedProperties[i];
  						delete filteredBody[n];
  					}
  					return filteredBody;
  				}
  
  				return this.bodyJson_;
  			},
  			files: files,
  		};
  
  		let id = null;
  		let link = null;
  		const params = parsedPath.params;
  
  		if (params.length >= 1) {
  			id = params[0];
  			params.splice(0, 1);
  			if (params.length >= 1) {
  				link = params[0];
  				params.splice(0, 1);
  			}
  		}
  
  		request.params = params;
  
  		if (!this[parsedPath.callName]) throw new ErrorNotFound();
  
  		try {
  			return await this[parsedPath.callName](request, id, link);
  		} catch (error) {
  			if (!error.httpCode) error.httpCode = 500;
  			throw error;
  		}
  	}
  
  	setLogger(l) {
  		this.logger_ = l;
  	}
  
  	logger() {
  		return this.logger_;
  	}
  
  	readonlyProperties(requestMethod) {
  		const output = ['created_time', 'updated_time', 'encryption_blob_encrypted', 'encryption_applied', 'encryption_cipher_text'];
  		if (requestMethod !== 'POST') output.splice(0, 0, 'id');
  		return output;
  	}
  
  	fields_(request, defaultFields) {
  		const query = request.query;
  		if (!query || !query.fields) return defaultFields;
  		const fields = query.fields
  			.split(',')
  			.map(f => f.trim())
  			.filter(f => !!f);
  		return fields.length ? fields : defaultFields;
  	}
  
  	checkToken_(request) {
  		// For now, whitelist some calls to allow the web clipper to work
  		// without an extra auth step
  		const whiteList = [['GET', 'ping'], ['GET', 'tags'], ['GET', 'folders'], ['POST', 'notes']];
  
  		for (let i = 0; i < whiteList.length; i++) {
  			if (whiteList[i][0] === request.method && whiteList[i][1] === request.path) return;
  		}
  
  		if (!this.token) return;
  		if (!request.query || !request.query.token) throw new ErrorForbidden('Missing "token" parameter');
  		if (request.query.token !== this.token) throw new ErrorForbidden('Invalid "token" parameter');
  	}
  
  	async defaultAction_(modelType, request, id = null, link = null) {
  		this.checkToken_(request);
  
  		if (link) throw new ErrorNotFound(); // Default action doesn't support links at all for now
  
  		const ModelClass = BaseItem.getClassByItemType(modelType);
  
  		const getOneModel = async () => {
  			const model = await ModelClass.load(id);
  			if (!model) throw new ErrorNotFound();
  			return model;
  		};
  
  		if (request.method === 'GET') {
  			if (id) {
  				return getOneModel();
  			} else {
  				const options = {};
  				const fields = this.fields_(request, []);
  				if (fields.length) options.fields = fields;
  				return await ModelClass.all(options);
  			}
  		}
  
  		if (request.method === 'PUT' && id) {
  			const model = await getOneModel();
  			let newModel = Object.assign({}, model, request.bodyJson(this.readonlyProperties('PUT')));
  			newModel = await ModelClass.save(newModel, { userSideValidation: true });
  			return newModel;
  		}
  
  		if (request.method === 'DELETE' && id) {
  			const model = await getOneModel();
  			await ModelClass.delete(model.id);
  			return;
  		}
  
  		if (request.method === 'POST') {
  			const props = this.readonlyProperties('POST');
  			const idIdx = props.indexOf('id');
  			if (idIdx >= 0) props.splice(idIdx, 1);
  			const model = request.bodyJson(props);
  			const result = await ModelClass.save(model, this.defaultSaveOptions_(model, 'POST'));
  			return result;
  		}
  
  		throw new ErrorMethodNotAllowed();
  	}
  
  	async action_ping(request) {
  		if (request.method === 'GET') {
  			return 'JoplinClipperServer';
  		}
  
  		throw new ErrorMethodNotAllowed();
  	}
  
  	async action_search(request) {
  		this.checkToken_(request);
  
  		if (request.method !== 'GET') throw new ErrorMethodNotAllowed();
  
  		const query = request.query.query;
  		if (!query) throw new ErrorBadRequest('Missing "query" parameter');
  
  		const queryType = request.query.type ? BaseModel.modelNameToType(request.query.type) : BaseModel.TYPE_NOTE;
  
  		if (queryType !== BaseItem.TYPE_NOTE) {
  			const ModelClass = BaseItem.getClassByItemType(queryType);
  			const options = {};
  			const fields = this.fields_(request, []);
  			if (fields.length) options.fields = fields;
  			const sqlQueryPart = query.replace(/\*/g, '%');
  			options.where = 'title LIKE ?';
  			options.whereParams = [sqlQueryPart];
  			options.caseInsensitive = true;
  			return await ModelClass.all(options);
  		} else {
  			return await SearchEngineUtils.notesForQuery(query, this.notePreviewsOptions_(request));
  		}
  	}
  
  	async action_folders(request, id = null, link = null) {
  		if (request.method === 'GET' && !id) {
  			const folders = await FoldersScreenUtils.allForDisplay({ fields: this.fields_(request, ['id', 'parent_id', 'title']) });
  			const output = await Folder.allAsTree(folders);
  			return output;
  		}
  
  		if (request.method === 'GET' && id) {
  			if (link && link === 'notes') {
  				const options = this.notePreviewsOptions_(request);
  				return Note.previews(id, options);
  			} else if (link) {
  				throw new ErrorNotFound();
  			}
  		}
  
  		return this.defaultAction_(BaseModel.TYPE_FOLDER, request, id, link);
  	}
  
  	async action_tags(request, id = null, link = null) {
  		if (link === 'notes') {
  			const tag = await Tag.load(id);
  			if (!tag) throw new ErrorNotFound();
  
  			if (request.method === 'POST') {
  				const note = request.bodyJson();
  				if (!note || !note.id) throw new ErrorBadRequest('Missing note ID');
  				return await Tag.addNote(tag.id, note.id);
  			}
  
  			if (request.method === 'DELETE') {
  				const noteId = request.params.length ? request.params[0] : null;
  				if (!noteId) throw new ErrorBadRequest('Missing note ID');
  				await Tag.removeNote(tag.id, noteId);
  				return;
  			}
  
  			if (request.method === 'GET') {
  				// Ideally we should get all this in one SQL query but for now that will do
  				const noteIds = await Tag.noteIds(tag.id);
  				const output = [];
  				for (let i = 0; i < noteIds.length; i++) {
  					const n = await Note.preview(noteIds[i], this.notePreviewsOptions_(request));
  					if (!n) continue;
  					output.push(n);
  				}
  				return output;
  			}
  		}
  
  		return this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
  	}
  
  	async action_master_keys(request, id = null, link = null) {
  		return this.defaultAction_(BaseModel.TYPE_MASTER_KEY, request, id, link);
  	}
  
  	async action_resources(request, id = null, link = null) {
  		// fieldName: "data"
  		// headers: Object
  		// originalFilename: "test.jpg"
  		// path: "C:\Users\Laurent\AppData\Local\Temp\BW77wkpP23iIGUstd0kDuXXC.jpg"
  		// size: 164394
  
  		if (request.method === 'GET') {
  			if (link === 'file') {
  				const resource = await Resource.load(id);
  				if (!resource) throw new ErrorNotFound();
  
  				const filePath = Resource.fullPath(resource);
  				const buffer = await shim.fsDriver().readFile(filePath, 'Buffer');
  
  				const response = new ApiResponse();
  				response.type = 'attachment';
  				response.body = buffer;
  				response.contentType = resource.mime;
  				response.attachmentFilename = Resource.friendlyFilename(resource);
  				return response;
  			}
  
  			if (link) throw new ErrorNotFound();
  		}
  
  		if (request.method === 'POST') {
  			if (!request.files.length) throw new ErrorBadRequest('Resource cannot be created without a file');
  			const filePath = request.files[0].path;
  			const defaultProps = request.bodyJson(this.readonlyProperties('POST'));
  			return shim.createResourceFromPath(filePath, defaultProps);
  		}
  
  		return this.defaultAction_(BaseModel.TYPE_RESOURCE, request, id, link);
  	}
  
  	notePreviewsOptions_(request) {
  		const fields = this.fields_(request, []); // previews() already returns default fields
  		const options = {};
  		if (fields.length) options.fields = fields;
  		return options;
  	}
  
  	defaultSaveOptions_(model, requestMethod) {
  		const options = { userSideValidation: true };
  		if (requestMethod === 'POST' && model.id) options.isNew = true;
  		return options;
  	}
  
  	defaultLoadOptions_(request) {
  		const options = {};
  		const fields = this.fields_(request, []);
  		if (fields.length) options.fields = fields;
  		return options;
  	}
  
  	async execServiceActionFromRequest_(externalApi, request) {
  		const action = externalApi[request.action];
  		if (!action) throw new ErrorNotFound(`Invalid action: ${request.action}`);
  		const args = Object.assign({}, request);
  		delete args.action;
  		return action(args);
  	}
  
  	async action_services(request, serviceName) {
  		this.checkToken_(request);
  
  		if (request.method !== 'POST') throw new ErrorMethodNotAllowed();
  		if (!this.actionApi_) throw new ErrorNotFound('No action API has been setup!');
  		if (!this.actionApi_[serviceName]) throw new ErrorNotFound(`No such service: ${serviceName}`);
  
  		const externalApi = this.actionApi_[serviceName]();
  		return this.execServiceActionFromRequest_(externalApi, JSON.parse(request.body));
  	}
  
  	async action_notes(request, id = null, link = null) {
  		this.checkToken_(request);
  
  		if (request.method === 'GET') {
  			if (link && link === 'tags') {
  				return Tag.tagsByNoteId(id);
  			} else if (link && link === 'resources') {
  				const note = await Note.load(id);
  				if (!note) throw new ErrorNotFound();
  				const resourceIds = await Note.linkedResourceIds(note.body);
  				const output = [];
  				const loadOptions = this.defaultLoadOptions_(request);
  				for (const resourceId of resourceIds) {
  					output.push(await Resource.load(resourceId, loadOptions));
  				}
  				return output;
  			} else if (link) {
  				throw new ErrorNotFound();
  			}
  
  			const options = this.notePreviewsOptions_(request);
  			if (id) {
  				return await Note.preview(id, options);
  			} else {
  				return await Note.previews(null, options);
  			}
  		}
  
  		if (request.method === 'POST') {
  			const requestId = Date.now();
  			const requestNote = JSON.parse(request.body);
  
  			const allowFileProtocolImages = urlUtils.urlProtocol(requestNote.base_url).toLowerCase() === 'file:';
  
  			const imageSizes = requestNote.image_sizes ? requestNote.image_sizes : {};
  
  			let note = await this.requestNoteToNote_(requestNote);
  
  			const imageUrls = ArrayUtils.unique(markupLanguageUtils.extractImageUrls(note.markup_language, note.body));
  
  			this.logger().info(`Request (${requestId}): Downloading images: ${imageUrls.length}`);
  
  			let result = await this.downloadImages_(imageUrls, allowFileProtocolImages);
  
  			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.markup_language, note.body, result, imageSizes);
  
  			this.logger().info(`Request (${requestId}): Saving note...`);
  
  			const saveOptions = this.defaultSaveOptions_(note, 'POST');
  			saveOptions.autoTimestamp = false; // No auto-timestamp because user may have provided them
  			const timestamp = Date.now();
  			note.updated_time = timestamp;
  			note.created_time = timestamp;
  
  			note = await Note.save(note, saveOptions);
  
  			if (requestNote.tags) {
  				const tagTitles = requestNote.tags.split(',');
  				await Tag.setNoteTagsByTitles(note.id, tagTitles);
  			}
  
  			if (requestNote.image_data_url) {
  				note = await this.attachImageFromDataUrl_(note, requestNote.image_data_url, requestNote.crop_rect);
  			}
  
  			this.logger().info(`Request (${requestId}): Created note ${note.id}`);
  
  			return note;
  		}
  
  		if (request.method === 'PUT') {
  			const note = await Note.load(id);
  
  			if (!note) throw new ErrorNotFound();
  
  			const updatedNote = await this.defaultAction_(BaseModel.TYPE_NOTE, request, id, link);
  
  			const requestNote = JSON.parse(request.body);
  			if (requestNote.tags || requestNote.tags === '') {
  				const tagTitles = requestNote.tags.split(',');
  				await Tag.setNoteTagsByTitles(id, tagTitles);
  			}
  
  			return updatedNote;
  		}
  
  		return this.defaultAction_(BaseModel.TYPE_NOTE, request, id, link);
  	}
  
  	// ========================================================================================================================
  	// UTILIY FUNCTIONS
  	// ========================================================================================================================
  
  	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.id) output.id = requestNote.id;
  
  		const baseUrl = requestNote.base_url ? requestNote.base_url : '';
  
  		if (requestNote.body_html) {
  			if (requestNote.convert_to === 'html') {
  				const style = await this.buildNoteStyleSheet_(requestNote.stylesheets);
  				const minify = require('html-minifier').minify;
  
  				const minifyOptions = {
  					// Remove all spaces and, especially, newlines from tag attributes, as that would
  					// break the rendering.
  					customAttrCollapse: /.*/,
  					// Need to remove all whitespaces because whitespace at a beginning of a line
  					// means a code block in Markdown.
  					collapseWhitespace: true,
  					minifyCSS: true,
  					maxLineLength: 300,
  				};
  
  				const uglifycss = require('uglifycss');
  				const styleString = uglifycss.processString(style.join('\n'), {
  					// Need to set a max length because Ace Editor takes forever
  					// to display notes with long lines.
  					maxLineLen: 200,
  				});
  
  				const styleTag = style.length ? `<style>${styleString}</style>` + '\n' : '';
  				let minifiedHtml = '';
  				try {
  					minifiedHtml = minify(requestNote.body_html, minifyOptions);
  				} catch (error) {
  					console.warn('Could not minify HTML - using non-minified HTML instead', error);
  					minifiedHtml = requestNote.body_html;
  				}
  				output.body = styleTag + minifiedHtml;
  				output.body = htmlUtils.prependBaseUrl(output.body, baseUrl);
  				output.markup_language = MarkupToHtml.MARKUP_LANGUAGE_HTML;
  			} else {
  				// Convert to Markdown
  				// 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: baseUrl,
  					anchorNames: requestNote.anchor_names ? requestNote.anchor_names : [],
  				});
  				output.markup_language = MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN;
  			}
  		}
  
  		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 ('source_url' in requestNote) output.source_url = requestNote.source_url;
  		if ('author' in requestNote) output.author = requestNote.author;
  		if ('user_updated_time' in requestNote) output.user_updated_time = Database.formatValue(Database.TYPE_INT, requestNote.user_updated_time);
  		if ('user_created_time' in requestNote) output.user_created_time = Database.formatValue(Database.TYPE_INT, requestNote.user_created_time);
  		if ('is_todo' in requestNote) output.is_todo = Database.formatValue(Database.TYPE_INT, requestNote.is_todo);
  		if ('markup_language' in requestNote) output.markup_language = Database.formatValue(Database.TYPE_INT, requestNote.markup_language);
  
  		if (!output.markup_language) output.markup_language = MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN;
  
  		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 tryToGuessImageExtFromMimeType_(response, imagePath) {
  		const mimeType = netUtils.mimeTypeFromHeaders(response.headers);
  		if (!mimeType) return imagePath;
  
  		const newExt = mimeUtils.toFileExtension(mimeType);
  		if (!newExt) return imagePath;
  
  		const newImagePath = `${imagePath}.${newExt}`;
  		await shim.fsDriver().move(imagePath, newImagePath);
  		return newImagePath;
  	}
  
  	async buildNoteStyleSheet_(stylesheets) {
  		if (!stylesheets) return [];
  
  		const output = [];
  
  		for (const stylesheet of stylesheets) {
  			if (stylesheet.type === 'text') {
  				output.push(stylesheet.value);
  			} else if (stylesheet.type === 'url') {
  				try {
  					const tempPath = `${Setting.value('tempDir')}/${md5(`${Math.random()}_${Date.now()}`)}.css`;
  					await shim.fetchBlob(stylesheet.value, { path: tempPath, maxRetry: 1 });
  					const text = await shim.fsDriver().readFile(tempPath);
  					output.push(text);
  					await shim.fsDriver().remove(tempPath);
  				} catch (error) {
  					this.logger().warn(`Cannot download stylesheet at ${stylesheet.value}`, error);
  				}
  			} else {
  				throw new Error(`Invalid stylesheet type: ${stylesheet.type}`);
  			}
  		}
  
  		return output;
  	}
  
  	async downloadImage_(url /* , allowFileProtocolImages */) {
  		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 (!mimeUtils.fromFileExtension(fileExt)) fileExt = ''; // If the file extension is unknown - clear it.
  		if (fileExt) fileExt = `.${fileExt}`;
  
  		// Append a UUID because simply checking if the file exists is not enough since
  		// multiple resources can be downloaded at the same time (race condition).
  		let imagePath = `${tempDir}/${safeFilename(name)}_${uuid.create()}${fileExt}`;
  
  		try {
  			if (isDataUrl) {
  				await shim.imageFromDataUrl(url, imagePath);
  			} else if (urlUtils.urlProtocol(url).toLowerCase() === 'file:') {
  				// Can't think of any reason to disallow this at this point
  				// if (!allowFileProtocolImages) throw new Error('For security reasons, this URL with file:// protocol cannot be downloaded');
  				const localPath = uri2path(url);
  				await shim.fsDriver().copy(localPath, imagePath);
  			} else {
  				const response = await shim.fetchBlob(url, { path: imagePath, maxRetry: 1 });
  
  				// If we could not find the file extension from the URL, try to get it
  				// now based on the Content-Type header.
  				if (!fileExt) imagePath = await this.tryToGuessImageExtFromMimeType_(response, imagePath);
  			}
  			return imagePath;
  		} catch (error) {
  			this.logger().warn(`Cannot download image at ${url}`, error);
  			return '';
  		}
  	}
  
  	async downloadImages_(urls, allowFileProtocolImages) {
  		const PromisePool = require('es6-promise-pool');
  
  		const output = {};
  
  		const downloadOne = async url => {
  			const imagePath = await this.downloadImage_(url, allowFileProtocolImages);
  			if (imagePath) output[url] = { path: imagePath, originalUrl: url };
  		};
  
  		let urlIndex = 0;
  		const promiseProducer = () => {
  			if (urlIndex >= urls.length) return null;
  
  			const url = urls[urlIndex++];
  			return downloadOne(url);
  		};
  
  		const concurrency = 10;
  		const pool = new PromisePool(promiseProducer, concurrency);
  		await pool.start();
  
  		return output;
  	}
  
  	async createResourcesFromPaths_(urls) {
  		for (const 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 (const 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_(markupLanguage, md, urls, imageSizes) {
  		const imageSizesIndexes = {};
  
  		if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
  			return htmlUtils.replaceImageUrls(md, imageUrl => {
  				const urlInfo = urls[imageUrl];
  				if (!urlInfo || !urlInfo.resource) return imageUrl;
  				return Resource.internalUrl(urlInfo.resource);
  			});
  		} else {
  			// eslint-disable-next-line no-useless-escape
  			return md.replace(/(!\[.*?\]\()([^\s\)]+)(.*?\))/g, (match, before, imageUrl, after) => {
  				const urlInfo = urls[imageUrl];
  				if (!urlInfo || !urlInfo.resource) return before + imageUrl + after;
  				if (!(urlInfo.originalUrl in imageSizesIndexes)) imageSizesIndexes[urlInfo.originalUrl] = 0;
  
  				const resourceUrl = Resource.internalUrl(urlInfo.resource);
  				const imageSizesCollection = imageSizes[urlInfo.originalUrl];
  
  				if (!imageSizesCollection) {
  					// In some cases, we won't find the image size information for that particular URL. Normally
  					// it will only happen when using the "Clip simplified page" feature, which can modify the
  					// image URLs (for example it will select a smaller size resolution). In that case, it's
  					// fine to return the image as-is because it has already good dimensions.
  					return before + resourceUrl + after;
  				}
  
  				const imageSize = imageSizesCollection[imageSizesIndexes[urlInfo.originalUrl]];
  				imageSizesIndexes[urlInfo.originalUrl]++;
  
  				if (imageSize && (imageSize.naturalWidth !== imageSize.width || imageSize.naturalHeight !== imageSize.height)) {
  					return `<img width="${imageSize.width}" height="${imageSize.height}" src="${resourceUrl}"/>`;
  				} else {
  					return before + resourceUrl + after;
  				}
  			});
  		}
  	}
  }
  
  module.exports = Api;
  