2018-03-09 22:59:12 +02:00
const BaseItem = require ( 'lib/models/BaseItem.js' ) ;
const BaseModel = require ( 'lib/BaseModel.js' ) ;
const Resource = require ( 'lib/models/Resource.js' ) ;
const Folder = require ( 'lib/models/Folder.js' ) ;
const NoteTag = require ( 'lib/models/NoteTag.js' ) ;
const Note = require ( 'lib/models/Note.js' ) ;
const ArrayUtils = require ( 'lib/ArrayUtils' ) ;
const { sprintf } = require ( 'sprintf-js' ) ;
const { shim } = require ( 'lib/shim' ) ;
const { _ } = require ( 'lib/locale' ) ;
const { fileExtension } = require ( 'lib/path-utils' ) ;
const { toTitleCase } = require ( 'lib/string-utils' ) ;
2018-02-25 19:01:16 +02:00
2018-02-26 21:25:54 +02:00
class InteropService {
2018-02-27 22:04:38 +02:00
constructor ( ) {
2019-09-30 00:11:36 +02:00
this . modules _ = null ;
}
2018-02-27 22:04:38 +02:00
modules ( ) {
if ( this . modules _ ) return this . modules _ ;
2019-12-18 12:00:59 +02:00
// - canDoMultiExport: Tells whether the format can package multiple notes into one file. Default: true.
2018-02-27 22:04:38 +02:00
let importModules = [
{
2018-03-09 22:59:12 +02:00
format : 'jex' ,
2018-06-26 01:07:53 +02:00
fileExtensions : [ 'jex' ] ,
2018-03-09 22:59:12 +02:00
sources : [ 'file' ] ,
description : _ ( 'Joplin Export File' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-03-09 22:59:12 +02:00
format : 'md' ,
2019-09-21 00:00:59 +02:00
fileExtensions : [ 'md' , 'markdown' , 'txt' ] ,
2018-03-09 22:59:12 +02:00
sources : [ 'file' , 'directory' ] ,
2018-02-27 22:04:38 +02:00
isNoteArchive : false , // Tells whether the file can contain multiple notes (eg. Enex or Jex format)
2018-03-09 22:59:12 +02:00
description : _ ( 'Markdown' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-03-09 22:59:12 +02:00
format : 'raw' ,
sources : [ 'directory' ] ,
description : _ ( 'Joplin Export Directory' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-03-09 22:59:12 +02:00
format : 'enex' ,
2018-06-26 01:07:53 +02:00
fileExtensions : [ 'enex' ] ,
2018-03-09 22:59:12 +02:00
sources : [ 'file' ] ,
2019-09-23 23:18:30 +02:00
description : _ ( 'Evernote Export File (as Markdown)' ) ,
importerClass : 'InteropService_Importer_EnexToMd' ,
isDefault : true ,
} ,
{
format : 'enex' ,
fileExtensions : [ 'enex' ] ,
sources : [ 'file' ] ,
description : _ ( 'Evernote Export File (as HTML)' ) ,
// TODO: Consider doing this the same way as the multiple `md` importers are handled
importerClass : 'InteropService_Importer_EnexToHtml' ,
2018-02-27 22:04:38 +02:00
} ,
] ;
let exportModules = [
{
2018-03-09 22:59:12 +02:00
format : 'jex' ,
2018-06-26 01:07:53 +02:00
fileExtensions : [ 'jex' ] ,
2018-03-09 22:59:12 +02:00
target : 'file' ,
2019-12-15 20:41:13 +02:00
canDoMultiExport : true ,
2018-03-09 22:59:12 +02:00
description : _ ( 'Joplin Export File' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-03-09 22:59:12 +02:00
format : 'raw' ,
target : 'directory' ,
description : _ ( 'Joplin Export Directory' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-11-11 22:17:43 +02:00
format : 'json' ,
target : 'directory' ,
description : _ ( 'Json Export Directory' ) ,
2019-07-29 15:43:53 +02:00
} ,
{
2018-09-04 12:59:09 +02:00
format : 'md' ,
target : 'directory' ,
description : _ ( 'Markdown' ) ,
2018-02-27 22:04:38 +02:00
} ,
2019-12-15 20:41:13 +02:00
{
format : 'html' ,
target : 'file' ,
canDoMultiExport : false ,
description : _ ( 'HTML File' ) ,
} ,
{
format : 'html' ,
target : 'directory' ,
description : _ ( 'HTML Directory' ) ,
} ,
2018-02-27 22:04:38 +02:00
] ;
2019-07-29 15:43:53 +02:00
importModules = importModules . map ( a => {
2019-09-19 23:51:18 +02:00
const className = a . importerClass || ` InteropService_Importer_ ${ toTitleCase ( a . format ) } ` ;
2019-07-29 15:43:53 +02:00
const output = Object . assign (
{ } ,
{
type : 'importer' ,
2019-09-19 23:51:18 +02:00
path : ` lib/services/ ${ className } ` ,
2019-07-29 15:43:53 +02:00
} ,
a
) ;
2018-03-09 22:59:12 +02:00
if ( ! ( 'isNoteArchive' in output ) ) output . isNoteArchive = true ;
2018-02-27 22:04:38 +02:00
return output ;
} ) ;
2019-07-29 15:43:53 +02:00
exportModules = exportModules . map ( a => {
2019-09-19 23:51:18 +02:00
const className = ` InteropService_Exporter_ ${ toTitleCase ( a . format ) } ` ;
2019-07-29 15:43:53 +02:00
return Object . assign (
{ } ,
{
type : 'exporter' ,
2019-09-19 23:51:18 +02:00
path : ` lib/services/ ${ className } ` ,
2019-07-29 15:43:53 +02:00
} ,
a
) ;
2018-02-27 22:04:38 +02:00
} ) ;
this . modules _ = importModules . concat ( exportModules ) ;
2018-03-12 20:01:47 +02:00
2019-07-29 15:43:53 +02:00
this . modules _ = this . modules _ . map ( a => {
2018-03-12 20:01:47 +02:00
a . fullLabel = function ( moduleSource = null ) {
2019-09-19 23:51:18 +02:00
const label = [ ` ${ this . format . toUpperCase ( ) } - ${ this . description } ` ] ;
2018-03-12 20:01:47 +02:00
if ( moduleSource && this . sources . length > 1 ) {
2019-09-19 23:51:18 +02:00
label . push ( ` ( ${ moduleSource === 'file' ? _ ( 'File' ) : _ ( 'Directory' ) } ) ` ) ;
2018-03-12 20:01:47 +02:00
}
return label . join ( ' ' ) ;
} ;
return a ;
} ) ;
2018-02-27 22:04:38 +02:00
return this . modules _ ;
}
2019-09-23 23:18:30 +02:00
// Find the module that matches the given type ("importer" or "exporter")
// and the given format. Some formats can have multiple assocated importers
// 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.
2019-12-15 20:41:13 +02:00
findModuleByFormat _ ( type , format , target = null ) {
2018-02-27 22:04:38 +02:00
const modules = this . modules ( ) ;
2019-09-23 23:18:30 +02:00
const matches = [ ] ;
2018-02-27 22:04:38 +02:00
for ( let i = 0 ; i < modules . length ; i ++ ) {
const m = modules [ i ] ;
2019-12-15 20:41:13 +02:00
if ( m . format === format && m . type === type ) {
if ( target === null ) {
matches . push ( m ) ;
} else if ( target === m . target ) {
matches . push ( m ) ;
}
}
2018-02-26 21:16:01 +02:00
}
2019-09-23 23:18:30 +02:00
const output = matches . find ( m => ! ! m . isDefault ) ;
if ( output ) return output ;
return matches . length ? matches [ 0 ] : null ;
2018-02-25 19:01:16 +02:00
}
2019-09-23 23:18:30 +02:00
/ * *
* NOTE TO FUTURE SELF : It might make sense to simply move all the existing
* formatters to the ` newModuleFromPath_ ` approach , so that there ' s only one way
* to do this mapping . This isn ' t a priority right now ( per the convo in :
* https : //github.com/laurent22/joplin/pull/1795#discussion_r322379121) but
* we can do it if it ever becomes necessary .
* /
newModuleByFormat _ ( type , format ) {
const moduleMetadata = this . findModuleByFormat _ ( type , format ) ;
if ( ! moduleMetadata ) throw new Error ( _ ( 'Cannot load "%s" module for format "%s"' , type , format ) ) ;
const ModuleClass = require ( moduleMetadata . path ) ;
2018-06-26 01:07:53 +02:00
const output = new ModuleClass ( ) ;
2019-09-23 23:18:30 +02:00
output . setMetadata ( moduleMetadata ) ;
return output ;
}
/ * *
* The existing ` newModuleByFormat_ ` fn would load by the input format . This
* was fine when there was a 1 - 1 mapping of input formats to output formats ,
* but now that we have 2 possible outputs for an ` enex ` input , we need to be
* explicit with which importer we want to use .
*
* https : //github.com/laurent22/joplin/pull/1795#pullrequestreview-281574417
* /
newModuleFromPath _ ( type , options ) {
2019-12-17 02:40:25 +02:00
let modulePath = options && options . modulePath ? options . modulePath : '' ;
if ( ! modulePath ) {
const moduleMetadata = this . findModuleByFormat _ ( type , options . format , options . target ) ;
modulePath = moduleMetadata . path ;
2019-09-23 23:18:30 +02:00
}
2019-12-17 02:40:25 +02:00
const ModuleClass = require ( modulePath ) ;
2019-09-23 23:18:30 +02:00
const output = new ModuleClass ( ) ;
2019-12-15 20:41:13 +02:00
const moduleMetadata = this . findModuleByFormat _ ( type , options . format , options . target ) ;
2019-09-23 23:18:30 +02:00
output . setMetadata ( { options , ... moduleMetadata } ) ; // TODO: Check that this metadata is equivalent to module above
2018-06-26 01:07:53 +02:00
return output ;
2018-02-26 20:43:50 +02:00
}
2018-02-27 22:04:38 +02:00
moduleByFileExtension _ ( type , ext ) {
ext = ext . toLowerCase ( ) ;
const modules = this . modules ( ) ;
for ( let i = 0 ; i < modules . length ; i ++ ) {
const m = modules [ i ] ;
if ( type !== m . type ) continue ;
2019-07-13 18:26:47 +02:00
if ( m . fileExtensions && m . fileExtensions . indexOf ( ext ) >= 0 ) return m ;
2018-02-27 22:04:38 +02:00
}
return null ;
2018-02-26 21:16:01 +02:00
}
2018-02-23 21:32:19 +02:00
async import ( options ) {
2019-07-29 15:43:53 +02:00
if ( ! ( await shim . fsDriver ( ) . exists ( options . path ) ) ) throw new Error ( _ ( 'Cannot find "%s".' , options . path ) ) ;
2018-02-26 21:16:01 +02:00
2019-07-29 15:43:53 +02:00
options = Object . assign (
{ } ,
{
format : 'auto' ,
destinationFolderId : null ,
destinationFolder : null ,
} ,
options
) ;
2018-02-25 19:01:16 +02:00
2018-03-09 22:59:12 +02:00
if ( options . format === 'auto' ) {
const module = this . moduleByFileExtension _ ( 'importer' , fileExtension ( options . path ) ) ;
if ( ! module ) throw new Error ( _ ( 'Please specify import format for %s' , options . path ) ) ;
2019-07-30 09:35:42 +02:00
// eslint-disable-next-line require-atomic-updates
2018-02-27 22:04:38 +02:00
options . format = module . format ;
2018-02-25 19:01:16 +02:00
}
if ( options . destinationFolderId ) {
const folder = await Folder . load ( options . destinationFolderId ) ;
2018-02-27 22:04:38 +02:00
if ( ! folder ) throw new Error ( _ ( 'Cannot find "%s".' , options . destinationFolderId ) ) ;
2019-07-30 09:35:42 +02:00
// eslint-disable-next-line require-atomic-updates
2018-02-26 20:43:50 +02:00
options . destinationFolder = folder ;
2018-02-25 19:01:16 +02:00
}
2019-07-29 15:43:53 +02:00
let result = { warnings : [ ] } ;
2018-02-25 19:01:16 +02:00
2019-09-23 23:18:30 +02:00
// console.log('options passed to InteropService:');
// console.log(JSON.stringify({options}, null, 2));
let importer = null ;
if ( options . modulePath ) {
importer = this . newModuleFromPath _ ( 'importer' , options ) ;
} else {
importer = this . newModuleByFormat _ ( 'importer' , options . format ) ;
}
2018-02-26 20:43:50 +02:00
await importer . init ( options . path , options ) ;
result = await importer . exec ( result ) ;
2018-02-25 19:01:16 +02:00
return result ;
2018-02-23 21:32:19 +02:00
}
async export ( options ) {
2019-12-15 20:41:13 +02:00
options = Object . assign ( { } , options ) ;
if ( ! options . format ) options . format = 'jex' ;
2018-02-23 21:32:19 +02:00
const exportPath = options . path ? options . path : null ;
2018-06-10 20:15:40 +02:00
let sourceFolderIds = options . sourceFolderIds ? options . sourceFolderIds : [ ] ;
2018-02-23 21:32:19 +02:00
const sourceNoteIds = options . sourceNoteIds ? options . sourceNoteIds : [ ] ;
2019-07-29 15:43:53 +02:00
const result = { warnings : [ ] } ;
2018-02-23 21:32:19 +02:00
const itemsToExport = [ ] ;
const queueExportItem = ( itemType , itemOrId ) => {
itemsToExport . push ( {
type : itemType ,
2019-07-29 15:43:53 +02:00
itemOrId : itemOrId ,
2018-02-23 21:32:19 +02:00
} ) ;
2019-07-29 15:43:53 +02:00
} ;
2018-02-23 21:32:19 +02:00
let exportedNoteIds = [ ] ;
let resourceIds = [ ] ;
const folderIds = await Folder . allIds ( ) ;
2018-06-10 20:15:40 +02:00
let fullSourceFolderIds = sourceFolderIds . slice ( ) ;
for ( let i = 0 ; i < sourceFolderIds . length ; i ++ ) {
const id = sourceFolderIds [ i ] ;
const childrenIds = await Folder . childrenIds ( id ) ;
fullSourceFolderIds = fullSourceFolderIds . concat ( childrenIds ) ;
}
sourceFolderIds = fullSourceFolderIds ;
2018-02-23 21:32:19 +02:00
for ( let folderIndex = 0 ; folderIndex < folderIds . length ; folderIndex ++ ) {
const folderId = folderIds [ folderIndex ] ;
if ( sourceFolderIds . length && sourceFolderIds . indexOf ( folderId ) < 0 ) continue ;
if ( ! sourceNoteIds . length ) await queueExportItem ( BaseModel . TYPE _FOLDER , folderId ) ;
const noteIds = await Folder . noteIds ( folderId ) ;
for ( let noteIndex = 0 ; noteIndex < noteIds . length ; noteIndex ++ ) {
const noteId = noteIds [ noteIndex ] ;
if ( sourceNoteIds . length && sourceNoteIds . indexOf ( noteId ) < 0 ) continue ;
const note = await Note . load ( noteId ) ;
await queueExportItem ( BaseModel . TYPE _NOTE , note ) ;
exportedNoteIds . push ( noteId ) ;
2018-05-03 14:11:45 +02:00
const rids = await Note . linkedResourceIds ( note . body ) ;
2018-02-23 21:32:19 +02:00
resourceIds = resourceIds . concat ( rids ) ;
}
}
2018-02-26 21:25:54 +02:00
resourceIds = ArrayUtils . unique ( resourceIds ) ;
2018-02-23 21:32:19 +02:00
for ( let i = 0 ; i < resourceIds . length ; i ++ ) {
await queueExportItem ( BaseModel . TYPE _RESOURCE , resourceIds [ i ] ) ;
}
const noteTags = await NoteTag . all ( ) ;
let exportedTagIds = [ ] ;
for ( let i = 0 ; i < noteTags . length ; i ++ ) {
const noteTag = noteTags [ i ] ;
if ( exportedNoteIds . indexOf ( noteTag . note _id ) < 0 ) continue ;
await queueExportItem ( BaseModel . TYPE _NOTE _TAG , noteTag . id ) ;
exportedTagIds . push ( noteTag . tag _id ) ;
}
for ( let i = 0 ; i < exportedTagIds . length ; i ++ ) {
await queueExportItem ( BaseModel . TYPE _TAG , exportedTagIds [ i ] ) ;
}
2019-12-15 20:41:13 +02:00
const exporter = this . newModuleFromPath _ ( 'exporter' , options ) ; // this.newModuleByFormat_('exporter', exportFormat);
2018-02-23 21:32:19 +02:00
await exporter . init ( exportPath ) ;
2018-11-21 02:36:23 +02:00
const typeOrder = [ BaseModel . TYPE _FOLDER , BaseModel . TYPE _RESOURCE , BaseModel . TYPE _NOTE , BaseModel . TYPE _TAG , BaseModel . TYPE _NOTE _TAG ] ;
const context = {
resourcePaths : { } ,
} ;
for ( let typeOrderIndex = 0 ; typeOrderIndex < typeOrder . length ; typeOrderIndex ++ ) {
const type = typeOrder [ typeOrderIndex ] ;
for ( let i = 0 ; i < itemsToExport . length ; i ++ ) {
const itemType = itemsToExport [ i ] . type ;
if ( itemType !== type ) continue ;
const ItemClass = BaseItem . getClassByItemType ( itemType ) ;
const itemOrId = itemsToExport [ i ] . itemOrId ;
const item = typeof itemOrId === 'object' ? itemOrId : await ItemClass . load ( itemOrId ) ;
if ( ! item ) {
if ( itemType === BaseModel . TYPE _RESOURCE ) {
result . warnings . push ( sprintf ( 'A resource that does not exist is referenced in a note. The resource was skipped. Resource ID: %s' , itemOrId ) ) ;
} else {
result . warnings . push ( sprintf ( 'Cannot find item with type "%s" and ID %s. Item was skipped.' , ItemClass . tableName ( ) , JSON . stringify ( itemOrId ) ) ) ;
}
continue ;
2018-02-23 21:32:19 +02:00
}
2018-11-21 02:36:23 +02:00
if ( item . encryption _applied || item . encryption _blob _encrypted ) throw new Error ( _ ( 'This item is currently encrypted: %s "%s". Please wait for all items to be decrypted and try again.' , BaseModel . modelTypeToName ( itemType ) , item . title ? item . title : item . id ) ) ;
2018-02-27 22:51:07 +02:00
2018-11-21 02:36:23 +02:00
try {
if ( itemType == BaseModel . TYPE _RESOURCE ) {
const resourcePath = Resource . fullPath ( item ) ;
context . resourcePaths [ item . id ] = resourcePath ;
exporter . updateContext ( context ) ;
await exporter . processResource ( item , resourcePath ) ;
}
2018-02-23 21:32:19 +02:00
2018-11-21 02:36:23 +02:00
await exporter . processItem ( ItemClass , item ) ;
} catch ( error ) {
2019-12-15 20:41:13 +02:00
console . error ( error ) ;
2018-11-21 02:36:23 +02:00
result . warnings . push ( error . message ) ;
}
2018-02-23 21:32:19 +02:00
}
}
await exporter . close ( ) ;
return result ;
}
}
2019-07-29 15:43:53 +02:00
module . exports = InteropService ;