Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lib/dialogs/import/source-importer/executor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
10 changes: 10 additions & 0 deletions lib/dialogs/import/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
115 changes: 115 additions & 0 deletions lib/utils/import/googlekeep/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting. a note can only either be a list or some text?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in Keep, you can "show checkboxes", which turns the whole note into a checklist, and as far as I can tell there's no way to have regular text then.


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 }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it were me I might suggest sending archived notes to the trash or add a tag to them - archived and leave them in the inbox.

trash is generally safe but someone could accidentally "empty the trash" and wipe out their archive

if, on the other hand, they import their archive into the "All Notes" section then they might get more notes in the list than they expect.

maybe we just need a setting…

Archived Notes: [ ] Import into trash [ ] Import with tag _________

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is how I would do it as well. I don't know how to add a (importer-specific) setting to the dialog.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try to play around with it this week and see if it wouldn't be too hard. if not, maybe adding archive to the tags could be a good interim solution?

)
.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;