diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index adfecb172..337fa43b4 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,6 +2,10 @@ ## [v1.15.1] +### Enhancements + +- Added possibility to import notes from Google Keep [#2015](https://github.com/Automattic/simplenote-electron/pull/2015) + ### Other Changes - Fix application signing to generate proper appx for Windows Store [#1960](https://github.com/Automattic/simplenote-electron/pull/1960) diff --git a/lib/dialogs/import/source-importer/executor/index.tsx b/lib/dialogs/import/source-importer/executor/index.tsx index 3b692b812..de5ebc2e2 100644 --- a/lib/dialogs/import/source-importer/executor/index.tsx +++ b/lib/dialogs/import/source-importer/executor/index.tsx @@ -9,11 +9,13 @@ import TransitionFadeInOut from '../../../../components/transition-fade-in-out'; import ImportProgress from '../progress'; import EvernoteImporter from '../../../../utils/import/evernote'; +import GoogleKeepImporter from '../../../../utils/import/googlekeep'; import SimplenoteImporter from '../../../../utils/import/simplenote'; import TextFileImporter from '../../../../utils/import/text-files'; const importers = { evernote: EvernoteImporter, + googlekeep: GoogleKeepImporter, plaintext: TextFileImporter, simplenote: SimplenoteImporter, }; diff --git a/lib/dialogs/import/sources.ts b/lib/dialogs/import/sources.ts index 270b0bb57..973cc2c4a 100644 --- a/lib/dialogs/import/sources.ts +++ b/lib/dialogs/import/sources.ts @@ -10,6 +10,16 @@ const sources = [ instructions: 'Choose an Evernote export file (.enex)', optionsHint: 'Images and other media will not be imported.', }, + { + name: 'Google Keep', + slug: 'googlekeep', + acceptedTypes: '.zip,.json', + electronOnly: true, + instructions: + 'Choose an archive file exported from Google Takeout (.zip) or individual notes (.json)', + optionsHint: 'Images and other media will not be imported.', + multiple: true, + }, { name: 'Simplenote', slug: 'simplenote', diff --git a/lib/utils/import/googlekeep/index.ts b/lib/utils/import/googlekeep/index.ts new file mode 100644 index 000000000..434740f5c --- /dev/null +++ b/lib/utils/import/googlekeep/index.ts @@ -0,0 +1,115 @@ +import { EventEmitter } from 'events'; +import { endsWith, isEmpty, get, has } from 'lodash'; +import JSZip from 'jszip'; + +import CoreImporter from '../'; + +let fs = null; +const isElectron = has(window, 'process.type'); +if (isElectron) { + fs = __non_webpack_require__('fs'); // eslint-disable-line no-undef +} + +class GoogleKeepImporter extends EventEmitter { + constructor({ noteBucket, tagBucket, options }) { + super(); + this.noteBucket = noteBucket; + this.tagBucket = tagBucket; + this.options = options; + } + + importNotes = filesArray => { + if (isEmpty(filesArray)) { + this.emit('status', 'error', 'No file to import.'); + return; + } + + const coreImporter = new CoreImporter({ + noteBucket: this.noteBucket, + tagBucket: this.tagBucket, + }); + + let importedNoteCount = 0; + + const importJsonString = jsonString => { + // note: If importing the note fails, it is silently ignored by the + // promise below. This is okay since the warning message would be hidden + // by the next progress update anyway. + const importedNote = JSON.parse(jsonString); + + if ( + !importedNote.title && + !importedNote.textContent && + !importedNote.listContent + ) { + // empty note, skip + return; + } + + const title = importedNote.title; + + const importedContent = importedNote.listContent + ? importedNote.listContent // Note has checkboxes, no text content + .map(item => `- [${item.isChecked ? 'x' : ' '}] ${item.text}`) + .join('\n') + : importedNote.textContent; + + const textContent = title + ? `${title}\n\n${importedContent}` + : importedContent; + + return coreImporter + .importNote( + { + content: textContent, + // Keep doesn't store the creation date... + creationDate: importedNote.userEditedTimestampUsec / 1e6, + modificationDate: importedNote.userEditedTimestampUsec / 1e6, + pinned: importedNote.isPinned, + tags: get(importedNote, 'labels', []).map(item => item.name), + }, + { ...this.options, isTrashed: importedNote.isTrashed } + ) + .then(() => { + importedNoteCount++; + this.emit('status', 'progress', importedNoteCount); + }); + }; + + const importZipFile = fileData => + JSZip.loadAsync(fileData).then(zip => { + const promises = zip + .file(/.*\/Keep\/.*\.json/) + .map(zipObj => zipObj.async('string').then(importJsonString)); + return Promise.all(promises); + }); + + const promises = filesArray.map(file => + fs.promises + .readFile(file.path) + .then(data => { + if (endsWith(file.name.toLowerCase(), '.zip')) { + return importZipFile(data); + } else if (endsWith(file.name.toLowerCase(), '.json')) { + // The data is a string, import it directly + return importJsonString(data); + } else { + this.emit( + 'status', + 'error', + `Invalid file extension: ${file.name}` + ); + } + }) + .catch(err => { + this.emit('status', 'error', `Error reading file ${file.path}`); + }) + ); + + return Promise.all(promises).then(() => { + this.emit('status', 'complete', importedNoteCount); + }); + }; +} + +export default GoogleKeepImporter;