Skip to content

Commit d123ea7

Browse files
Support for italic and bold inside text using markdown modifiers (#549)
* Support for italic and bold inside text using markdown modifiers * Fixing unit test
1 parent 538723b commit d123ea7

File tree

5 files changed

+180
-90
lines changed

5 files changed

+180
-90
lines changed

application/core/ollama/src/commonMain/kotlin/io/writeopia/responses/OllamaResponse.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
55
@Serializable
66
class OllamaResponse(
77
val model: String? = null,
8-
val created_at: Long? = null,
8+
// val created_at: Long? = null,
99
val response: String? = null,
1010
val done: Boolean? = null,
1111
val done_reason: String? = null,
Lines changed: 95 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,125 @@
11
package io.writeopia.sdk.import.markdown
22

3+
import io.writeopia.sdk.manager.InTextMarkdownHandler
34
import io.writeopia.sdk.models.story.StoryTypes
45
import io.writeopia.sdk.models.story.Tag
56
import io.writeopia.sdk.serialization.data.StoryStepApi
67
import io.writeopia.sdk.serialization.data.StoryTypeApi
78
import io.writeopia.sdk.serialization.data.TagInfoApi
9+
import io.writeopia.sdk.serialization.extensions.toApi
10+
import io.writeopia.sdk.serialization.extensions.toModel
811

912
object MarkdownParser {
1013

1114
fun parse(lines: List<String>): List<StoryStepApi> {
1215
var acc = -1
13-
// Take care when moving the code, order matters for comparations!
1416
return lines.map { it.trim() }
1517
.mapIndexed { i, trimmed ->
1618
acc++
17-
when {
18-
i == 0 && trimmed.startsWith("#") -> {
19-
val type = StoryTypes.TITLE.type
20-
StoryStepApi(
21-
type = StoryTypeApi(type.name, type.number),
22-
text = trimmed.drop(1).trimStart(),
23-
position = acc
24-
)
25-
}
19+
val stepApi = parseLine(acc, i, trimmed)
2620

27-
i == 0 && !trimmed.startsWith("#") -> {
28-
val type = StoryTypes.TITLE.type
29-
StoryStepApi(
30-
type = StoryTypeApi(type.name, type.number),
31-
text = "",
32-
position = acc
33-
)
34-
}
21+
InTextMarkdownHandler.handleMarkdown(stepApi.toModel()).toApi(acc)
22+
}
23+
}
24+
25+
private fun parseLine(acc: Int, i: Int, trimmed: String) =
26+
// Take care when moving the code, order matters!
27+
when {
28+
i == 0 && trimmed.startsWith("#") -> {
29+
val type = StoryTypes.TITLE.type
30+
StoryStepApi(
31+
type = StoryTypeApi(type.name, type.number),
32+
text = trimmed.drop(1).trimStart(),
33+
position = acc
34+
)
35+
}
3536

36-
trimmed.startsWith("####") -> {
37-
val type = StoryTypes.TEXT.type
38-
StoryStepApi(
39-
type = StoryTypeApi(type.name, type.number),
40-
text = trimmed.drop(4).trimStart(),
41-
tags = setOf(TagInfoApi(Tag.H4.name, 0)),
42-
position = acc
43-
)
44-
}
37+
i == 0 && !trimmed.startsWith("#") -> {
38+
val type = StoryTypes.TITLE.type
39+
StoryStepApi(
40+
type = StoryTypeApi(type.name, type.number),
41+
text = "",
42+
position = acc
43+
)
44+
}
45+
46+
trimmed.startsWith("####") -> {
47+
val type = StoryTypes.TEXT.type
48+
StoryStepApi(
49+
type = StoryTypeApi(type.name, type.number),
50+
text = trimmed.drop(4).trimStart(),
51+
tags = setOf(TagInfoApi(Tag.H4.name, 0)),
52+
position = acc
53+
)
54+
}
4555

46-
trimmed.startsWith("###") -> {
47-
val type = StoryTypes.TEXT.type
48-
StoryStepApi(
49-
type = StoryTypeApi(type.name, type.number),
50-
text = trimmed.drop(3).trimStart(),
51-
tags = setOf(TagInfoApi(Tag.H3.name, 0)),
52-
position = acc
53-
)
54-
}
56+
trimmed.startsWith("###") -> {
57+
val type = StoryTypes.TEXT.type
58+
StoryStepApi(
59+
type = StoryTypeApi(type.name, type.number),
60+
text = trimmed.drop(3).trimStart(),
61+
tags = setOf(TagInfoApi(Tag.H3.name, 0)),
62+
position = acc
63+
)
64+
}
5565

56-
trimmed.startsWith("##") -> {
57-
val type = StoryTypes.TEXT.type
58-
StoryStepApi(
59-
type = StoryTypeApi(type.name, type.number),
60-
text = trimmed.drop(2).trimStart(),
61-
tags = setOf(TagInfoApi(Tag.H2.name, 0)),
62-
position = acc
63-
)
64-
}
66+
trimmed.startsWith("##") -> {
67+
val type = StoryTypes.TEXT.type
68+
StoryStepApi(
69+
type = StoryTypeApi(type.name, type.number),
70+
text = trimmed.drop(2).trimStart(),
71+
tags = setOf(TagInfoApi(Tag.H2.name, 0)),
72+
position = acc
73+
)
74+
}
6575

66-
trimmed.startsWith("#") -> {
67-
val type = StoryTypes.TEXT.type
68-
StoryStepApi(
69-
type = StoryTypeApi(type.name, type.number),
70-
text = trimmed.drop(1).trimStart(),
71-
tags = setOf(TagInfoApi(Tag.H1.name, 0)),
72-
position = acc
73-
)
74-
}
76+
trimmed.startsWith("#") -> {
77+
val type = StoryTypes.TEXT.type
78+
StoryStepApi(
79+
type = StoryTypeApi(type.name, type.number),
80+
text = trimmed.drop(1).trimStart(),
81+
tags = setOf(TagInfoApi(Tag.H1.name, 0)),
82+
position = acc
83+
)
84+
}
7585

7686
// trimmed.matches(Regex("^\\d+\\.\\s+.*")) -> {
7787
// StoryType.OrderedListItem to trimmed.replace(Regex("^\\d+\\.\\s+"), "")
7888
// }
7989

80-
trimmed.startsWith("---") -> {
81-
val type = StoryTypes.DIVIDER.type
82-
StoryStepApi(
83-
type = StoryTypeApi(type.name, type.number),
84-
position = acc
85-
)
86-
}
90+
trimmed.startsWith("---") -> {
91+
val type = StoryTypes.DIVIDER.type
92+
StoryStepApi(
93+
type = StoryTypeApi(type.name, type.number),
94+
position = acc
95+
)
96+
}
8797

88-
trimmed.startsWith("[] ") || trimmed.startsWith("-[] ") -> {
89-
val type = StoryTypes.CHECK_ITEM.type
90-
StoryStepApi(
91-
type = StoryTypeApi(type.name, type.number),
92-
text = trimmed.drop(3).trimStart(),
93-
position = acc
94-
)
95-
}
98+
trimmed.startsWith("[] ") || trimmed.startsWith("-[] ") -> {
99+
val type = StoryTypes.CHECK_ITEM.type
100+
StoryStepApi(
101+
type = StoryTypeApi(type.name, type.number),
102+
text = trimmed.drop(3).trimStart(),
103+
position = acc
104+
)
105+
}
96106

97-
trimmed.startsWith("- ") || trimmed.startsWith("* ") -> {
98-
val type = StoryTypes.UNORDERED_LIST_ITEM.type
99-
StoryStepApi(
100-
type = StoryTypeApi(type.name, type.number),
101-
text = trimmed.drop(2).trimStart(),
102-
position = acc
103-
)
104-
}
107+
trimmed.startsWith("- ") || trimmed.startsWith("* ") -> {
108+
val type = StoryTypes.UNORDERED_LIST_ITEM.type
109+
StoryStepApi(
110+
type = StoryTypeApi(type.name, type.number),
111+
text = trimmed.drop(2).trimStart(),
112+
position = acc
113+
)
114+
}
105115

106-
else -> {
107-
val type = StoryTypes.TEXT.type
108-
StoryStepApi(
109-
type = StoryTypeApi(type.name, type.number),
110-
text = trimmed,
111-
position = acc
112-
)
113-
}
114-
}
116+
else -> {
117+
val type = StoryTypes.TEXT.type
118+
StoryStepApi(
119+
type = StoryTypeApi(type.name, type.number),
120+
text = trimmed,
121+
position = acc
122+
)
115123
}
116-
}
124+
}
117125
}

plugins/writeopia_import_document/src/commonTest/kotlin/io/writeopia/sdk/imports/test/MarkdownParser.kt renamed to plugins/writeopia_import_document/src/commonTest/kotlin/io/writeopia/sdk/imports/test/MarkdownParserTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class MarkdownParserTest {
104104
val expected = listOf(
105105
StoryTypes.TITLE.type.number to "Sample Markdown Document",
106106
StoryTypes.TEXT.type.number to "",
107-
StoryTypes.TEXT.type.number to "Welcome to this **Markdown** example!",
107+
StoryTypes.TEXT.type.number to "Welcome to this Markdown example!",
108108
)
109109

110110
val results = MarkdownParser.parse(sample)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.writeopia.sdk.manager
2+
3+
import io.writeopia.sdk.models.id.GenerateId
4+
import io.writeopia.sdk.models.span.Span
5+
import io.writeopia.sdk.models.span.SpanInfo
6+
import io.writeopia.sdk.models.story.StoryStep
7+
8+
object InTextMarkdownHandler {
9+
10+
// Bold: Matches exactly **text**
11+
private val BOLD_REGEX = Regex("""\*\*(?!\*)(.*?)\*\*""")
12+
13+
// Italic: Matches *text* but ensures the boundaries are not double asterisks
14+
// (?<!\*) means "not preceded by *"
15+
// (?!\*) means "not followed by *"
16+
private val ITALIC_REGEX = Regex("""(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)""")
17+
18+
fun handleMarkdown(storyStep: StoryStep): StoryStep {
19+
val originalText = storyStep.text ?: return storyStep
20+
21+
val newSpans = mutableSetOf<SpanInfo>()
22+
var processedText = originalText
23+
24+
// Order matters: Process bold first to "clean" those markers
25+
// before the italic logic runs.
26+
processedText = processPattern(processedText, BOLD_REGEX, Span.BOLD, newSpans)
27+
processedText = processPattern(processedText, ITALIC_REGEX, Span.ITALIC, newSpans)
28+
29+
return if (newSpans.isNotEmpty()) {
30+
storyStep.copy(
31+
text = processedText,
32+
spans = storyStep.spans + newSpans,
33+
localId = if (newSpans.isNotEmpty()) GenerateId.generate() else storyStep.localId
34+
)
35+
} else {
36+
storyStep
37+
}
38+
}
39+
40+
private fun processPattern(
41+
text: String,
42+
regex: Regex,
43+
spanType: Span,
44+
spanSet: MutableSet<SpanInfo>
45+
): String {
46+
var currentText = text
47+
var match = regex.find(currentText)
48+
49+
while (match != null) {
50+
val fullMatchRange = match.range
51+
val content = match.groupValues[1]
52+
53+
// Check to ensure there is actually content inside the tags
54+
if (content.isNotEmpty()) {
55+
val spanInfo = SpanInfo.create(
56+
start = fullMatchRange.first,
57+
end = fullMatchRange.first + content.length,
58+
span = spanType
59+
)
60+
spanSet.add(spanInfo)
61+
currentText = currentText.replaceRange(fullMatchRange, content)
62+
} else {
63+
// If it's just **, we move past it to avoid infinite loops
64+
match = regex.find(currentText, fullMatchRange.first + 1)
65+
continue
66+
}
67+
68+
match = regex.find(currentText)
69+
}
70+
return currentText
71+
}
72+
}

writeopia_ui/src/commonMain/kotlin/io/writeopia/ui/manager/WriteopiaStateManager.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package io.writeopia.ui.manager
44

55
import io.writeopia.sdk.manager.DocumentTracker
6+
import io.writeopia.sdk.manager.InTextMarkdownHandler
67
import io.writeopia.sdk.manager.WriteopiaManager
78
import io.writeopia.sdk.manager.fixMove
89
import io.writeopia.sdk.model.action.Action
@@ -90,6 +91,7 @@ class WriteopiaStateManager(
9091
StoryTypes.CHECK_ITEM.type.number,
9192
StoryTypes.UNORDERED_LIST_ITEM.type.number,
9293
),
94+
private val inTextMarkdownHandler: InTextMarkdownHandler? = InTextMarkdownHandler
9395
) : BackstackHandler, BackstackInform by backStackManager {
9496

9597
private val selectionBuffer: EventBuffer<Pair<Boolean, Int>> = EventBuffer(coroutineScope)
@@ -572,7 +574,15 @@ class WriteopiaStateManager(
572574
if (!isEditable) return
573575
backStackManager.addTextState(_currentStory.value, stateChange.position)
574576

575-
changeStoryStateAndTrackIt(stateChange, trackIt = false)
577+
val step = stateChange.storyStep
578+
579+
val newState = if (inTextMarkdownHandler != null) {
580+
stateChange.copy(storyStep = inTextMarkdownHandler.handleMarkdown(step))
581+
} else {
582+
stateChange
583+
}
584+
585+
changeStoryStateAndTrackIt(newState, trackIt = false)
576586
}
577587

578588
/**

0 commit comments

Comments
 (0)