Skip to content

Commit 5f5a625

Browse files
authored
Add Quillpad import and fix Notally import (#978)
1 parent 8e2f8be commit 5f5a625

23 files changed

Lines changed: 1015 additions & 229 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* Use **Home Screen Widget** to access important notes fast
4646
* **Lock your notes via Biometric/PIN**
4747
* Configurable **auto-backups**
48+
* **Import from other apps** such as [Evernote](https://evernote.com/), [Google Keep](https://keep.google.com/), [Quillpad](https://quillpad.github.io/)
4849
* Create quick audio notes
4950
* Display the notes either in a **List or Grid**
5051
* Quickly share notes by text

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,6 @@ dependencies {
297297
testImplementation("org.json:json:20180813")
298298
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
299299
testImplementation("org.mockito:mockito-core:5.13.0")
300-
testImplementation("org.robolectric:robolectric:4.15.1")
300+
testImplementation("org.robolectric:robolectric:4.16.1")
301301
testImplementation("com.github.luben:zstd-jni:1.5.7-6")
302302
}

app/src/main/java/com/philkes/notallyx/data/imports/NotesImporter.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.philkes.notallyx.data.NotallyDatabase
1010
import com.philkes.notallyx.data.dao.BaseNoteDao.Companion.MAX_BODY_CHAR_LENGTH
1111
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
1212
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter
13+
import com.philkes.notallyx.data.imports.quillpad.QuillpadImporter
1314
import com.philkes.notallyx.data.imports.txt.JsonImporter
1415
import com.philkes.notallyx.data.imports.txt.PlainTextImporter
1516
import com.philkes.notallyx.data.model.Audio
@@ -44,6 +45,7 @@ class NotesImporter(private val app: Application, private val database: NotallyD
4445
try {
4546
when (importSource) {
4647
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
48+
ImportSource.QUILLPAD -> QuillpadImporter()
4749
ImportSource.EVERNOTE -> EvernoteImporter()
4850
ImportSource.PLAIN_TEXT -> PlainTextImporter()
4951
ImportSource.JSON -> JsonImporter()
@@ -179,7 +181,7 @@ enum class ImportSource(
179181
val helpTextResId: Int,
180182
val documentationUrl: String?,
181183
val iconResId: Int,
182-
) {
184+
) : Display {
183185
GOOGLE_KEEP(
184186
R.string.google_keep,
185187
MIME_TYPE_ZIP,
@@ -194,6 +196,13 @@ enum class ImportSource(
194196
"https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML",
195197
R.drawable.icon_evernote,
196198
),
199+
QUILLPAD(
200+
R.string.quillpad,
201+
MIME_TYPE_ZIP,
202+
R.string.quillpad_help,
203+
"https://quillpad.github.io/",
204+
R.drawable.icon_quillpad,
205+
),
197206
PLAIN_TEXT(
198207
R.string.plain_text_files,
199208
FOLDER_OR_FILE_MIMETYPE,
@@ -207,7 +216,21 @@ enum class ImportSource(
207216
R.string.json_files_help,
208217
null,
209218
R.drawable.file_json,
210-
),
219+
);
220+
221+
override fun getTextId(): Int {
222+
return displayNameResId
223+
}
224+
225+
override fun getIconId(): Int {
226+
return iconResId
227+
}
228+
}
229+
230+
interface Display {
231+
fun getTextId(): Int
232+
233+
fun getIconId(): Int
211234
}
212235

213236
const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"

app/src/main/java/com/philkes/notallyx/data/imports/google/GoogleKeepImporter.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ import java.io.InputStream
2525
import java.util.zip.ZipEntry
2626
import java.util.zip.ZipInputStream
2727
import kotlinx.serialization.ExperimentalSerializationApi
28+
import kotlinx.serialization.InternalSerializationApi
2829
import kotlinx.serialization.json.Json
2930

31+
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
3032
class GoogleKeepImporter : ExternalImporter {
3133

32-
@OptIn(ExperimentalSerializationApi::class)
3334
private val json = Json {
3435
ignoreUnknownKeys = true
3536
isLenient = true
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.philkes.notallyx.data.imports.quillpad
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class QuillpadBackup(
7+
val notes: List<QuillpadNote> = emptyList(),
8+
val notebooks: List<QuillpadNotebook> = emptyList(),
9+
val tags: List<QuillpadTag> = emptyList(),
10+
val reminders: List<QuillpadReminder> = emptyList(),
11+
val joins: List<QuillpadJoin> = emptyList(),
12+
)
13+
14+
@Serializable
15+
data class QuillpadNote(
16+
val id: Long,
17+
val title: String? = null,
18+
val content: String? = null,
19+
val isList: Boolean = false,
20+
val taskList: List<QuillpadTask>? = null,
21+
val isPinned: Boolean = false,
22+
val isArchived: Boolean = false,
23+
val isDeleted: Boolean = false,
24+
val creationDate: Long,
25+
val modifiedDate: Long,
26+
val notebookId: Long? = null,
27+
val tags: List<QuillpadTag>? = null,
28+
val attachments: List<QuillpadAttachment>? = null,
29+
val reminders: List<QuillpadReminder> = emptyList(),
30+
)
31+
32+
@Serializable data class QuillpadTask(val id: Int, val content: String, val isDone: Boolean)
33+
34+
@Serializable data class QuillpadNotebook(val id: Long, val name: String)
35+
36+
@Serializable data class QuillpadTag(val id: Long, val name: String)
37+
38+
@Serializable
39+
data class QuillpadReminder(
40+
val id: Long,
41+
val noteId: Long,
42+
val date: Long,
43+
val name: String? = null,
44+
)
45+
46+
@Serializable data class QuillpadJoin(val tagId: Long, val noteId: Long)
47+
48+
@Serializable
49+
data class QuillpadAttachment(
50+
val type: String? = null,
51+
val description: String? = null,
52+
val fileName: String,
53+
)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package com.philkes.notallyx.data.imports.quillpad
2+
3+
import android.app.Application
4+
import android.net.Uri
5+
import androidx.lifecycle.MutableLiveData
6+
import com.philkes.notallyx.R
7+
import com.philkes.notallyx.data.imports.ExternalImporter
8+
import com.philkes.notallyx.data.imports.ImportException
9+
import com.philkes.notallyx.data.imports.ImportProgress
10+
import com.philkes.notallyx.data.imports.ImportStage
11+
import com.philkes.notallyx.data.imports.markdown.parseBodyAndSpansFromMarkdown
12+
import com.philkes.notallyx.data.model.Audio
13+
import com.philkes.notallyx.data.model.BaseNote
14+
import com.philkes.notallyx.data.model.FileAttachment
15+
import com.philkes.notallyx.data.model.Folder
16+
import com.philkes.notallyx.data.model.ListItem
17+
import com.philkes.notallyx.data.model.NoteViewMode
18+
import com.philkes.notallyx.data.model.Reminder
19+
import com.philkes.notallyx.data.model.Type
20+
import com.philkes.notallyx.utils.getMimeType
21+
import com.philkes.notallyx.utils.moveAllFiles
22+
import com.philkes.notallyx.utils.toMillis
23+
import java.io.File
24+
import java.io.FileOutputStream
25+
import java.io.IOException
26+
import java.io.InputStream
27+
import java.util.Date
28+
import java.util.zip.ZipEntry
29+
import java.util.zip.ZipInputStream
30+
import kotlin.collections.forEach
31+
import kotlin.collections.map
32+
import kotlinx.serialization.ExperimentalSerializationApi
33+
import kotlinx.serialization.json.Json
34+
35+
@OptIn(ExperimentalSerializationApi::class)
36+
class QuillpadImporter : ExternalImporter {
37+
38+
internal val json = Json {
39+
ignoreUnknownKeys = true
40+
isLenient = true
41+
allowTrailingComma = true
42+
}
43+
44+
override fun import(
45+
app: Application,
46+
source: Uri,
47+
destination: File,
48+
progress: MutableLiveData<ImportProgress>?,
49+
): Pair<List<BaseNote>, File> {
50+
progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES))
51+
val dataFolder =
52+
try {
53+
app.contentResolver.openInputStream(source)!!.use { unzip(destination, it) }
54+
} catch (e: Exception) {
55+
throw ImportException(R.string.invalid_quillpad, e)
56+
}
57+
58+
val mediaFolder = File(dataFolder, "media")
59+
if (mediaFolder.exists() && mediaFolder.isDirectory) {
60+
mediaFolder.moveAllFiles(dataFolder)
61+
}
62+
63+
val backupFile = File(dataFolder, "backup.json")
64+
if (!backupFile.exists()) {
65+
throw ImportException(
66+
R.string.invalid_quillpad,
67+
RuntimeException("backup.json not found in ZIP"),
68+
)
69+
}
70+
71+
val quillpadBackup =
72+
try {
73+
json.decodeFromString<QuillpadBackup>(backupFile.readText())
74+
} catch (e: Exception) {
75+
throw ImportException(R.string.invalid_quillpad, e)
76+
}
77+
78+
val notebookMap = quillpadBackup.notebooks.associate { it.id to it.name }
79+
val total = quillpadBackup.notes.size
80+
progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES))
81+
var counter = 1
82+
83+
val baseNotes =
84+
quillpadBackup.notes.map { quillpadNote ->
85+
val result = quillpadNote.toBaseNote(notebookMap)
86+
progress?.postValue(
87+
ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES)
88+
)
89+
result
90+
}
91+
92+
return Pair(baseNotes, dataFolder)
93+
}
94+
95+
fun QuillpadNote.toBaseNote(notebookMap: Map<Long, String>): BaseNote {
96+
val (body, spans) =
97+
if (!isList && content != null) {
98+
parseBodyAndSpansFromMarkdown(content)
99+
} else {
100+
Pair("", emptyList())
101+
}
102+
103+
val items =
104+
taskList?.mapIndexed { index, task ->
105+
ListItem(
106+
body = task.content,
107+
checked = task.isDone,
108+
isChild = false,
109+
order = index,
110+
children = mutableListOf(),
111+
)
112+
} ?: emptyList()
113+
114+
val images = mutableListOf<FileAttachment>()
115+
val files = mutableListOf<FileAttachment>()
116+
val audios = mutableListOf<Audio>()
117+
118+
attachments?.forEach { attachment ->
119+
when (attachment.type) {
120+
"AUDIO" ->
121+
audios.add(
122+
Audio(
123+
name = attachment.fileName,
124+
duration = null,
125+
timestamp = modifiedDate.toMillis(),
126+
)
127+
)
128+
else -> {
129+
val mimetype = attachment.fileName.getMimeType()
130+
if (mimetype?.startsWith("image/") == true) {
131+
images.add(
132+
FileAttachment(
133+
localName = attachment.fileName,
134+
originalName = attachment.description ?: attachment.fileName,
135+
mimeType = mimetype,
136+
)
137+
)
138+
} else {
139+
files.add(
140+
FileAttachment(
141+
localName = attachment.fileName,
142+
originalName = attachment.description ?: attachment.fileName,
143+
mimeType = mimetype ?: "application/octet-stream",
144+
)
145+
)
146+
}
147+
}
148+
}
149+
}
150+
151+
val labels = mutableSetOf<String>()
152+
notebookId?.let { notebookId -> notebookMap[notebookId]?.let { labels.add(it) } }
153+
tags?.forEach { labels.add(it.name) }
154+
155+
val reminders =
156+
this.reminders.map { Reminder(id = it.id, dateTime = Date(it.date), repetition = null) }
157+
158+
return BaseNote(
159+
id = 0L,
160+
type = if (isList) Type.LIST else Type.NOTE,
161+
folder =
162+
when {
163+
isDeleted -> Folder.DELETED
164+
isArchived -> Folder.ARCHIVED
165+
else -> Folder.NOTES
166+
},
167+
color = BaseNote.COLOR_DEFAULT,
168+
title = title ?: "",
169+
pinned = isPinned,
170+
timestamp = creationDate.toMillis(),
171+
modifiedTimestamp = modifiedDate.toMillis(),
172+
labels = labels.sorted().toList(),
173+
body = body,
174+
spans = spans,
175+
items = items,
176+
images = images,
177+
files = files,
178+
audios = audios,
179+
reminders = reminders,
180+
viewMode = NoteViewMode.EDIT,
181+
isPinnedToStatus = false,
182+
)
183+
}
184+
185+
private fun unzip(destinationPath: File, inputStream: InputStream): File {
186+
val buffer = ByteArray(1024)
187+
val zis = ZipInputStream(inputStream)
188+
var zipEntry = zis.nextEntry
189+
while (zipEntry != null) {
190+
val newFile = newFile(destinationPath, zipEntry)
191+
if (zipEntry.isDirectory) {
192+
if (!newFile.isDirectory && !newFile.mkdirs()) {
193+
throw IOException("Failed to create directory $newFile")
194+
}
195+
} else {
196+
val parent = newFile.parentFile
197+
if (parent != null) {
198+
if (!parent.isDirectory && !parent.mkdirs()) {
199+
throw IOException("Failed to create directory $parent")
200+
}
201+
}
202+
FileOutputStream(newFile).use {
203+
var len: Int
204+
while ((zis.read(buffer).also { length -> len = length }) > 0) {
205+
it.write(buffer, 0, len)
206+
}
207+
}
208+
}
209+
zipEntry = zis.nextEntry
210+
}
211+
zis.closeEntry()
212+
zis.close()
213+
return destinationPath
214+
}
215+
216+
private fun newFile(destinationDir: File, zipEntry: ZipEntry): File {
217+
val destFile = File(destinationDir, zipEntry.name)
218+
val destDirPath = destinationDir.canonicalPath
219+
val destFilePath = destFile.canonicalPath
220+
if (!destFilePath.startsWith(destDirPath + File.separator)) {
221+
throw IOException("Entry is outside of the target dir: " + zipEntry.name)
222+
}
223+
return destFile
224+
}
225+
226+
companion object {
227+
private const val TAG = "QuillpadImporter"
228+
}
229+
}

0 commit comments

Comments
 (0)