Skip to content
Draft
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
36 changes: 36 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,42 @@ describe("autosave", () => {
})
})

describe("bulk add", () => {
it("builds a thread from a pasted script", () => {
render(<App />)
fireEvent.change(screen.getByLabelText("Dialogue script"), {
target: { value: "Me: Bulk one\nSarah: Bulk two" },
})
fireEvent.click(screen.getByRole("button", { name: "Add to chat" }))

expect(screen.getAllByText("Bulk one").length).toBeGreaterThan(0)
expect(screen.getAllByText("Bulk two").length).toBeGreaterThan(0)
// The textarea clears after a successful add.
expect(screen.getByLabelText("Dialogue script")).toHaveValue("")
})

it("replace mode swaps out the existing conversation", () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
render(<App />)
fireEvent.click(screen.getByRole("checkbox", { name: /replace existing/i }))
fireEvent.change(screen.getByLabelText("Dialogue script"), {
target: { value: "Me: Fresh start" },
})
fireEvent.click(screen.getByRole("button", { name: "Add to chat" }))

expect(screen.getAllByText("Fresh start").length).toBeGreaterThan(0)
expect(screen.queryByText(/Hey! How are you/)).not.toBeInTheDocument()
confirmSpy.mockRestore()
})

it("does nothing for an empty script", () => {
render(<App />)
const before = screen.getAllByTitle("Delete").length
fireEvent.click(screen.getByRole("button", { name: "Add to chat" }))
expect(screen.getAllByTitle("Delete")).toHaveLength(before)
})
})

describe("responsible use", () => {
it("shows the watermark by default and allows turning it off", () => {
render(<App />)
Expand Down
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ function App() {
setConfig(INITIAL_CONFIG)
}, [])

const handleBulkAdd = useCallback(
(msgs: Omit<Message, "id">[], replace: boolean) => {
setMessages((prev) => {
const withIds = msgs.map((m) => ({ ...m, id: crypto.randomUUID() }))
return replace ? withIds : [...prev, ...withIds]
})
},
[],
)

const handleAddMessage = useCallback(
(msg: Omit<Message, "id">) => {
setMessages((prev) => [...prev, { ...msg, id: crypto.randomUUID() }])
Expand Down Expand Up @@ -262,6 +272,7 @@ function App() {
onUpdateConfig={setConfig}
onExport={handleExport}
onReset={handleReset}
onBulkAdd={handleBulkAdd}
/>
</div>
</div>
Expand Down
58 changes: 58 additions & 0 deletions src/components/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ExportFormat,
ExportOptions,
} from "../types"
import { parseScript } from "../script-parser"

type ControlPanelProps = {
messages: Message[]
Expand All @@ -20,6 +21,7 @@ type ControlPanelProps = {
onUpdateConfig: (config: ChatConfig) => void
onExport: (options: ExportOptions) => void
onReset: () => void
onBulkAdd: (msgs: Omit<Message, "id">[], replace: boolean) => void
}

export function ControlPanel({
Expand All @@ -32,6 +34,7 @@ export function ControlPanel({
onUpdateConfig,
onExport,
onReset,
onBulkAdd,
}: ControlPanelProps) {
const [text, setText] = useState("")
const [sender, setSender] = useState<MessageSender>("them")
Expand All @@ -45,10 +48,27 @@ export function ControlPanel({
const [editingId, setEditingId] = useState<string | null>(null)
const [exportFormat, setExportFormat] = useState<ExportFormat>("png")
const [exportScale, setExportScale] = useState(2)
const [script, setScript] = useState("")
const [replaceOnBulk, setReplaceOnBulk] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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 = ""
Expand Down Expand Up @@ -566,6 +586,44 @@ export function ControlPanel({
</div>
</form>

{/* Bulk add from a script */}
<div className="bg-white rounded-xl p-4 shadow-sm flex flex-col gap-3">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
Bulk Add
</h3>
<textarea
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder={"Me: Hey, are you coming?\nSarah: On my way! 🚗"}
rows={4}
aria-label="Dialogue script"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none font-mono focus:outline-none focus:ring-2 focus:ring-[#008069]/30 focus:border-[#008069]"
/>
<p className="text-xs text-gray-400 -mt-1">
One “Name: message” per line. “Me”, “You”, or “I” become sent
messages; anyone else is the contact. Timestamps advance one minute
per message.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleBulkAdd}
className="flex-1 py-2.5 bg-[#008069] hover:bg-[#006b57] text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
Add to chat
</button>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={replaceOnBulk}
onChange={(e) => setReplaceOnBulk(e.target.checked)}
className="w-4 h-4 accent-[#008069]"
/>
<span className="text-sm text-gray-700">Replace existing</span>
</label>
</div>
</div>

{/* Messages List */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Expand Down
53 changes: 53 additions & 0 deletions src/script-parser.test.ts
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([])
})
Comment on lines +41 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent regressions, we should add a unit test that verifies lines with a speaker prefix but no message content are correctly ignored.

  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([])
  })

})
75 changes: 75 additions & 0 deletions src/script-parser.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a bug when parsing lines that contain a speaker prefix but no message content (e.g., "Me: " or "Sarah: ").

Because match[2].trim() is empty, the condition match && match[2].trim() evaluates to false. The parser then falls into the else if or else blocks:

  1. If there are existing messages, it appends the trimmed line (e.g., "Me:") to the previous message as a continuation.
  2. If there are no existing messages, it treats the line as a speakerless leading line and adds a message with the text "Me:" sent by the contact ("them").

We should instead ignore lines that have a speaker prefix but no message content.

Suggested change
const match = LINE_RE.exec(line)
if (match && match[2].trim()) {
const speaker = match[1].trim()
const sender = ME_ALIASES.has(speaker.toLowerCase()) ? "me" : "them"
messages.push({
text: match[2].trim(),
sender,
timestamp: formatTime(minutes),
status: "read",
})
minutes += 1
const match = LINE_RE.exec(line)
if (match) {
const text = match[2].trim()
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
}
Loading