(null)
const isSystem = sender === "system"
+ const handleBulkAdd = () => {
+ // Start bulk timestamps from the compose form's Time field.
+ const parsed = parseScript(script, timestamp)
+ if (parsed.length === 0) return
+ if (
+ replaceOnBulk &&
+ messages.length > 0 &&
+ !confirm(`Replace the existing ${messages.length} messages?`)
+ ) {
+ return
+ }
+ onBulkAdd(parsed, replaceOnBulk)
+ setScript("")
+ }
+
const clearImage = () => {
setImage("")
if (fileInputRef.current) fileInputRef.current.value = ""
@@ -566,6 +586,44 @@ export function ControlPanel({
+ {/* Bulk add from a script */}
+
+
{/* Messages List */}
diff --git a/src/script-parser.test.ts b/src/script-parser.test.ts
new file mode 100644
index 0000000..50af518
--- /dev/null
+++ b/src/script-parser.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from "vitest"
+import { parseScript } from "./script-parser"
+
+describe("parseScript", () => {
+ it("parses speakers into senders, me/you/i aliases case-insensitively", () => {
+ const msgs = parseScript(
+ ["Me: hey", "Sarah: hi!", "YOU: what's up?", "i: nothing"].join("\n"),
+ )
+ expect(msgs.map((m) => m.sender)).toEqual(["me", "them", "me", "me"])
+ expect(msgs[1].text).toBe("hi!")
+ })
+
+ it("advances timestamps one minute per message from the start time", () => {
+ const msgs = parseScript("Me: a\nSarah: b\nMe: c", "09:58")
+ expect(msgs.map((m) => m.timestamp)).toEqual(["09:58", "09:59", "10:00"])
+ })
+
+ it("wraps timestamps past midnight", () => {
+ const msgs = parseScript("Me: a\nSarah: b", "23:59")
+ expect(msgs.map((m) => m.timestamp)).toEqual(["23:59", "00:00"])
+ })
+
+ it("appends speakerless lines to the previous message", () => {
+ const msgs = parseScript("Sarah: first line\nsecond line\nMe: reply")
+ expect(msgs).toHaveLength(2)
+ expect(msgs[0].text).toBe("first line\nsecond line")
+ expect(msgs[1].text).toBe("reply")
+ })
+
+ it("treats a leading speakerless line as the contact", () => {
+ const msgs = parseScript("just text")
+ expect(msgs).toHaveLength(1)
+ expect(msgs[0].sender).toBe("them")
+ })
+
+ it("ignores blank lines and falls back on a bad start time", () => {
+ const msgs = parseScript("\nMe: a\n\n\nSarah: b\n", "nonsense")
+ expect(msgs).toHaveLength(2)
+ expect(msgs[0].timestamp).toBe("10:30")
+ })
+
+ it("ignores lines with a speaker prefix but no message content", () => {
+ const msgs = parseScript("Me: hello\nSarah: \nMe: world")
+ expect(msgs).toHaveLength(2)
+ expect(msgs[0].text).toBe("hello")
+ expect(msgs[1].text).toBe("world")
+ })
+
+ it("returns an empty list for empty input", () => {
+ expect(parseScript("")).toEqual([])
+ expect(parseScript(" \n ")).toEqual([])
+ })
+})
diff --git a/src/script-parser.ts b/src/script-parser.ts
new file mode 100644
index 0000000..01f2d1c
--- /dev/null
+++ b/src/script-parser.ts
@@ -0,0 +1,75 @@
+import type { Message } from "./types"
+
+// A parsed line of dialogue, ready to become a Message (id assigned later).
+export type ParsedMessage = Omit
+
+const LINE_RE = /^([^:\n]{1,30}):\s?(.*)$/
+
+// Speakers that map to the sent ("me") side, case-insensitively.
+const ME_ALIASES = new Set(["me", "you", "i"])
+
+function formatTime(totalMinutes: number): string {
+ const wrapped = ((totalMinutes % 1440) + 1440) % 1440
+ const h = Math.floor(wrapped / 60)
+ const m = wrapped % 60
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
+}
+
+function parseStartTime(time: string): number {
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim())
+ if (!match) return 10 * 60 + 30
+ return Number(match[1]) * 60 + Number(match[2])
+}
+
+/**
+ * Parse a pasted dialogue script into messages.
+ *
+ * Format: one `Speaker: text` per line. Speakers named "Me"/"You"/"I"
+ * (any case) become sent messages; anyone else becomes the contact.
+ * Lines without a `Speaker:` prefix continue the previous message on a
+ * new line. Timestamps start at `startTime` and advance one minute per
+ * message.
+ */
+export function parseScript(
+ script: string,
+ startTime = "10:30",
+): ParsedMessage[] {
+ const messages: ParsedMessage[] = []
+ let minutes = parseStartTime(startTime)
+
+ for (const rawLine of script.split("\n")) {
+ const line = rawLine.trim()
+ if (!line) continue
+
+ const match = LINE_RE.exec(line)
+ if (match) {
+ const text = match[2].trim()
+ // A speaker prefix with no content ("Sarah: ") is noise — skip it
+ // rather than gluing the literal line onto the previous message.
+ if (!text) continue
+ const speaker = match[1].trim()
+ const sender = ME_ALIASES.has(speaker.toLowerCase()) ? "me" : "them"
+ messages.push({
+ text,
+ sender,
+ timestamp: formatTime(minutes),
+ status: "read",
+ })
+ minutes += 1
+ } else if (messages.length > 0) {
+ // Continuation of the previous message.
+ messages[messages.length - 1].text += `\n${line}`
+ } else {
+ // Leading line without a speaker: treat as the contact talking.
+ messages.push({
+ text: line,
+ sender: "them",
+ timestamp: formatTime(minutes),
+ status: "read",
+ })
+ minutes += 1
+ }
+ }
+
+ return messages
+}