Skip to content

Commit 277d1e9

Browse files
authored
Merge pull request #717 from PhilKes/feature/markdown-export-import
Feature/markdown export import
2 parents 8504ae0 + 4c8e78b commit 277d1e9

File tree

26 files changed

+803
-178
lines changed

26 files changed

+803
-178
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,5 @@ Before submitting your proposed changes as a Pull-Request, make sure all tests a
9090
The original Notally project was developed by [OmGodse](https://github.com/OmGodse) under the [GPL 3.0 License](https://github.com/OmGodse/Notally/blob/master/LICENSE.md).
9191

9292
In accordance to GPL 3.0, this project is licensed under the same [GPL 3.0 License](https://github.com/PhilKes/NotallyX/blob/master/LICENSE.md).
93+
94+

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ dependencies {
267267
implementation("org.simpleframework:simple-xml:2.7.1") {
268268
exclude(group = "xpp3", module = "xpp3")
269269
}
270+
implementation("org.commonmark:commonmark:0.27.0")
271+
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.0")
270272

271273
androidTestImplementation("androidx.room:room-testing:$roomVersion")
272274
androidTestImplementation("androidx.work:work-testing:2.9.1")
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package com.philkes.notallyx.data.imports.markdown
2+
3+
import com.philkes.notallyx.data.model.SpanRepresentation
4+
import org.commonmark.Extension
5+
import org.commonmark.ext.gfm.strikethrough.Strikethrough
6+
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
7+
import org.commonmark.node.*
8+
import org.commonmark.parser.Parser
9+
import org.commonmark.renderer.markdown.MarkdownRenderer
10+
11+
/**
12+
* Parses a Markdown string into a plain text body and a list of SpanRepresentation Only supported
13+
* spans are mapped: bold, italic, monospace (code), strikethrough, and link. Any other formatting
14+
* is ignored and raw text is preserved.
15+
*/
16+
fun parseBodyAndSpansFromMarkdown(input: String): Pair<String, List<SpanRepresentation>> {
17+
val extensions: List<Extension> = listOf(StrikethroughExtension.create())
18+
// Prepare renderer with GFM strikethrough
19+
val renderer = MarkdownRenderer.builder().extensions(extensions).build()
20+
val parser: Parser = Parser.builder().extensions(extensions).build()
21+
val document: Node = parser.parse(input)
22+
23+
val sb = StringBuilder()
24+
val spans = mutableListOf<SpanRepresentation>()
25+
26+
// Visitor that builds text and collects spans based on the node ranges in the built text
27+
document.accept(
28+
object : AbstractVisitor() {
29+
override fun visit(text: Text) {
30+
sb.append(text.literal)
31+
}
32+
33+
override fun visit(softLineBreak: SoftLineBreak) {
34+
sb.append('\n')
35+
}
36+
37+
override fun visit(hardLineBreak: HardLineBreak) {
38+
sb.append('\n')
39+
}
40+
41+
override fun visit(paragraph: Paragraph) {
42+
visitChildren(paragraph)
43+
if (sb.isNotEmpty() && (sb.last() != '\n')) sb.append('\n')
44+
}
45+
46+
override fun visit(code: Code) {
47+
val start = sb.length
48+
sb.append(code.literal)
49+
val end = sb.length
50+
spans.add(SpanRepresentation(start, end, monospace = true))
51+
}
52+
53+
override fun visit(emphasis: Emphasis) {
54+
val start = sb.length
55+
visitChildren(emphasis)
56+
val end = sb.length
57+
if (start != end) spans.add(SpanRepresentation(start, end, italic = true))
58+
}
59+
60+
override fun visit(strongEmphasis: StrongEmphasis) {
61+
val start = sb.length
62+
visitChildren(strongEmphasis)
63+
val end = sb.length
64+
if (start != end) spans.add(SpanRepresentation(start, end, bold = true))
65+
}
66+
67+
override fun visit(customNode: CustomNode) {
68+
if (customNode is Strikethrough) {
69+
val start = sb.length
70+
visitChildren(customNode)
71+
val end = sb.length
72+
if (start != end)
73+
spans.add(SpanRepresentation(start, end, strikethrough = true))
74+
} else {
75+
sb.append(renderer.render(customNode))
76+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
77+
}
78+
}
79+
80+
override fun visit(link: Link) {
81+
val start = sb.length
82+
visitChildren(link)
83+
val end = sb.length
84+
if (start != end)
85+
spans.add(
86+
SpanRepresentation(start, end, link = true, linkData = link.destination)
87+
)
88+
}
89+
90+
// For other blocks (Headings, Lists, etc.), just add raw markdown
91+
override fun visit(heading: Heading) {
92+
sb.append(renderer.render(heading))
93+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
94+
}
95+
96+
override fun visit(blockQuote: BlockQuote) {
97+
sb.append(renderer.render(blockQuote))
98+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
99+
}
100+
101+
override fun visit(bulletList: BulletList) {
102+
sb.append(renderer.render(bulletList))
103+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
104+
}
105+
106+
override fun visit(orderedList: OrderedList) {
107+
sb.append(renderer.render(orderedList))
108+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
109+
}
110+
111+
override fun visit(listItem: ListItem) {
112+
sb.append(renderer.render(listItem))
113+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
114+
}
115+
116+
override fun visit(thematicBreak: ThematicBreak) {
117+
sb.append(renderer.render(thematicBreak))
118+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
119+
}
120+
121+
override fun visit(image: Image) {
122+
sb.append(renderer.render(image))
123+
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
124+
}
125+
}
126+
)
127+
128+
// Trim a final single trailing newline introduced by paragraph/list handling
129+
val body = if (sb.endsWith("\n")) sb.trimEnd('\n').toString() else sb.toString()
130+
return Pair(body, spans)
131+
}
132+
133+
/**
134+
* Build a Markdown string from a plain text body and span representations using CommonMark AST and
135+
* MarkdownRenderer. Only supported inline styles are emitted: bold, italic, code, strikethrough and
136+
* links.
137+
*/
138+
fun createMarkdownFromBodyAndSpans(body: String, spans: List<SpanRepresentation>): String {
139+
// Prepare renderer with GFM strikethrough
140+
val extensions: List<Extension> = listOf(StrikethroughExtension.create())
141+
val renderer = MarkdownRenderer.builder().extensions(extensions).build()
142+
143+
val document = Document()
144+
145+
// Split into paragraphs by newline. Within a paragraph, emit SoftLineBreak on single newlines.
146+
// We'll create one Paragraph and insert SoftLineBreak for every '\n'. This preserves newlines
147+
// in output.
148+
val paragraph = Paragraph()
149+
document.appendChild(paragraph)
150+
151+
// Collect all boundary indices where styles change
152+
val boundaries = java.util.TreeSet<Int>()
153+
boundaries.add(0)
154+
boundaries.add(body.length)
155+
for (s in spans) {
156+
val start = s.start.coerceIn(0, body.length)
157+
val end = s.end.coerceIn(0, body.length)
158+
if (start < end) {
159+
boundaries.add(start)
160+
boundaries.add(end)
161+
}
162+
}
163+
164+
fun activeFor(rangeStart: Int, rangeEnd: Int): List<SpanRepresentation> {
165+
if (rangeStart >= rangeEnd) return emptyList()
166+
return spans.filter { it.start < rangeEnd && it.end > rangeStart }
167+
}
168+
169+
val it = boundaries.iterator()
170+
if (!it.hasNext()) return ""
171+
var prev = it.next()
172+
while (it.hasNext()) {
173+
val next = it.next()
174+
if (prev >= next) {
175+
prev = next
176+
continue
177+
}
178+
val segment = body.substring(prev, next)
179+
// Handle embedded newlines by splitting and inserting SoftLineBreak nodes
180+
val parts = segment.split('\n')
181+
for ((idx, part) in parts.withIndex()) {
182+
if (part.isNotEmpty()) {
183+
appendStyledInline(paragraph, part, activeFor(prev, next))
184+
}
185+
if (idx < parts.lastIndex) {
186+
paragraph.appendChild(SoftLineBreak())
187+
}
188+
}
189+
prev = next
190+
}
191+
192+
return renderer.render(document).trimEnd('\n')
193+
}
194+
195+
private fun appendStyledInline(parent: Node, text: String, actives: List<SpanRepresentation>) {
196+
// Determine flags in priority order. Code cannot contain children; Link will wrap others.
197+
val hasLink = actives.any { it.link }
198+
val linkData = actives.lastOrNull { it.link }?.linkData
199+
val bold = actives.any { it.bold }
200+
val italic = actives.any { it.italic }
201+
val strike = actives.any { it.strikethrough }
202+
val code = actives.any { it.monospace }
203+
204+
fun appendTextNode(container: Node) {
205+
container.appendChild(Text(text))
206+
}
207+
208+
if (code) {
209+
// Inline code has no children
210+
parent.appendChild(Code(text))
211+
return
212+
}
213+
214+
var container: Node = parent
215+
if (hasLink && !linkData.isNullOrEmpty()) {
216+
val link = Link(linkData, null)
217+
container.appendChild(link)
218+
container = link
219+
}
220+
if (bold) {
221+
val n = StrongEmphasis()
222+
container.appendChild(n)
223+
container = n
224+
}
225+
if (italic) {
226+
val n = Emphasis()
227+
container.appendChild(n)
228+
container = n
229+
}
230+
if (strike) {
231+
val n: CustomNode = Strikethrough("~~")
232+
container.appendChild(n)
233+
container = n
234+
}
235+
236+
appendTextNode(container)
237+
}

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import androidx.documentfile.provider.DocumentFile
66
import androidx.lifecycle.MutableLiveData
77
import com.philkes.notallyx.data.imports.ExternalImporter
88
import com.philkes.notallyx.data.imports.ImportProgress
9+
import com.philkes.notallyx.data.imports.markdown.parseBodyAndSpansFromMarkdown
910
import com.philkes.notallyx.data.model.BaseNote
1011
import com.philkes.notallyx.data.model.Folder
1112
import com.philkes.notallyx.data.model.ListItem
1213
import com.philkes.notallyx.data.model.NoteViewMode
1314
import com.philkes.notallyx.data.model.Type
15+
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
1416
import com.philkes.notallyx.utils.MIME_TYPE_JSON
17+
import com.philkes.notallyx.utils.log
1518
import com.philkes.notallyx.utils.readFileContents
1619
import java.io.File
1720

@@ -37,11 +40,35 @@ class PlainTextImporter : ExternalImporter {
3740
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
3841
var content = app.contentResolver.readFileContents(file.uri)
3942
val listItems = mutableListOf<ListItem>()
40-
content.findListSyntaxRegex()?.let { listSyntaxRegex ->
41-
listItems.addAll(content.extractListItems(listSyntaxRegex))
43+
// If content contains recognizable list syntax, prefer importing as LIST
44+
// If its markdown there could only be a headerline, ignore it for LISTs
45+
val listContent =
46+
if (file.isMarkdownFile() && content.startsWith("#")) {
47+
content.lines().drop(1).joinToString("\n")
48+
} else content
49+
listContent.findListSyntaxRegex()?.let { listSyntaxRegex ->
50+
listItems.addAll(listContent.extractListItems(listSyntaxRegex))
4251
content = ""
4352
}
4453
val timestamp = System.currentTimeMillis()
54+
55+
val (body, spans) =
56+
if (file.isMarkdownFile() && listItems.isEmpty()) {
57+
// Parse Markdown into body + spans, ignoring unsupported formatting
58+
try {
59+
parseBodyAndSpansFromMarkdown(content)
60+
} catch (e: Exception) {
61+
app.log(
62+
TAG,
63+
msg =
64+
"Parsing from Markdown content failed, will import as raw text. Markdown Content:\n$content",
65+
throwable = e,
66+
)
67+
// Fallback to plain text if parser fails
68+
Pair(content, emptyList())
69+
}
70+
} else Pair(content, emptyList())
71+
4572
notes.add(
4673
BaseNote(
4774
id = 0L, // Auto-generated
@@ -53,8 +80,8 @@ class PlainTextImporter : ExternalImporter {
5380
timestamp = timestamp,
5481
modifiedTimestamp = timestamp,
5582
labels = listOf(),
56-
body = content,
57-
spans = listOf(),
83+
body = if (listItems.isEmpty()) body else "",
84+
spans = if (listItems.isEmpty()) spans else listOf(),
5885
items = listItems,
5986
images = listOf(),
6087
files = listOf(),
@@ -77,6 +104,17 @@ class PlainTextImporter : ExternalImporter {
77104
private fun String.isTextMimeType(): Boolean {
78105
return startsWith("text/") || this in APPLICATION_TEXT_MIME_TYPES
79106
}
107+
108+
private fun DocumentFile.isMarkdownFile(): Boolean {
109+
return (type?.equals(ExportMimeType.MD.mimeType, ignoreCase = true) == true) ||
110+
(name
111+
?.substringAfterLast('.', "")
112+
?.equals(ExportMimeType.MD.fileExtension, ignoreCase = true) == true)
113+
}
114+
115+
companion object {
116+
private const val TAG = "PlainTextImporter"
117+
}
80118
}
81119

82120
val APPLICATION_TEXT_MIME_TYPES =

0 commit comments

Comments
 (0)