You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop, Cli: Allow exporting a note as HTML
This commit is contained in:
		| @@ -32,6 +32,8 @@ class InteropServiceHelper { | ||||
| 		const exportOptions = {}; | ||||
| 		exportOptions.path = path; | ||||
| 		exportOptions.format = module.format; | ||||
| 		exportOptions.modulePath = module.path; | ||||
| 		exportOptions.target = module.target; | ||||
| 		if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds; | ||||
| 		if (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds; | ||||
|  | ||||
|   | ||||
| @@ -128,6 +128,7 @@ class NoteListUtils { | ||||
| 			for (let i = 0; i < ioModules.length; i++) { | ||||
| 				const module = ioModules[i]; | ||||
| 				if (module.type !== 'exporter') continue; | ||||
| 				if (noteIds.length > 1 && module.canDoMultiExport === false) continue; | ||||
|  | ||||
| 				exportMenu.append( | ||||
| 					new MenuItem({ | ||||
|   | ||||
							
								
								
									
										26
									
								
								ElectronClient/app/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								ElectronClient/app/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -596,14 +596,12 @@ | ||||
|             "balanced-match": { | ||||
|               "version": "1.0.0", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "brace-expansion": { | ||||
|               "version": "1.1.11", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true, | ||||
|               "requires": { | ||||
|                 "balanced-match": "^1.0.0", | ||||
|                 "concat-map": "0.0.1" | ||||
| @@ -618,14 +616,12 @@ | ||||
|             "code-point-at": { | ||||
|               "version": "1.1.0", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "concat-map": { | ||||
|               "version": "0.0.1", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "console-control-strings": { | ||||
|               "version": "1.1.0", | ||||
| @@ -747,8 +743,7 @@ | ||||
|             "inherits": { | ||||
|               "version": "2.0.3", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "ini": { | ||||
|               "version": "1.3.5", | ||||
| @@ -760,7 +755,6 @@ | ||||
|               "version": "1.0.0", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true, | ||||
|               "requires": { | ||||
|                 "number-is-nan": "^1.0.0" | ||||
|               } | ||||
| @@ -775,7 +769,6 @@ | ||||
|               "version": "3.0.4", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true, | ||||
|               "requires": { | ||||
|                 "brace-expansion": "^1.1.7" | ||||
|               } | ||||
| @@ -783,8 +776,7 @@ | ||||
|             "minimist": { | ||||
|               "version": "0.0.8", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "minipass": { | ||||
|               "version": "2.3.5", | ||||
| @@ -888,8 +880,7 @@ | ||||
|             "number-is-nan": { | ||||
|               "version": "1.0.1", | ||||
|               "bundled": true, | ||||
|               "dev": true, | ||||
|               "optional": true | ||||
|               "dev": true | ||||
|             }, | ||||
|             "object-assign": { | ||||
|               "version": "4.1.1", | ||||
| @@ -2341,6 +2332,11 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "dataurl": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/dataurl/-/dataurl-0.1.0.tgz", | ||||
|       "integrity": "sha1-H0c0/t3sBf/kRXR5eNhnWcSzMZk=" | ||||
|     }, | ||||
|     "debug": { | ||||
|       "version": "2.6.9", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", | ||||
|   | ||||
| @@ -90,6 +90,7 @@ | ||||
|     "chokidar": "^3.0.0", | ||||
|     "clean-html": "^1.5.0", | ||||
|     "compare-versions": "^3.2.1", | ||||
|     "dataurl": "^0.1.0", | ||||
|     "diacritics": "^1.3.0", | ||||
|     "diff-match-patch": "^1.0.4", | ||||
|     "electron-context-menu": "^0.15.0", | ||||
|   | ||||
| @@ -175,4 +175,4 @@ a { | ||||
| @keyframes rotate { | ||||
| 	from {transform: rotate(0deg);} | ||||
| 	to {transform: rotate(360deg);} | ||||
| } | ||||
| } | ||||
| @@ -10,7 +10,7 @@ function installRule(markdownIt, mdOptions, ruleOptions) { | ||||
| 		const src = utils.getAttr(token.attrs, 'src'); | ||||
| 		const title = utils.getAttr(token.attrs, 'title'); | ||||
|  | ||||
| 		if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self); | ||||
| 		if (!Resource.isResourceUrl(src) || ruleOptions.plainResourceRendering) return defaultRender(tokens, idx, options, env, self); | ||||
|  | ||||
| 		const r = utils.imageReplacement(src, ruleOptions.resources, ruleOptions.resourceBaseUrl); | ||||
| 		if (typeof r === 'string') return r; | ||||
|   | ||||
| @@ -27,7 +27,7 @@ function installRule(markdownIt, mdOptions, ruleOptions) { | ||||
| 				mime = result.item.mime; | ||||
| 			} | ||||
|  | ||||
| 			if (result && resourceStatus !== 'ready') { | ||||
| 			if (result && resourceStatus !== 'ready' && !ruleOptions.plainResourceRendering) { | ||||
| 				const icon = utils.resourceStatusFile(resourceStatus); | ||||
| 				return `<a class="not-loaded-resource resource-status-${resourceStatus}" data-resource-id="${resourceId}">` + `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>`; | ||||
| 			} else { | ||||
| @@ -57,7 +57,12 @@ function installRule(markdownIt, mdOptions, ruleOptions) { | ||||
|  | ||||
| 		let js = `${ruleOptions.postMessageSyntax}(${JSON.stringify(href)}); return false;`; | ||||
| 		if (hrefAttr.indexOf('#') === 0 && href.indexOf('#') === 0) js = ''; // If it's an internal anchor, don't add any JS since the webview is going to handle navigating to the right place | ||||
| 		return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${hrefAttr}' onclick='${js}' type='${htmlentities(mime)}'>${icon}`; | ||||
|  | ||||
| 		if (ruleOptions.plainResourceRendering) { | ||||
| 			return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${hrefAttr}' type='${htmlentities(mime)}'>`; | ||||
| 		} else { | ||||
| 			return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${hrefAttr}' onclick='${js}' type='${htmlentities(mime)}'>${icon}`; | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -254,6 +254,16 @@ module.exports = function(style, options) { | ||||
| 			opacity: 0.5; | ||||
| 		} | ||||
|  | ||||
| 		.exported-note-title { | ||||
| 			font-size: 2.2em; | ||||
| 			font-weight: bold; | ||||
| 			margin-bottom: 1em; | ||||
| 		} | ||||
|  | ||||
| 		.exported-note { | ||||
| 			padding: 1em; | ||||
| 		} | ||||
|  | ||||
| 		@media print { | ||||
| 			body { | ||||
| 				height: auto !important; | ||||
|   | ||||
| @@ -61,6 +61,7 @@ class InteropService { | ||||
| 				format: 'jex', | ||||
| 				fileExtensions: ['jex'], | ||||
| 				target: 'file', | ||||
| 				canDoMultiExport: true, | ||||
| 				description: _('Joplin Export File'), | ||||
| 			}, | ||||
| 			{ | ||||
| @@ -78,6 +79,17 @@ class InteropService { | ||||
| 				target: 'directory', | ||||
| 				description: _('Markdown'), | ||||
| 			}, | ||||
| 			{ | ||||
| 				format: 'html', | ||||
| 				target: 'file', | ||||
| 				canDoMultiExport: false, | ||||
| 				description: _('HTML File'), | ||||
| 			}, | ||||
| 			{ | ||||
| 				format: 'html', | ||||
| 				target: 'directory', | ||||
| 				description: _('HTML Directory'), | ||||
| 			}, | ||||
| 		]; | ||||
|  | ||||
| 		importModules = importModules.map(a => { | ||||
| @@ -127,12 +139,18 @@ class InteropService { | ||||
| 	// or exporters, such as ENEX. In this case, the one marked as "isDefault" | ||||
| 	// is returned. This is useful to auto-detect the module based on the format. | ||||
| 	// For more precise matching, newModuleFromPath_ should be used. | ||||
| 	findModuleByFormat_(type, format) { | ||||
| 	findModuleByFormat_(type, format, target = null) { | ||||
| 		const modules = this.modules(); | ||||
| 		const matches = []; | ||||
| 		for (let i = 0; i < modules.length; i++) { | ||||
| 			const m = modules[i]; | ||||
| 			if (m.format === format && m.type === type) matches.push(modules[i]); | ||||
| 			if (m.format === format && m.type === type) { | ||||
| 				if (target === null) { | ||||
| 					matches.push(m); | ||||
| 				} else if (target === m.target) { | ||||
| 					matches.push(m); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const output = matches.find(m => !!m.isDefault); | ||||
| @@ -171,7 +189,7 @@ class InteropService { | ||||
| 		} | ||||
| 		const ModuleClass = require(options.modulePath); | ||||
| 		const output = new ModuleClass(); | ||||
| 		const moduleMetadata = this.findModuleByFormat_(type, options.format); | ||||
| 		const moduleMetadata = this.findModuleByFormat_(type, options.format, options.target); | ||||
| 		output.setMetadata({options, ...moduleMetadata}); // TODO: Check that this metadata is equivalent to module above | ||||
| 		return output; | ||||
| 	} | ||||
| @@ -237,10 +255,12 @@ class InteropService { | ||||
| 	} | ||||
|  | ||||
| 	async export(options) { | ||||
| 		options = Object.assign({}, options); | ||||
| 		if (!options.format) options.format = 'jex'; | ||||
|  | ||||
| 		const exportPath = options.path ? options.path : null; | ||||
| 		let sourceFolderIds = options.sourceFolderIds ? options.sourceFolderIds : []; | ||||
| 		const sourceNoteIds = options.sourceNoteIds ? options.sourceNoteIds : []; | ||||
| 		const exportFormat = options.format ? options.format : 'jex'; | ||||
| 		const result = { warnings: [] }; | ||||
| 		const itemsToExport = []; | ||||
|  | ||||
| @@ -304,7 +324,7 @@ class InteropService { | ||||
| 			await queueExportItem(BaseModel.TYPE_TAG, exportedTagIds[i]); | ||||
| 		} | ||||
|  | ||||
| 		const exporter = this.newModuleByFormat_('exporter', exportFormat); | ||||
| 		const exporter = this.newModuleFromPath_('exporter', options);// this.newModuleByFormat_('exporter', exportFormat); | ||||
| 		await exporter.init(exportPath); | ||||
|  | ||||
| 		const typeOrder = [BaseModel.TYPE_FOLDER, BaseModel.TYPE_RESOURCE, BaseModel.TYPE_NOTE, BaseModel.TYPE_TAG, BaseModel.TYPE_NOTE_TAG]; | ||||
| @@ -345,6 +365,7 @@ class InteropService { | ||||
|  | ||||
| 					await exporter.processItem(ItemClass, item); | ||||
| 				} catch (error) { | ||||
| 					console.error(error); | ||||
| 					result.warnings.push(error.message); | ||||
| 				} | ||||
| 			} | ||||
|   | ||||
							
								
								
									
										125
									
								
								ReactNativeClient/lib/services/InteropService_Exporter_Html.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								ReactNativeClient/lib/services/InteropService_Exporter_Html.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| const InteropService_Exporter_Base = require('lib/services/InteropService_Exporter_Base'); | ||||
| const { basename, friendlySafeFilename, rtrimSlashes } = require('lib/path-utils.js'); | ||||
| const BaseModel = require('lib/BaseModel'); | ||||
| const Folder = require('lib/models/Folder'); | ||||
| const Note = require('lib/models/Note'); | ||||
| const Setting = require('lib/models/Setting'); | ||||
| const Resource = require('lib/models/Resource'); | ||||
| const { shim } = require('lib/shim'); | ||||
| const MdToHtml = require('lib/renderers/MdToHtml.js'); | ||||
| const dataurl	 = require('dataurl'); | ||||
| const { themeStyle } = require('../../theme.js'); | ||||
| const { dirname } = require('lib/path-utils.js'); | ||||
|  | ||||
| class InteropService_Exporter_Html extends InteropService_Exporter_Base { | ||||
|  | ||||
| 	async init(path) { | ||||
| 		if (this.metadata().target === 'file') { | ||||
| 			this.destDir_ = dirname(path); | ||||
| 			this.filePath_ = path; | ||||
| 		} else { | ||||
| 			this.destDir_ = path; | ||||
| 			this.filePath_ = null; | ||||
| 		} | ||||
|  | ||||
| 		this.createdDirs_ = []; | ||||
| 		this.resourceDir_ = this.destDir_ ? `${this.destDir_}/_resources` : null; | ||||
|  | ||||
| 		await shim.fsDriver().mkdir(this.destDir_); | ||||
| 		this.mdToHtml_ = new MdToHtml(); | ||||
| 		this.resources_ = []; | ||||
| 		this.style_ = themeStyle(Setting.THEME_LIGHT); | ||||
| 	} | ||||
|  | ||||
| 	async makeDirPath_(item, pathPart = null) { | ||||
| 		let output = ''; | ||||
| 		while (true) { | ||||
| 			if (item.type_ === BaseModel.TYPE_FOLDER) { | ||||
| 				if (pathPart) { | ||||
| 					output = `${pathPart}/${output}`; | ||||
| 				} else { | ||||
| 					output = `${friendlySafeFilename(item.title, null, true)}/${output}`; | ||||
| 					output = await shim.fsDriver().findUniqueFilename(output); | ||||
| 				} | ||||
| 			} | ||||
| 			if (!item.parent_id) return output; | ||||
| 			item = await Folder.load(item.parent_id); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async processNoteResources_(item) { | ||||
| 		const target = this.metadata().target; | ||||
| 		const linkedResourceIds = await Note.linkedResourceIds(item.body); | ||||
| 		const relativePath = target === 'directory' ? rtrimSlashes(await this.makeDirPath_(item, '..')) : ''; | ||||
| 		const resourcePaths = this.context() && this.context().resourcePaths ? this.context().resourcePaths : {}; | ||||
|  | ||||
| 		let newBody = item.body; | ||||
|  | ||||
| 		for (let i = 0; i < linkedResourceIds.length; i++) { | ||||
| 			const id = linkedResourceIds[i]; | ||||
| 			const resource = await Resource.load(id); | ||||
| 			let resourceContent = ''; | ||||
| 			const isImage = Resource.isSupportedImageMimeType(resource.mime); | ||||
|  | ||||
| 			if (!isImage) { | ||||
| 				resourceContent = `${relativePath ? `${relativePath}/` : ''}_resources/${basename(resourcePaths[id])}`; | ||||
| 			} else { | ||||
| 				const buffer = await shim.fsDriver().readFile(resourcePaths[id], 'Buffer'); | ||||
|  | ||||
| 				resourceContent = dataurl.convert({ | ||||
| 					data: buffer, | ||||
| 					mimetype: resource.mime, | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			newBody = newBody.replace(new RegExp(`:/${id}`, 'g'), resourceContent); | ||||
| 		} | ||||
|  | ||||
| 		return newBody; | ||||
| 	} | ||||
|  | ||||
| 	async processItem(ItemClass, item) { | ||||
| 		if ([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER].indexOf(item.type_) < 0) return; | ||||
|  | ||||
| 		let dirPath = ''; | ||||
| 		if (!this.filePath_) { | ||||
| 			dirPath = `${this.destDir_}/${await this.makeDirPath_(item)}`; | ||||
|  | ||||
| 			if (this.createdDirs_.indexOf(dirPath) < 0) { | ||||
| 				await shim.fsDriver().mkdir(dirPath); | ||||
| 				this.createdDirs_.push(dirPath); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (item.type_ === BaseModel.TYPE_NOTE) { | ||||
| 			let noteFilePath = ''; | ||||
|  | ||||
| 			if (this.filePath_) { | ||||
| 				noteFilePath = this.filePath_; | ||||
| 			} else { | ||||
| 				noteFilePath = `${dirPath}/${friendlySafeFilename(item.title, null, true)}.html`; | ||||
| 				noteFilePath = await shim.fsDriver().findUniqueFilename(noteFilePath); | ||||
| 			} | ||||
|  | ||||
| 			const bodyMd = await this.processNoteResources_(item); | ||||
| 			const result = this.mdToHtml_.render(bodyMd, this.style_, { resources: this.resources_, plainResourceRendering: true }); | ||||
| 			const noteContent = []; | ||||
| 			if (item.title) noteContent.push(`<div class="exported-note-title">${item.title}</div>`); | ||||
| 			if (result.html) noteContent.push(result.html); | ||||
|  | ||||
| 			await shim.fsDriver().writeFile(noteFilePath, `<div class="exported-note">${noteContent.join('\n\n')}</div>`, 'utf-8'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async processResource(resource, filePath) { | ||||
| 		if (!Resource.isSupportedImageMimeType(resource.mime)) { | ||||
| 			const destResourcePath = `${this.resourceDir_}/${basename(filePath)}`; | ||||
| 			await shim.fsDriver().copy(filePath, destResourcePath); | ||||
| 			this.resources_.push(resource); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async close() {} | ||||
| } | ||||
|  | ||||
| module.exports = InteropService_Exporter_Html; | ||||
		Reference in New Issue
	
	Block a user