Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
* Use **Home Screen Widget** to access important notes fast
* **Lock your notes via Biometric/PIN**
* Configurable **auto-backups**
* **Import from other apps** such as [Evernote](https://evernote.com/), [Google Keep](https://keep.google.com/), [Quillpad](https://quillpad.github.io/)
* Create quick audio notes
* Display the notes either in a **List or Grid**
* Quickly share notes by text
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,6 @@ dependencies {
testImplementation("org.json:json:20180813")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.mockito:mockito-core:5.13.0")
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("com.github.luben:zstd-jni:1.5.7-6")
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.dao.BaseNoteDao.Companion.MAX_BODY_CHAR_LENGTH
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter
import com.philkes.notallyx.data.imports.quillpad.QuillpadImporter
import com.philkes.notallyx.data.imports.txt.JsonImporter
import com.philkes.notallyx.data.imports.txt.PlainTextImporter
import com.philkes.notallyx.data.model.Audio
Expand Down Expand Up @@ -44,6 +45,7 @@ class NotesImporter(private val app: Application, private val database: NotallyD
try {
when (importSource) {
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
ImportSource.QUILLPAD -> QuillpadImporter()
ImportSource.EVERNOTE -> EvernoteImporter()
ImportSource.PLAIN_TEXT -> PlainTextImporter()
ImportSource.JSON -> JsonImporter()
Expand Down Expand Up @@ -179,7 +181,7 @@ enum class ImportSource(
val helpTextResId: Int,
val documentationUrl: String?,
val iconResId: Int,
) {
) : Display {
GOOGLE_KEEP(
R.string.google_keep,
MIME_TYPE_ZIP,
Expand All @@ -194,6 +196,13 @@ enum class ImportSource(
"https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML",
R.drawable.icon_evernote,
),
QUILLPAD(
R.string.quillpad,
MIME_TYPE_ZIP,
R.string.quillpad_help,
"https://quillpad.github.io/",
R.drawable.icon_quillpad,
),
PLAIN_TEXT(
R.string.plain_text_files,
FOLDER_OR_FILE_MIMETYPE,
Expand All @@ -207,7 +216,21 @@ enum class ImportSource(
R.string.json_files_help,
null,
R.drawable.file_json,
),
);

override fun getTextId(): Int {
return displayNameResId
}

override fun getIconId(): Int {
return iconResId
}
}

interface Display {
fun getTextId(): Int

fun getIconId(): Int
}

const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.json.Json

@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
class GoogleKeepImporter : ExternalImporter {

@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.philkes.notallyx.data.imports.quillpad

import kotlinx.serialization.Serializable

@Serializable
data class QuillpadBackup(
val notes: List<QuillpadNote> = emptyList(),
val notebooks: List<QuillpadNotebook> = emptyList(),
val tags: List<QuillpadTag> = emptyList(),
val reminders: List<QuillpadReminder> = emptyList(),
val joins: List<QuillpadJoin> = emptyList(),
)

@Serializable
data class QuillpadNote(
val id: Long,
val title: String? = null,
val content: String? = null,
val isList: Boolean = false,
val taskList: List<QuillpadTask>? = null,
val isPinned: Boolean = false,
val isArchived: Boolean = false,
val isDeleted: Boolean = false,
val creationDate: Long,
val modifiedDate: Long,
val notebookId: Long? = null,
val tags: List<QuillpadTag>? = null,
val attachments: List<QuillpadAttachment>? = null,
val reminders: List<QuillpadReminder> = emptyList(),
)

@Serializable data class QuillpadTask(val id: Int, val content: String, val isDone: Boolean)

@Serializable data class QuillpadNotebook(val id: Long, val name: String)

@Serializable data class QuillpadTag(val id: Long, val name: String)

@Serializable
data class QuillpadReminder(
val id: Long,
val noteId: Long,
val date: Long,
val name: String? = null,
)

@Serializable data class QuillpadJoin(val tagId: Long, val noteId: Long)

@Serializable
data class QuillpadAttachment(
val type: String? = null,
val description: String? = null,
val fileName: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package com.philkes.notallyx.data.imports.quillpad

import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportException
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.imports.markdown.parseBodyAndSpansFromMarkdown
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.getMimeType
import com.philkes.notallyx.utils.moveAllFiles
import com.philkes.notallyx.utils.toMillis
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.Date
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import kotlin.collections.forEach
import kotlin.collections.map
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json

@OptIn(ExperimentalSerializationApi::class)
class QuillpadImporter : ExternalImporter {

internal val json = Json {
ignoreUnknownKeys = true
isLenient = true
allowTrailingComma = true
}

override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File> {
progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES))
val dataFolder =
try {
app.contentResolver.openInputStream(source)!!.use { unzip(destination, it) }
} catch (e: Exception) {
throw ImportException(R.string.invalid_quillpad, e)
}

val mediaFolder = File(dataFolder, "media")
if (mediaFolder.exists() && mediaFolder.isDirectory) {
mediaFolder.moveAllFiles(dataFolder)
}

val backupFile = File(dataFolder, "backup.json")
if (!backupFile.exists()) {
throw ImportException(
R.string.invalid_quillpad,
RuntimeException("backup.json not found in ZIP"),
)
}

val quillpadBackup =
try {
json.decodeFromString<QuillpadBackup>(backupFile.readText())
} catch (e: Exception) {
throw ImportException(R.string.invalid_quillpad, e)
}

val notebookMap = quillpadBackup.notebooks.associate { it.id to it.name }
val total = quillpadBackup.notes.size
progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES))
var counter = 1

val baseNotes =
quillpadBackup.notes.map { quillpadNote ->
val result = quillpadNote.toBaseNote(notebookMap)
progress?.postValue(
ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES)
)
result
}

return Pair(baseNotes, dataFolder)
}

fun QuillpadNote.toBaseNote(notebookMap: Map<Long, String>): BaseNote {
val (body, spans) =
if (!isList && content != null) {
parseBodyAndSpansFromMarkdown(content)
} else {
Pair("", emptyList())
}

val items =
taskList?.mapIndexed { index, task ->
ListItem(
body = task.content,
checked = task.isDone,
isChild = false,
order = index,
children = mutableListOf(),
)
} ?: emptyList()

val images = mutableListOf<FileAttachment>()
val files = mutableListOf<FileAttachment>()
val audios = mutableListOf<Audio>()

attachments?.forEach { attachment ->
when (attachment.type) {
"AUDIO" ->
audios.add(
Audio(
name = attachment.fileName,
duration = null,
timestamp = modifiedDate.toMillis(),
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else -> {
val mimetype = attachment.fileName.getMimeType()
if (mimetype?.startsWith("image/") == true) {
images.add(
FileAttachment(
localName = attachment.fileName,
originalName = attachment.description ?: attachment.fileName,
mimeType = mimetype,
)
)
} else {
files.add(
FileAttachment(
localName = attachment.fileName,
originalName = attachment.description ?: attachment.fileName,
mimeType = mimetype ?: "application/octet-stream",
)
)
}
}
}
}

val labels = mutableSetOf<String>()
notebookId?.let { notebookId -> notebookMap[notebookId]?.let { labels.add(it) } }
tags?.forEach { labels.add(it.name) }

val reminders =
this.reminders.map { Reminder(id = it.id, dateTime = Date(it.date), repetition = null) }

return BaseNote(
id = 0L,
type = if (isList) Type.LIST else Type.NOTE,
folder =
when {
isDeleted -> Folder.DELETED
isArchived -> Folder.ARCHIVED
else -> Folder.NOTES
},
color = BaseNote.COLOR_DEFAULT,
title = title ?: "",
pinned = isPinned,
timestamp = creationDate.toMillis(),
modifiedTimestamp = modifiedDate.toMillis(),
labels = labels.sorted().toList(),
body = body,
spans = spans,
items = items,
images = images,
files = files,
audios = audios,
reminders = reminders,
viewMode = NoteViewMode.EDIT,
isPinnedToStatus = false,
)
}

private fun unzip(destinationPath: File, inputStream: InputStream): File {
val buffer = ByteArray(1024)
val zis = ZipInputStream(inputStream)
var zipEntry = zis.nextEntry
while (zipEntry != null) {
val newFile = newFile(destinationPath, zipEntry)
if (zipEntry.isDirectory) {
if (!newFile.isDirectory && !newFile.mkdirs()) {
throw IOException("Failed to create directory $newFile")
}
} else {
val parent = newFile.parentFile
if (parent != null) {
if (!parent.isDirectory && !parent.mkdirs()) {
throw IOException("Failed to create directory $parent")
}
}
FileOutputStream(newFile).use {
var len: Int
while ((zis.read(buffer).also { length -> len = length }) > 0) {
it.write(buffer, 0, len)
}
}
}
zipEntry = zis.nextEntry
}
zis.closeEntry()
zis.close()
return destinationPath
Comment thread
Crustack marked this conversation as resolved.
}

private fun newFile(destinationDir: File, zipEntry: ZipEntry): File {
val destFile = File(destinationDir, zipEntry.name)
val destDirPath = destinationDir.canonicalPath
val destFilePath = destFile.canonicalPath
if (!destFilePath.startsWith(destDirPath + File.separator)) {
throw IOException("Entry is outside of the target dir: " + zipEntry.name)
}
return destFile
}

companion object {
private const val TAG = "QuillpadImporter"
}
}
Loading