-
Notifications
You must be signed in to change notification settings - Fork 0
feat: bulk-add messages from a pasted dialogue script #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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([]) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<Message, "id"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a bug when parsing lines that contain a speaker prefix but no message content (e.g., Because
We should instead ignore lines that have a speaker prefix but no message content.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| } 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent regressions, we should add a unit test that verifies lines with a speaker prefix but no message content are correctly ignored.