diff --git a/CLAUDE.md b/CLAUDE.md index 1e25adf..2e1324b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,45 @@ If you're using the Nix development environment (via `nix develop` or direnv), u - IMPORTANT: Always try to extract testable logic that can be independent of Obsidian plugins to separate classes or functions and write unit tests for it. - IMPORTANT: Do not write useless tests just to increase coverage, make them actually useful for catching issues in the code. +## Architecture + +The codebase is organized into modular components: + +``` +src/ + main.ts # Plugin entry point, orchestrates components + types.ts # All TypeScript interfaces and types + colorUtils.ts # Color manipulation utilities + + parser/ # Parsing logic (Obsidian-independent) + TranscriptParser.ts # Orchestrates line parsing + FrontmatterParser.ts # Parses speech-bubbles frontmatter config + TimestampParser.ts # Parses [HH:MM] timestamps + DateSeparatorParser.ts # Parses --- date --- separators + + config/ # Configuration resolution + ConfigResolver.ts # Merges global settings with frontmatter + SpeakerResolver.ts # Resolves speaker colors, sides, icons + + renderer/ # DOM rendering + BubbleRenderer.ts # Creates speech bubble elements + DateSeparatorRenderer.ts # Creates date separator elements + + settings/ # Plugin settings UI + SettingsTab.ts # Obsidian settings tab + + __tests__/ # Jest tests mirroring src structure + colorUtils.test.ts + parser/ + config/ +``` + +Key design principles: + +- **Separation of concerns**: Parsing, configuration, and rendering are isolated +- **Testability**: Parser and config modules are Obsidian-independent and fully unit-tested +- **Type safety**: All interfaces defined in `types.ts` + ## Typescript & Testing - Strict null checks required (strictNullChecks: true) @@ -86,7 +125,7 @@ If you're using the Nix development environment (via `nix develop` or direnv), u - All CSS classes should have the prefix `speech-bubbles-`. - All user data attributes should start with `data-speech-bubbles-` - Do not overwrite Obsidian core styling, always use custom classes or data attributes. -- **Avoid inline styles**: Never assign styles via JavaScript (`element.style.x = y`) or inline HTML style attributes. Move all styles to CSS so themes and snippets can adapt them. +- **Avoid inline styles**: Never assign styles via JavaScript (`element.style.x = y`) or inline HTML style attributes. Move all styles to CSS so themes and snippets can adapt them. Exception: CSS custom properties (`element.style.setProperty('--var', value)`) are acceptable for dynamic values like colors. ## Obsidian API Best Practices diff --git a/README.md b/README.md index 96ddc28..a5e612e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ An Obsidian plugin that renders transcript notes as message app style speech bub - Different colors for different speakers - Right-aligned bubbles for the vault owner (configurable) - Support for multiple speaker aliases +- **Per-speaker custom colors and icons** via frontmatter +- **Inline timestamps** for messages +- **Date separators** between conversation days +- **Speaker groups/sides** for roleplay and D&D scenarios +- **Customizable appearance** (bubble width, radius, compact mode) ## Usage @@ -22,7 +27,7 @@ An Obsidian plugin that renders transcript notes as message app style speech bub 2. Add the `transcript` tag to the note frontmatter to enable bubbles in Reading view. - ``` + ```yaml --- tags: [transcript] --- @@ -30,7 +35,7 @@ An Obsidian plugin that renders transcript notes as message app style speech bub 3. Switch to Reading view to see the bubbles. -### Example +### Basic example ```markdown [[John Smith]]: Hello! @@ -39,12 +44,124 @@ An Obsidian plugin that renders transcript notes as message app style speech bub [[me]]: I'm doing great, thanks for asking! ``` +### Timestamps + +Add timestamps to messages using `[HH:MM]` or `[HH:MM:SS]` format after the speaker name: + +```markdown +[[John]] [14:32]: Hello! +[[me]] [14:33]: Hi there! +[[John]] [14:35]: How's it going? +``` + +### Date separators + +Add visual date separators between conversation days: + +``` +--- 2024-01-15 --- +[[John]]: Hello! +[[me]]: Hi! + +--- 2024-01-16 --- +[[John]]: Following up on yesterday... +``` + +Supported formats: + +- `--- YYYY-MM-DD ---` (e.g., `--- 2024-01-15 ---`) +- `--- Month Day, Year ---` (e.g., `--- January 15, 2024 ---`) + +### Per-speaker customization + +Customize individual speakers with colors and icons via frontmatter: + +```yaml +--- +tags: [transcript] +speech-bubbles: + speakers: + Gandalf: "#9CA3AF" # Simple color format + Frodo: + color: "#34D399" # Object format with color + icon: "🧙" # Emoji icon + Sauron: + color: "#EF4444" + icon: "[[avatars/eye.png]]" # Vault image as icon +--- +[[Gandalf]]: You shall not pass! +[[Frodo]]: But I must destroy the ring... +[[Sauron]]: I see you... +``` + +### Speaker groups/sides + +For D&D, debates, or roleplay scenarios, assign speakers to left or right sides regardless of the owner setting: + +```yaml +--- +tags: [transcript] +speech-bubbles: + sides: + left: ["Gandalf", "Frodo", "Aragorn"] # Party members + right: ["Sauron", "Saruman"] # Villains +--- +[[Gandalf]]: The fellowship must continue. +[[Sauron]]: Your quest is futile. +[[Frodo]]: We will not give up. +``` + ## Settings +### Identity + - **Your name**: The name used in transcripts to identify you. Messages from this person will appear on the right side with blue bubbles (default: "me"). - **Aliases**: Other names that should also be treated as you (comma-separated). + +### Appearance + +- **Maximum bubble width**: Maximum width of speech bubbles as a percentage (10-100%, default: 75%). +- **Bubble corner radius**: Corner radius of speech bubbles in pixels (0-30px, default: 18px). +- **Show speaker names**: Display the speaker name above each bubble (default: on). +- **Compact mode**: Use smaller spacing and font sizes for a more compact layout. +- **Your bubble color**: Custom hex color for your speech bubbles (leave empty for default indigo). + +### Debug + - **Enable debug logging**: Logs toggle and render details to the developer console for troubleshooting. +## Complete example + +```markdown +--- +tags: [transcript] +speech-bubbles: + speakers: + DM: + color: "#8B5CF6" + icon: "🎲" + Gandalf: + color: "#9CA3AF" + icon: "🧙" + Frodo: + color: "#34D399" + sides: + left: ["DM"] + right: ["Gandalf", "Frodo"] +--- + +--- January 15, 2024 --- + +[[DM]] [19:00]: Welcome to Middle-earth! You find yourselves at the gates of Moria. +[[Gandalf]] [19:01]: I remember this place... the doors are hidden. +[[Frodo]] [19:02]: What's the password? +[[DM]] [19:03]: Roll an Intelligence check. + +--- January 16, 2024 --- + +[[DM]] [19:00]: Last session, you entered Moria... +``` + ## Installation ### From Obsidian Community Plugins diff --git a/src/__tests__/colorUtils.test.ts b/src/__tests__/colorUtils.test.ts index 031d7c4..0520c59 100644 --- a/src/__tests__/colorUtils.test.ts +++ b/src/__tests__/colorUtils.test.ts @@ -1,4 +1,4 @@ -import { darkenColor, isOwner, SPEAKER_COLORS, OWNER_COLOR } from "../colorUtils"; +import { darkenColor, isOwner, lightenColor, hexToSpeakerColor, SPEAKER_COLORS, OWNER_COLOR } from "../colorUtils"; describe("darkenColor", () => { it("should darken a light gray color", () => { @@ -33,6 +33,41 @@ describe("darkenColor", () => { }); }); +describe("lightenColor", () => { + it("should lighten a dark color", () => { + const result = lightenColor("#000000", 0.5); + expect(result).toBe("#808080"); + }); + + it("should lighten a color by a small amount", () => { + const result = lightenColor("#6366F1", 0.2); + expect(result).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it("should return the same color for invalid hex", () => { + const result = lightenColor("invalid", 0.5); + expect(result).toBe("invalid"); + }); + + it("should not exceed white", () => { + const result = lightenColor("#FFFFFF", 0.5); + expect(result).toBe("#ffffff"); + }); +}); + +describe("hexToSpeakerColor", () => { + it("should create a gradient from a hex color", () => { + const result = hexToSpeakerColor("#6366F1"); + expect(result.start).toMatch(/^#[0-9a-f]{6}$/i); + expect(result.end).toBe("#6366F1"); + }); + + it("should have a lighter start than end", () => { + const result = hexToSpeakerColor("#000000"); + expect(result.start).not.toBe(result.end); + }); +}); + describe("isOwner", () => { it("should return true when name matches owner name", () => { expect(isOwner("John", "John", [])).toBe(true); diff --git a/src/__tests__/config/ConfigResolver.test.ts b/src/__tests__/config/ConfigResolver.test.ts new file mode 100644 index 0000000..f5d4468 --- /dev/null +++ b/src/__tests__/config/ConfigResolver.test.ts @@ -0,0 +1,46 @@ +import { resolveConfig } from "../../config/ConfigResolver"; +import { DEFAULT_SETTINGS } from "../../types"; + +describe("resolveConfig", () => { + it("should return config with default settings when no frontmatter", () => { + const result = resolveConfig(DEFAULT_SETTINGS, null); + expect(result.settings).toEqual(DEFAULT_SETTINGS); + expect(result.speakerConfigs.size).toBe(0); + expect(result.sides).toBeNull(); + }); + + it("should merge frontmatter speaker configs", () => { + const result = resolveConfig(DEFAULT_SETTINGS, { + "speech-bubbles": { + speakers: { + Gandalf: "#9CA3AF", + }, + }, + }); + expect(result.speakerConfigs.get("gandalf")?.color).toBe("#9CA3AF"); + }); + + it("should merge frontmatter sides config", () => { + const result = resolveConfig(DEFAULT_SETTINGS, { + "speech-bubbles": { + sides: { + left: ["Gandalf"], + right: ["Sauron"], + }, + }, + }); + expect(result.sides?.left.has("gandalf")).toBe(true); + expect(result.sides?.right.has("sauron")).toBe(true); + }); + + it("should use provided settings", () => { + const customSettings = { + ...DEFAULT_SETTINGS, + ownerName: "Frodo", + compactMode: true, + }; + const result = resolveConfig(customSettings, null); + expect(result.settings.ownerName).toBe("Frodo"); + expect(result.settings.compactMode).toBe(true); + }); +}); diff --git a/src/__tests__/config/SpeakerResolver.test.ts b/src/__tests__/config/SpeakerResolver.test.ts new file mode 100644 index 0000000..d401379 --- /dev/null +++ b/src/__tests__/config/SpeakerResolver.test.ts @@ -0,0 +1,153 @@ +import { SpeakerResolver } from "../../config/SpeakerResolver"; +import { DEFAULT_SETTINGS, TranscriptConfig } from "../../types"; +import { OWNER_COLOR, SPEAKER_COLORS } from "../../colorUtils"; + +function createConfig(overrides: Partial = {}): TranscriptConfig { + return { + settings: { ...DEFAULT_SETTINGS }, + speakerConfigs: new Map(), + sides: null, + ...overrides, + }; +} + +describe("SpeakerResolver", () => { + describe("getSpeakerColor", () => { + it("should return owner color for owner speaker", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + const color = resolver.getSpeakerColor("me"); + expect(color).toEqual(OWNER_COLOR); + }); + + it("should return owner color for alias", () => { + const config = createConfig({ + settings: { ...DEFAULT_SETTINGS, ownerAliases: ["John"] }, + }); + const resolver = new SpeakerResolver(config); + const color = resolver.getSpeakerColor("John"); + expect(color).toEqual(OWNER_COLOR); + }); + + it("should return custom owner color when configured", () => { + const config = createConfig({ + settings: { ...DEFAULT_SETTINGS, ownerBubbleColor: "#FF0000" }, + }); + const resolver = new SpeakerResolver(config); + const color = resolver.getSpeakerColor("me"); + expect(color.end).toBe("#FF0000"); + }); + + it("should prioritize frontmatter color over cached", () => { + const speakerConfigs = new Map(); + speakerConfigs.set("gandalf", { color: "#9CA3AF" }); + const config = createConfig({ speakerConfigs }); + const resolver = new SpeakerResolver(config); + const color = resolver.getSpeakerColor("Gandalf"); + expect(color.end).toBe("#9CA3AF"); + }); + + it("should cycle through palette for other speakers", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + const color1 = resolver.getSpeakerColor("Alice"); + const color2 = resolver.getSpeakerColor("Bob"); + expect(color1).toEqual(SPEAKER_COLORS[0]); + expect(color2).toEqual(SPEAKER_COLORS[1]); + }); + + it("should return same color for same speaker", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + const color1 = resolver.getSpeakerColor("Alice"); + const color2 = resolver.getSpeakerColor("Alice"); + expect(color1).toEqual(color2); + }); + + it("should be case-insensitive for speaker names", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + const color1 = resolver.getSpeakerColor("Alice"); + const color2 = resolver.getSpeakerColor("ALICE"); + expect(color1).toEqual(color2); + }); + }); + + describe("getSpeakerSide", () => { + it("should return right for owner speaker", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerSide("me")).toBe("right"); + }); + + it("should return left for non-owner speaker", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerSide("Gandalf")).toBe("left"); + }); + + it("should use sides config when present", () => { + const config = createConfig({ + sides: { + left: new Set(["gandalf"]), + right: new Set(["sauron"]), + }, + }); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerSide("Gandalf")).toBe("left"); + expect(resolver.getSpeakerSide("Sauron")).toBe("right"); + }); + + it("should prioritize sides config over owner check", () => { + const config = createConfig({ + sides: { + left: new Set(["me"]), + right: new Set(), + }, + }); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerSide("me")).toBe("left"); + }); + + it("should fall back to owner check when speaker not in sides", () => { + const config = createConfig({ + sides: { + left: new Set(["gandalf"]), + right: new Set(), + }, + }); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerSide("me")).toBe("right"); + }); + }); + + describe("getSpeakerIcon", () => { + it("should return null when no icon configured", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + expect(resolver.getSpeakerIcon("Gandalf")).toBeNull(); + }); + + it("should return icon when configured", () => { + const speakerConfigs = new Map(); + speakerConfigs.set("gandalf", { icon: { type: "emoji" as const, value: "🧙" } }); + const config = createConfig({ speakerConfigs }); + const resolver = new SpeakerResolver(config); + const icon = resolver.getSpeakerIcon("Gandalf"); + expect(icon?.type).toBe("emoji"); + expect(icon?.value).toBe("🧙"); + }); + }); + + describe("reset", () => { + it("should reset cached colors", () => { + const config = createConfig(); + const resolver = new SpeakerResolver(config); + resolver.getSpeakerColor("Alice"); + resolver.getSpeakerColor("Bob"); + resolver.reset(); + const color = resolver.getSpeakerColor("Charlie"); + expect(color).toEqual(SPEAKER_COLORS[0]); + }); + }); +}); diff --git a/src/__tests__/parser/DateSeparatorParser.test.ts b/src/__tests__/parser/DateSeparatorParser.test.ts new file mode 100644 index 0000000..0cf2a2d --- /dev/null +++ b/src/__tests__/parser/DateSeparatorParser.test.ts @@ -0,0 +1,67 @@ +import { parseDateSeparator } from "../../parser/DateSeparatorParser"; + +describe("parseDateSeparator", () => { + it("should parse --- YYYY-MM-DD --- format", () => { + const result = parseDateSeparator("--- 2024-01-15 ---"); + expect(result).not.toBeNull(); + expect(result!.type).toBe("date-separator"); + expect(result!.date.getFullYear()).toBe(2024); + expect(result!.date.getMonth()).toBe(0); // January is 0 + expect(result!.date.getDate()).toBe(15); + }); + + it("should parse --- January 15, 2024 --- format", () => { + const result = parseDateSeparator("--- January 15, 2024 ---"); + expect(result).not.toBeNull(); + expect(result!.type).toBe("date-separator"); + expect(result!.date.getFullYear()).toBe(2024); + expect(result!.date.getMonth()).toBe(0); + }); + + it("should parse --- February 28 2024 --- format (without comma)", () => { + const result = parseDateSeparator("--- February 28 2024 ---"); + expect(result).not.toBeNull(); + expect(result!.type).toBe("date-separator"); + expect(result!.date.getMonth()).toBe(1); + }); + + it("should handle case-insensitive month names", () => { + const result = parseDateSeparator("--- MARCH 1, 2024 ---"); + expect(result).not.toBeNull(); + expect(result!.date.getMonth()).toBe(2); + }); + + it("should handle whitespace around text", () => { + const result = parseDateSeparator(" --- 2024-01-15 --- "); + expect(result).not.toBeNull(); + }); + + it("should return null for partial matches", () => { + expect(parseDateSeparator("--- 2024-01-15")).toBeNull(); + expect(parseDateSeparator("2024-01-15 ---")).toBeNull(); + expect(parseDateSeparator("--- Hello ---")).toBeNull(); + }); + + it("should return null for invalid ISO dates", () => { + const result = parseDateSeparator("--- 2024-13-15 ---"); + expect(result).toBeNull(); + }); + + it("should return null for regular text", () => { + expect(parseDateSeparator("Hello world")).toBeNull(); + expect(parseDateSeparator("[[John]]: Hello")).toBeNull(); + }); + + it("should store raw text", () => { + const result = parseDateSeparator("--- 2024-01-15 ---"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe("--- 2024-01-15 ---"); + }); + + it("should format date for display", () => { + const result = parseDateSeparator("--- 2024-01-15 ---"); + expect(result).not.toBeNull(); + expect(result!.formattedDate).toBeTruthy(); + expect(result!.formattedDate.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/parser/FrontmatterParser.test.ts b/src/__tests__/parser/FrontmatterParser.test.ts new file mode 100644 index 0000000..4011325 --- /dev/null +++ b/src/__tests__/parser/FrontmatterParser.test.ts @@ -0,0 +1,178 @@ +import { parseFrontmatter } from "../../parser/FrontmatterParser"; + +describe("parseFrontmatter", () => { + it("should return empty config for null frontmatter", () => { + const result = parseFrontmatter(null); + expect(result.speakerConfigs.size).toBe(0); + expect(result.sides).toBeNull(); + }); + + it("should return empty config for non-object frontmatter", () => { + const result = parseFrontmatter("string"); + expect(result.speakerConfigs.size).toBe(0); + expect(result.sides).toBeNull(); + }); + + it("should return empty config for missing speech-bubbles key", () => { + const result = parseFrontmatter({ tags: ["transcript"] }); + expect(result.speakerConfigs.size).toBe(0); + expect(result.sides).toBeNull(); + }); + + describe("speaker colors", () => { + it("should parse simple speaker colors (string format)", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: "#9CA3AF", + Frodo: "#34D399", + }, + }, + }); + expect(result.speakerConfigs.get("gandalf")?.color).toBe("#9CA3AF"); + expect(result.speakerConfigs.get("frodo")?.color).toBe("#34D399"); + }); + + it("should parse speaker objects with color", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: { color: "#9CA3AF" }, + }, + }, + }); + expect(result.speakerConfigs.get("gandalf")?.color).toBe("#9CA3AF"); + }); + + it("should normalize speaker names to lowercase", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + GANDALF: "#9CA3AF", + }, + }, + }); + expect(result.speakerConfigs.has("gandalf")).toBe(true); + expect(result.speakerConfigs.has("GANDALF")).toBe(false); + }); + }); + + describe("speaker icons", () => { + it("should parse emoji icons", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: { icon: "🧙" }, + }, + }, + }); + const config = result.speakerConfigs.get("gandalf"); + expect(config?.icon?.type).toBe("emoji"); + expect(config?.icon?.value).toBe("🧙"); + }); + + it("should parse vault image paths", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: { icon: "[[avatars/gandalf.png]]" }, + }, + }, + }); + const config = result.speakerConfigs.get("gandalf"); + expect(config?.icon?.type).toBe("image"); + expect(config?.icon?.value).toBe("avatars/gandalf.png"); + }); + + it("should parse both color and icon", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: { color: "#9CA3AF", icon: "🧙" }, + }, + }, + }); + const config = result.speakerConfigs.get("gandalf"); + expect(config?.color).toBe("#9CA3AF"); + expect(config?.icon?.value).toBe("🧙"); + }); + }); + + describe("sides", () => { + it("should parse left and right sides", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + sides: { + left: ["Gandalf", "Frodo"], + right: ["Sauron"], + }, + }, + }); + expect(result.sides?.left.has("gandalf")).toBe(true); + expect(result.sides?.left.has("frodo")).toBe(true); + expect(result.sides?.right.has("sauron")).toBe(true); + }); + + it("should normalize side names to lowercase", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + sides: { + left: ["GANDALF"], + }, + }, + }); + expect(result.sides?.left.has("gandalf")).toBe(true); + }); + + it("should return null sides when empty", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + sides: {}, + }, + }); + expect(result.sides).toBeNull(); + }); + + it("should handle only left side", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + sides: { + left: ["Gandalf"], + }, + }, + }); + expect(result.sides?.left.has("gandalf")).toBe(true); + expect(result.sides?.right.size).toBe(0); + }); + + it("should handle only right side", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + sides: { + right: ["Sauron"], + }, + }, + }); + expect(result.sides?.left.size).toBe(0); + expect(result.sides?.right.has("sauron")).toBe(true); + }); + }); + + describe("combined config", () => { + it("should parse speakers and sides together", () => { + const result = parseFrontmatter({ + "speech-bubbles": { + speakers: { + Gandalf: { color: "#9CA3AF", icon: "🧙" }, + }, + sides: { + left: ["Gandalf"], + right: ["Sauron"], + }, + }, + }); + expect(result.speakerConfigs.get("gandalf")?.color).toBe("#9CA3AF"); + expect(result.sides?.left.has("gandalf")).toBe(true); + }); + }); +}); diff --git a/src/__tests__/parser/TimestampParser.test.ts b/src/__tests__/parser/TimestampParser.test.ts new file mode 100644 index 0000000..2dae93d --- /dev/null +++ b/src/__tests__/parser/TimestampParser.test.ts @@ -0,0 +1,107 @@ +import { parseTimestamp, formatTimestamp } from "../../parser/TimestampParser"; + +describe("parseTimestamp", () => { + it("should parse [HH:MM] format", () => { + const result = parseTimestamp("[14:32]: Hello"); + expect(result).not.toBeNull(); + expect(result!.timestamp.hours).toBe(14); + expect(result!.timestamp.minutes).toBe(32); + expect(result!.timestamp.seconds).toBeNull(); + expect(result!.remainingText).toBe(": Hello"); + }); + + it("should parse [HH:MM:SS] format", () => { + const result = parseTimestamp("[14:32:45]: Hello"); + expect(result).not.toBeNull(); + expect(result!.timestamp.hours).toBe(14); + expect(result!.timestamp.minutes).toBe(32); + expect(result!.timestamp.seconds).toBe(45); + expect(result!.remainingText).toBe(": Hello"); + }); + + it("should parse single-digit hours", () => { + const result = parseTimestamp("[9:05]: Hello"); + expect(result).not.toBeNull(); + expect(result!.timestamp.hours).toBe(9); + expect(result!.timestamp.minutes).toBe(5); + }); + + it("should handle leading whitespace", () => { + const result = parseTimestamp(" [14:32]: Hello"); + expect(result).not.toBeNull(); + expect(result!.timestamp.hours).toBe(14); + }); + + it("should return null for invalid hours (25:00)", () => { + const result = parseTimestamp("[25:00]: Hello"); + expect(result).toBeNull(); + }); + + it("should return null for invalid minutes (14:60)", () => { + const result = parseTimestamp("[14:60]: Hello"); + expect(result).toBeNull(); + }); + + it("should return null for invalid seconds (14:30:60)", () => { + const result = parseTimestamp("[14:30:60]: Hello"); + expect(result).toBeNull(); + }); + + it("should return null for text without timestamp", () => { + const result = parseTimestamp(": Hello"); + expect(result).toBeNull(); + }); + + it("should return null for malformed timestamp", () => { + const result = parseTimestamp("[14]: Hello"); + expect(result).toBeNull(); + }); + + it("should store raw timestamp string", () => { + const result = parseTimestamp("[14:32]: Hello"); + expect(result).not.toBeNull(); + expect(result!.timestamp.raw).toBe("[14:32]"); + }); +}); + +describe("formatTimestamp", () => { + it("should format HH:MM timestamp", () => { + const result = formatTimestamp({ + raw: "[14:32]", + hours: 14, + minutes: 32, + seconds: null, + }); + expect(result).toBe("14:32"); + }); + + it("should format HH:MM:SS timestamp", () => { + const result = formatTimestamp({ + raw: "[14:32:45]", + hours: 14, + minutes: 32, + seconds: 45, + }); + expect(result).toBe("14:32:45"); + }); + + it("should pad single digits", () => { + const result = formatTimestamp({ + raw: "[9:05]", + hours: 9, + minutes: 5, + seconds: null, + }); + expect(result).toBe("09:05"); + }); + + it("should pad seconds", () => { + const result = formatTimestamp({ + raw: "[9:05:03]", + hours: 9, + minutes: 5, + seconds: 3, + }); + expect(result).toBe("09:05:03"); + }); +}); diff --git a/src/colorUtils.ts b/src/colorUtils.ts index 5d35f5e..467857a 100644 --- a/src/colorUtils.ts +++ b/src/colorUtils.ts @@ -1,3 +1,5 @@ +import { SpeakerColor } from "./types"; + export function darkenColor(hex: string): string { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) { @@ -11,6 +13,26 @@ export function darkenColor(hex: string): string { return `rgb(${r}, ${g}, ${b})`; } +export function lightenColor(hex: string, amount: number): string { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) { + return hex; + } + + const r = Math.min(255, Math.round(parseInt(result[1], 16) + (255 - parseInt(result[1], 16)) * amount)); + const g = Math.min(255, Math.round(parseInt(result[2], 16) + (255 - parseInt(result[2], 16)) * amount)); + const b = Math.min(255, Math.round(parseInt(result[3], 16) + (255 - parseInt(result[3], 16)) * amount)); + + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +export function hexToSpeakerColor(hex: string): SpeakerColor { + return { + start: lightenColor(hex, 0.2), + end: hex, + }; +} + export function isOwner(speakerName: string, ownerName: string, ownerAliases: string[]): boolean { const normalizedName = speakerName.toLowerCase().trim(); const normalizedOwner = ownerName.toLowerCase().trim(); @@ -28,11 +50,6 @@ export function isOwner(speakerName: string, ownerName: string, ownerAliases: st return false; } -export interface SpeakerColor { - start: string; - end: string; -} - export const SPEAKER_COLORS: SpeakerColor[] = [ { start: "#E0E7FF", end: "#C7D2FE" }, // Indigo tint { start: "#DCFCE7", end: "#BBF7D0" }, // Emerald tint diff --git a/src/config/ConfigResolver.ts b/src/config/ConfigResolver.ts new file mode 100644 index 0000000..1b02386 --- /dev/null +++ b/src/config/ConfigResolver.ts @@ -0,0 +1,12 @@ +import { SpeechBubblesSettings, TranscriptConfig } from "../types"; +import { parseFrontmatter } from "../parser/FrontmatterParser"; + +export function resolveConfig(settings: SpeechBubblesSettings, frontmatter: unknown): TranscriptConfig { + const fmConfig = parseFrontmatter(frontmatter); + + return { + settings, + speakerConfigs: fmConfig.speakerConfigs, + sides: fmConfig.sides, + }; +} diff --git a/src/config/SpeakerResolver.ts b/src/config/SpeakerResolver.ts new file mode 100644 index 0000000..4a371e3 --- /dev/null +++ b/src/config/SpeakerResolver.ts @@ -0,0 +1,68 @@ +import { SpeakerColor, SpeakerIcon, TranscriptConfig } from "../types"; +import { isOwner, SPEAKER_COLORS, OWNER_COLOR, hexToSpeakerColor } from "../colorUtils"; + +export class SpeakerResolver { + private colorIndex = 0; + private cachedColors: Map = new Map(); + + constructor(private config: TranscriptConfig) {} + + getSpeakerColor(speakerName: string): SpeakerColor { + const normalized = speakerName.toLowerCase().trim(); + + const speakerConfig = this.config.speakerConfigs.get(normalized); + if (speakerConfig?.color) { + return hexToSpeakerColor(speakerConfig.color); + } + + if (this.isOwnerSpeaker(speakerName)) { + const ownerColor = this.config.settings.ownerBubbleColor; + if (ownerColor) { + return hexToSpeakerColor(ownerColor); + } + return OWNER_COLOR; + } + + if (!this.cachedColors.has(normalized)) { + const color = SPEAKER_COLORS[this.colorIndex % SPEAKER_COLORS.length]; + this.cachedColors.set(normalized, color); + this.colorIndex++; + } + + return this.cachedColors.get(normalized) ?? SPEAKER_COLORS[0]; + } + + getSpeakerSide(speakerName: string): "left" | "right" { + const normalized = speakerName.toLowerCase().trim(); + + if (this.config.sides) { + if (this.config.sides.right.has(normalized)) { + return "right"; + } + if (this.config.sides.left.has(normalized)) { + return "left"; + } + } + + if (this.isOwnerSpeaker(speakerName)) { + return "right"; + } + + return "left"; + } + + getSpeakerIcon(speakerName: string): SpeakerIcon | null { + const normalized = speakerName.toLowerCase().trim(); + const speakerConfig = this.config.speakerConfigs.get(normalized); + return speakerConfig?.icon ?? null; + } + + isOwnerSpeaker(speakerName: string): boolean { + return isOwner(speakerName, this.config.settings.ownerName, this.config.settings.ownerAliases); + } + + reset(): void { + this.colorIndex = 0; + this.cachedColors.clear(); + } +} diff --git a/src/main.ts b/src/main.ts index 2d28925..61e5b5d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,80 +1,51 @@ import { - App, MarkdownView, Plugin, - PluginSettingTab, - Setting, MarkdownPostProcessorContext, normalizePath, parseFrontMatterTags, TFile, } from "obsidian"; -import { darkenColor, isOwner, SPEAKER_COLORS, OWNER_COLOR, SpeakerColor } from "./colorUtils"; - -interface SpeechBubblesSettings { - ownerName: string; - ownerAliases: string[]; - debugLogging: boolean; -} +import { SpeechBubblesSettings, DEFAULT_SETTINGS } from "./types"; +import { resolveConfig } from "./config/ConfigResolver"; +import { SpeakerResolver } from "./config/SpeakerResolver"; +import { BubbleRenderer } from "./renderer/BubbleRenderer"; +import { createDateSeparator } from "./renderer/DateSeparatorRenderer"; +import { parseTranscriptLine, splitNodesByLineBreaks } from "./parser/TranscriptParser"; +import { SpeechBubblesSettingTab } from "./settings/SettingsTab"; const TRANSCRIPT_TAG = "transcript"; -const DEFAULT_SETTINGS: SpeechBubblesSettings = { - ownerName: "me", - ownerAliases: [], - debugLogging: false, -}; - export default class SpeechBubblesPlugin extends Plugin { settings: SpeechBubblesSettings; - private speakerColorMap: Map = new Map(); - private colorIndex = 0; async onload() { await this.loadSettings(); - // Register the markdown post processor this.registerMarkdownPostProcessor((el: HTMLElement, ctx: MarkdownPostProcessorContext) => { this.processTranscription(el, ctx); }); - // Add settings tab - this.addSettingTab(new SpeechBubblesSettingTab(this.app, this)); - } - - onunload() { - this.speakerColorMap.clear(); + this.addSettingTab( + new SpeechBubblesSettingTab( + this.app, + { + getSettings: () => this.settings, + saveSettings: async (partial: Partial) => { + this.settings = { ...this.settings, ...partial }; + await this.saveData(this.settings); + this.refreshTranscriptViews(); + }, + }, + this + ) + ); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as SpeechBubblesSettings); } - async saveSettings() { - await this.saveData(this.settings); - this.refreshTranscriptViews(); - } - - private checkIsOwner(speakerName: string): boolean { - return isOwner(speakerName, this.settings.ownerName, this.settings.ownerAliases); - } - - private getSpeakerColor(speakerName: string): SpeakerColor { - if (this.checkIsOwner(speakerName)) { - return OWNER_COLOR; - } - - const normalizedName = speakerName.toLowerCase().trim(); - - if (!this.speakerColorMap.has(normalizedName)) { - const color = SPEAKER_COLORS[this.colorIndex % SPEAKER_COLORS.length]; - this.speakerColorMap.set(normalizedName, color); - this.colorIndex++; - } - - return this.speakerColorMap.get(normalizedName) ?? SPEAKER_COLORS[0]; - } - private processTranscription(el: HTMLElement, ctx: MarkdownPostProcessorContext) { const filePath = normalizePath(ctx.sourcePath); @@ -94,9 +65,22 @@ export default class SpeechBubblesPlugin extends Plugin { return; } + const frontmatter = this.getFileFrontmatter(filePath); + const config = resolveConfig(this.settings, frontmatter); + const resolver = new SpeakerResolver(config); + const bubbleRenderer = new BubbleRenderer(this.app, resolver, this.settings); + let hasTranscription = false; const container = document.createElement("div"); container.className = "speech-bubbles-container"; + + if (this.settings.compactMode) { + container.classList.add("speech-bubbles-compact"); + } + + container.style.setProperty("--speech-bubbles-max-width", `${this.settings.bubbleMaxWidth}%`); + container.style.setProperty("--speech-bubbles-bubble-radius", `${this.settings.bubbleRadius}px`); + let regularLines: Node[][] = []; const flushRegularLines = () => { @@ -120,25 +104,29 @@ export default class SpeechBubblesPlugin extends Plugin { for (const block of blocks) { if (block.tagName === "P") { - const lineNodes = this.splitNodesByLineBreaks(Array.from(block.childNodes)); + const lineNodes = splitNodesByLineBreaks(Array.from(block.childNodes)); for (const line of lineNodes) { - const transcription = this.extractTranscriptionFromNodes(line); - if (!transcription) { - regularLines.push(line); + const parsed = parseTranscriptLine(line); + + if (parsed.type === "regular-text") { + regularLines.push(parsed.nodes); continue; } hasTranscription = true; flushRegularLines(); - const bubble = this.createBubbleFromNodes(transcription.speakerName, transcription.messageNodes); - container.appendChild(bubble); + if (parsed.type === "date-separator") { + container.appendChild(createDateSeparator(parsed)); + } else if (parsed.type === "bubble") { + container.appendChild(bubbleRenderer.createBubble(parsed)); + } } continue; } - const transcription = this.extractTranscriptionFromNodes(Array.from(block.childNodes)); - if (!transcription) { + const parsed = parseTranscriptLine(Array.from(block.childNodes)); + if (parsed.type === "regular-text") { flushRegularLines(); container.appendChild(block); continue; @@ -147,8 +135,11 @@ export default class SpeechBubblesPlugin extends Plugin { hasTranscription = true; flushRegularLines(); - const bubble = this.createBubbleFromNodes(transcription.speakerName, transcription.messageNodes); - container.appendChild(bubble); + if (parsed.type === "date-separator") { + container.appendChild(createDateSeparator(parsed)); + } else if (parsed.type === "bubble") { + container.appendChild(bubbleRenderer.createBubble(parsed)); + } } flushRegularLines(); @@ -168,154 +159,7 @@ export default class SpeechBubblesPlugin extends Plugin { } } - private createBubbleFromNodes(speakerName: string, messageNodes: Node[]): HTMLElement { - const isOwnerBubble = this.checkIsOwner(speakerName); - const color = this.getSpeakerColor(speakerName); - - const wrapper = document.createElement("div"); - wrapper.className = `speech-bubbles-wrapper ${isOwnerBubble ? "speech-bubbles-owner" : "speech-bubbles-other"}`; - - const bubble = document.createElement("div"); - bubble.className = `speech-bubbles-bubble ${isOwnerBubble ? "speech-bubbles-owner" : "speech-bubbles-other"}`; - bubble.style.setProperty("--speech-bubbles-color-start", color.start); - bubble.style.setProperty("--speech-bubbles-color-end", color.end); - bubble.style.setProperty( - "--speech-bubbles-name-color", - isOwnerBubble ? "rgba(255, 255, 255, 0.9)" : darkenColor(color.end) - ); - - const nameLabel = document.createElement("div"); - nameLabel.className = "speech-bubbles-name"; - nameLabel.textContent = speakerName; - bubble.appendChild(nameLabel); - - const messageEl = document.createElement("div"); - messageEl.className = "speech-bubbles-message"; - for (const node of messageNodes) { - messageEl.appendChild(node); - } - bubble.appendChild(messageEl); - - wrapper.appendChild(bubble); - - return wrapper; - } - - private extractTranscriptionFromNodes(nodes: Node[]): { speakerName: string; messageNodes: Node[] } | null { - let index = 0; - - while (index < nodes.length && this.isWhitespaceNode(nodes[index])) { - index++; - } - - if (index >= nodes.length) { - return null; - } - - const firstNode = nodes[index]; - if (!this.isInternalLinkNode(firstNode)) { - return null; - } - - const speakerName = firstNode.textContent?.trim() ?? ""; - if (!speakerName) { - return null; - } - - let colonFound = false; - const messageNodes: Node[] = []; - - for (let i = index + 1; i < nodes.length; i++) { - const node = nodes[i]; - - if (!colonFound) { - if (node.nodeType !== Node.TEXT_NODE) { - return null; - } - - const text = node.textContent ?? ""; - const colonIndex = text.indexOf(":"); - if (colonIndex === -1) { - if (text.trim().length === 0) { - continue; - } - return null; - } - - colonFound = true; - const afterColon = text.slice(colonIndex + 1).replace(/^\s+/, ""); - if (afterColon.length > 0) { - messageNodes.push(document.createTextNode(afterColon)); - } - continue; - } - - messageNodes.push(node); - } - - if (!colonFound) { - return null; - } - - return { speakerName, messageNodes }; - } - - private splitNodesByLineBreaks(nodes: Node[]): Node[][] { - const lines: Node[][] = []; - let current: Node[] = []; - - const pushLine = () => { - if (current.length > 0) { - lines.push(current); - } - current = []; - }; - - for (const node of nodes) { - if (node instanceof HTMLBRElement) { - pushLine(); - continue; - } - - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? ""; - if (text.includes("\n")) { - const parts = text.split("\n"); - for (let i = 0; i < parts.length; i++) { - if (parts[i].length > 0) { - current.push(document.createTextNode(parts[i])); - } - if (i < parts.length - 1) { - pushLine(); - } - } - continue; - } - } - - current.push(node); - } - - if (current.length > 0) { - lines.push(current); - } - - return lines; - } - - private isInternalLinkNode(node: Node): node is HTMLElement { - return node instanceof HTMLElement && node.classList.contains("internal-link"); - } - - private isWhitespaceNode(node: Node): boolean { - return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0; - } - private isSpeechBubblesEnabled(filePath: string): boolean { - return this.isEnabledByFrontmatter(filePath); - } - - private isEnabledByFrontmatter(filePath: string): boolean { const file = this.app.vault.getAbstractFileByPath(filePath); if (!(file instanceof TFile)) { return false; @@ -330,6 +174,16 @@ export default class SpeechBubblesPlugin extends Plugin { return tags.some(tag => this.normalizeTag(tag) === TRANSCRIPT_TAG); } + private getFileFrontmatter(filePath: string): unknown { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) { + return null; + } + + const cache = this.app.metadataCache.getFileCache(file); + return cache?.frontmatter ?? null; + } + private normalizeTag(tag: string): string { return tag.replace(/^#/, "").trim().toLowerCase(); } @@ -372,89 +226,3 @@ export default class SpeechBubblesPlugin extends Plugin { } } } - -class SpeechBubblesSettingTab extends PluginSettingTab { - plugin: SpeechBubblesPlugin; - - constructor(app: App, plugin: SpeechBubblesPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - - containerEl.empty(); - - containerEl.createEl("h2", { text: "Speech Bubbles Settings" }); - - new Setting(containerEl) - .setName("Your name") - .setDesc( - "The name used in transcripts to identify you. Messages from this person will appear on the right side with blue bubbles." - ) - .addText(text => - text - .setPlaceholder("me") - .setValue(this.plugin.settings.ownerName) - .onChange(async value => { - this.plugin.settings.ownerName = value; - await this.plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName("Aliases") - .setDesc("Other names that should also be treated as you (comma-separated). For example: 'John, John Smith, JS'") - .addText(text => - text - .setPlaceholder("John, John Smith") - .setValue(this.plugin.settings.ownerAliases.join(", ")) - .onChange(async value => { - this.plugin.settings.ownerAliases = value - .split(",") - .map(s => s.trim()) - .filter(s => s.length > 0); - await this.plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName("Enable debug logging") - .setDesc("Log toggle and render details to the developer console for troubleshooting.") - .addToggle(toggle => - toggle.setValue(this.plugin.settings.debugLogging).onChange(async value => { - this.plugin.settings.debugLogging = value; - await this.plugin.saveSettings(); - }) - ); - - containerEl.createEl("h3", { text: "Usage" }); - - const usageDiv = containerEl.createEl("div", { - cls: "speech-bubbles-usage", - }); - - usageDiv.createEl("p", { - text: "To use speech bubbles in your transcript notes:", - }); - - const list = usageDiv.createEl("ol"); - list.createEl("li", { - text: "Format your transcript with lines like: [[Speaker Name]]: Message text", - }); - list.createEl("li", { - text: "Add the transcript tag to the note frontmatter to enable speech bubbles", - }); - list.createEl("li", { - text: "Switch to Reading view to see the bubbles", - }); - - usageDiv.createEl("p", { text: "Example:" }); - - const codeBlock = usageDiv.createEl("pre"); - codeBlock.createEl("code", { - text: "[[John Smith]]: Hello!\n[[me]]: Hi there!\n[[John Smith]]: How are you doing?", - }); - } -} diff --git a/src/parser/DateSeparatorParser.ts b/src/parser/DateSeparatorParser.ts new file mode 100644 index 0000000..e1b525d --- /dev/null +++ b/src/parser/DateSeparatorParser.ts @@ -0,0 +1,46 @@ +import { ParsedDateSeparator } from "../types"; + +const ISO_DATE_REGEX = /^---\s+(\d{4}-\d{2}-\d{2})\s+---$/; +const NATURAL_DATE_REGEX = + /^---\s+((?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4})\s+---$/i; + +export function parseDateSeparator(text: string): ParsedDateSeparator | null { + const trimmed = text.trim(); + + const isoMatch = ISO_DATE_REGEX.exec(trimmed); + if (isoMatch) { + const date = new Date(isoMatch[1] + "T00:00:00"); + if (!isNaN(date.getTime())) { + return { + type: "date-separator", + raw: trimmed, + date, + formattedDate: formatDate(date), + }; + } + } + + const naturalMatch = NATURAL_DATE_REGEX.exec(trimmed); + if (naturalMatch) { + const date = new Date(naturalMatch[1]); + if (!isNaN(date.getTime())) { + return { + type: "date-separator", + raw: trimmed, + date, + formattedDate: formatDate(date), + }; + } + } + + return null; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); +} diff --git a/src/parser/FrontmatterParser.ts b/src/parser/FrontmatterParser.ts new file mode 100644 index 0000000..179d128 --- /dev/null +++ b/src/parser/FrontmatterParser.ts @@ -0,0 +1,99 @@ +import { SpeechBubblesFrontmatter, SpeakerConfig, SpeakerIcon, SidesConfig } from "../types"; + +export interface ParsedFrontmatterConfig { + speakerConfigs: Map; + sides: SidesConfig | null; +} + +export function parseFrontmatter(frontmatter: unknown): ParsedFrontmatterConfig { + const result: ParsedFrontmatterConfig = { + speakerConfigs: new Map(), + sides: null, + }; + + if (!frontmatter || typeof frontmatter !== "object") { + return result; + } + + const fm = frontmatter as Record; + const speechBubbles = fm["speech-bubbles"]; + + if (!speechBubbles || typeof speechBubbles !== "object") { + return result; + } + + const config = speechBubbles as SpeechBubblesFrontmatter; + + if (config.speakers) { + result.speakerConfigs = parseSpeakers(config.speakers); + } + + if (config.sides) { + result.sides = parseSides(config.sides); + } + + return result; +} + +function parseSpeakers( + speakers: Record +): Map { + const result = new Map(); + + for (const [name, value] of Object.entries(speakers)) { + const normalizedName = name.toLowerCase().trim(); + const config: SpeakerConfig = {}; + + if (typeof value === "string") { + config.color = value; + } else if (typeof value === "object" && value !== null) { + if (typeof value.color === "string") { + config.color = value.color; + } + if (typeof value.icon === "string") { + config.icon = parseIcon(value.icon); + } + } + + if (config.color || config.icon) { + result.set(normalizedName, config); + } + } + + return result; +} + +function parseIcon(icon: string): SpeakerIcon { + const imageMatch = /^\[\[(.+)\]\]$/.exec(icon); + if (imageMatch) { + return { type: "image", value: imageMatch[1] }; + } + return { type: "emoji", value: icon }; +} + +function parseSides(sides: { left?: string[]; right?: string[] }): SidesConfig | null { + const left = new Set(); + const right = new Set(); + + if (Array.isArray(sides.left)) { + for (const name of sides.left) { + if (typeof name === "string") { + left.add(name.toLowerCase().trim()); + } + } + } + + if (Array.isArray(sides.right)) { + for (const name of sides.right) { + if (typeof name === "string") { + right.add(name.toLowerCase().trim()); + } + } + } + + if (left.size === 0 && right.size === 0) { + return null; + } + + return { left, right }; +} diff --git a/src/parser/TimestampParser.ts b/src/parser/TimestampParser.ts new file mode 100644 index 0000000..f5eaa59 --- /dev/null +++ b/src/parser/TimestampParser.ts @@ -0,0 +1,45 @@ +import { ParsedTimestamp } from "../types"; + +const TIMESTAMP_REGEX = /^\s*\[(\d{1,2}):(\d{2})(?::(\d{2}))?\]/; + +export interface TimestampParseResult { + timestamp: ParsedTimestamp; + remainingText: string; +} + +export function parseTimestamp(text: string): TimestampParseResult | null { + const match = TIMESTAMP_REGEX.exec(text); + if (!match) { + return null; + } + + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + const seconds = match[3] ? parseInt(match[3], 10) : null; + + if (hours > 23 || minutes > 59 || (seconds !== null && seconds > 59)) { + return null; + } + + return { + timestamp: { + raw: match[0].trim(), + hours, + minutes, + seconds, + }, + remainingText: text.slice(match[0].length), + }; +} + +export function formatTimestamp(timestamp: ParsedTimestamp): string { + const hours = String(timestamp.hours).padStart(2, "0"); + const minutes = String(timestamp.minutes).padStart(2, "0"); + + if (timestamp.seconds !== null) { + const seconds = String(timestamp.seconds).padStart(2, "0"); + return `${hours}:${minutes}:${seconds}`; + } + + return `${hours}:${minutes}`; +} diff --git a/src/parser/TranscriptParser.ts b/src/parser/TranscriptParser.ts new file mode 100644 index 0000000..3a158fe --- /dev/null +++ b/src/parser/TranscriptParser.ts @@ -0,0 +1,163 @@ +import { ParsedBubble, ParsedLine, ParsedRegularText, ParsedTimestamp } from "../types"; +import { parseDateSeparator } from "./DateSeparatorParser"; +import { parseTimestamp } from "./TimestampParser"; + +export function parseTranscriptLine(nodes: Node[]): ParsedLine { + if (nodes.length === 1 && nodes[0].nodeType === Node.TEXT_NODE) { + const dateSeparator = parseDateSeparator(nodes[0].textContent ?? ""); + if (dateSeparator) { + return dateSeparator; + } + } + + const bubble = extractBubbleFromNodes(nodes); + if (bubble) { + return bubble; + } + + return { + type: "regular-text", + nodes: nodes, + } as ParsedRegularText; +} + +function extractBubbleFromNodes(nodes: Node[]): ParsedBubble | null { + let index = 0; + + while (index < nodes.length && isWhitespaceNode(nodes[index])) { + index++; + } + + if (index >= nodes.length) { + return null; + } + + const firstNode = nodes[index]; + if (!isInternalLinkNode(firstNode)) { + return null; + } + + const speakerName = firstNode.textContent?.trim() ?? ""; + if (!speakerName) { + return null; + } + + let timestamp: ParsedTimestamp | null = null; + let colonFound = false; + const messageNodes: Node[] = []; + + for (let i = index + 1; i < nodes.length; i++) { + const node = nodes[i]; + + if (!colonFound) { + if (node.nodeType !== Node.TEXT_NODE) { + return null; + } + + const text = node.textContent ?? ""; + + if (!timestamp) { + const timestampResult = parseTimestamp(text); + if (timestampResult) { + timestamp = timestampResult.timestamp; + const remaining = timestampResult.remainingText; + const colonIndex = remaining.indexOf(":"); + if (colonIndex === -1) { + if (remaining.trim().length === 0) { + continue; + } + return null; + } + colonFound = true; + const afterColon = remaining.slice(colonIndex + 1).replace(/^\s+/, ""); + if (afterColon.length > 0) { + messageNodes.push(document.createTextNode(afterColon)); + } + continue; + } + } + + const colonIndex = text.indexOf(":"); + if (colonIndex === -1) { + if (text.trim().length === 0) { + continue; + } + return null; + } + + colonFound = true; + const afterColon = text.slice(colonIndex + 1).replace(/^\s+/, ""); + if (afterColon.length > 0) { + messageNodes.push(document.createTextNode(afterColon)); + } + continue; + } + + messageNodes.push(node); + } + + if (!colonFound) { + return null; + } + + return { + type: "bubble", + speaker: { + name: speakerName, + normalizedName: speakerName.toLowerCase().trim(), + }, + timestamp, + messageNodes, + }; +} + +function isInternalLinkNode(node: Node): node is HTMLElement { + return node instanceof HTMLElement && node.classList.contains("internal-link"); +} + +function isWhitespaceNode(node: Node): boolean { + return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0; +} + +export function splitNodesByLineBreaks(nodes: Node[]): Node[][] { + const lines: Node[][] = []; + let current: Node[] = []; + + const pushLine = () => { + if (current.length > 0) { + lines.push(current); + } + current = []; + }; + + for (const node of nodes) { + if (node instanceof HTMLBRElement) { + pushLine(); + continue; + } + + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + if (text.includes("\n")) { + const parts = text.split("\n"); + for (let i = 0; i < parts.length; i++) { + if (parts[i].length > 0) { + current.push(document.createTextNode(parts[i])); + } + if (i < parts.length - 1) { + pushLine(); + } + } + continue; + } + } + + current.push(node); + } + + if (current.length > 0) { + lines.push(current); + } + + return lines; +} diff --git a/src/renderer/BubbleRenderer.ts b/src/renderer/BubbleRenderer.ts new file mode 100644 index 0000000..691ed69 --- /dev/null +++ b/src/renderer/BubbleRenderer.ts @@ -0,0 +1,88 @@ +import { App } from "obsidian"; +import { ParsedBubble, SpeechBubblesSettings, SpeakerIcon } from "../types"; +import { darkenColor } from "../colorUtils"; +import { SpeakerResolver } from "../config/SpeakerResolver"; +import { formatTimestamp } from "../parser/TimestampParser"; + +export class BubbleRenderer { + constructor( + private app: App, + private resolver: SpeakerResolver, + private settings: SpeechBubblesSettings + ) {} + + createBubble(bubble: ParsedBubble): HTMLElement { + const speakerName = bubble.speaker.name; + const side = this.resolver.getSpeakerSide(speakerName); + const isOwnerSide = side === "right"; + const color = this.resolver.getSpeakerColor(speakerName); + const icon = this.resolver.getSpeakerIcon(speakerName); + + const wrapper = document.createElement("div"); + wrapper.className = `speech-bubbles-wrapper ${isOwnerSide ? "speech-bubbles-owner" : "speech-bubbles-other"}`; + + const bubbleEl = document.createElement("div"); + bubbleEl.className = `speech-bubbles-bubble ${isOwnerSide ? "speech-bubbles-owner" : "speech-bubbles-other"}`; + bubbleEl.style.setProperty("--speech-bubbles-color-start", color.start); + bubbleEl.style.setProperty("--speech-bubbles-color-end", color.end); + bubbleEl.style.setProperty( + "--speech-bubbles-name-color", + isOwnerSide ? "rgba(255, 255, 255, 0.9)" : darkenColor(color.end) + ); + + if (this.settings.showSpeakerNames || icon || bubble.timestamp) { + const headerEl = document.createElement("div"); + headerEl.className = "speech-bubbles-header"; + + if (icon) { + headerEl.appendChild(this.createAvatarElement(icon)); + } + + if (this.settings.showSpeakerNames) { + const nameLabel = document.createElement("span"); + nameLabel.className = "speech-bubbles-name"; + nameLabel.textContent = speakerName; + headerEl.appendChild(nameLabel); + } + + if (bubble.timestamp) { + const timestampEl = document.createElement("span"); + timestampEl.className = "speech-bubbles-timestamp"; + timestampEl.textContent = formatTimestamp(bubble.timestamp); + headerEl.appendChild(timestampEl); + } + + bubbleEl.appendChild(headerEl); + } + + const messageEl = document.createElement("div"); + messageEl.className = "speech-bubbles-message"; + for (const node of bubble.messageNodes) { + messageEl.appendChild(node); + } + bubbleEl.appendChild(messageEl); + + wrapper.appendChild(bubbleEl); + + return wrapper; + } + + private createAvatarElement(icon: SpeakerIcon): HTMLElement { + const avatarEl = document.createElement("span"); + avatarEl.className = "speech-bubbles-avatar"; + + if (icon.type === "emoji") { + avatarEl.classList.add("speech-bubbles-avatar-emoji"); + avatarEl.textContent = icon.value; + } else { + avatarEl.classList.add("speech-bubbles-avatar-image"); + const img = document.createElement("img"); + img.src = this.app.vault.adapter.getResourcePath(icon.value); + img.alt = ""; + img.className = "speech-bubbles-avatar-img"; + avatarEl.appendChild(img); + } + + return avatarEl; + } +} diff --git a/src/renderer/DateSeparatorRenderer.ts b/src/renderer/DateSeparatorRenderer.ts new file mode 100644 index 0000000..fa372a2 --- /dev/null +++ b/src/renderer/DateSeparatorRenderer.ts @@ -0,0 +1,14 @@ +import { ParsedDateSeparator } from "../types"; + +export function createDateSeparator(dateSeparator: ParsedDateSeparator): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "speech-bubbles-date-separator"; + + const pill = document.createElement("span"); + pill.className = "speech-bubbles-date-separator-pill"; + pill.textContent = dateSeparator.formattedDate; + + wrapper.appendChild(pill); + + return wrapper; +} diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts new file mode 100644 index 0000000..6315588 --- /dev/null +++ b/src/settings/SettingsTab.ts @@ -0,0 +1,167 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import { SpeechBubblesSettings } from "../types"; + +export interface SettingsTabCallbacks { + getSettings(): SpeechBubblesSettings; + saveSettings(settings: Partial): Promise; +} + +export class SpeechBubblesSettingTab extends PluginSettingTab { + constructor( + app: App, + private callbacks: SettingsTabCallbacks, + plugin: { manifest: { id: string } } + ) { + super(app, plugin as never); + } + + display(): void { + const { containerEl } = this; + const settings = this.callbacks.getSettings(); + + containerEl.empty(); + + containerEl.createEl("h2", { text: "Speech bubbles settings" }); + + new Setting(containerEl) + .setName("Your name") + .setDesc( + "The name used in transcripts to identify you. Messages from this person will appear on the right side with blue bubbles." + ) + .addText(text => + text + .setPlaceholder("me") + .setValue(settings.ownerName) + .onChange(async value => { + await this.callbacks.saveSettings({ ownerName: value }); + }) + ); + + new Setting(containerEl) + .setName("Aliases") + .setDesc("Other names that should also be treated as you (comma-separated). For example: 'John, John Smith, JS'") + .addText(text => + text + .setPlaceholder("John, John Smith") + .setValue(settings.ownerAliases.join(", ")) + .onChange(async value => { + const aliases = value + .split(",") + .map(s => s.trim()) + .filter(s => s.length > 0); + await this.callbacks.saveSettings({ ownerAliases: aliases }); + }) + ); + + containerEl.createEl("h3", { text: "Appearance" }); + + new Setting(containerEl) + .setName("Maximum bubble width") + .setDesc("Maximum width of speech bubbles as a percentage of the container width (10-100).") + .addSlider(slider => + slider + .setLimits(10, 100, 5) + .setValue(settings.bubbleMaxWidth) + .setDynamicTooltip() + .onChange(async value => { + await this.callbacks.saveSettings({ bubbleMaxWidth: value }); + }) + ); + + new Setting(containerEl) + .setName("Bubble corner radius") + .setDesc("Corner radius of speech bubbles in pixels (0-30).") + .addSlider(slider => + slider + .setLimits(0, 30, 1) + .setValue(settings.bubbleRadius) + .setDynamicTooltip() + .onChange(async value => { + await this.callbacks.saveSettings({ bubbleRadius: value }); + }) + ); + + new Setting(containerEl) + .setName("Show speaker names") + .setDesc("Display the speaker name above each bubble.") + .addToggle(toggle => + toggle.setValue(settings.showSpeakerNames).onChange(async value => { + await this.callbacks.saveSettings({ showSpeakerNames: value }); + }) + ); + + new Setting(containerEl) + .setName("Compact mode") + .setDesc("Use smaller spacing and font sizes for a more compact layout.") + .addToggle(toggle => + toggle.setValue(settings.compactMode).onChange(async value => { + await this.callbacks.saveSettings({ compactMode: value }); + }) + ); + + new Setting(containerEl) + .setName("Your bubble color") + .setDesc("Custom color for your speech bubbles (leave empty for default indigo).") + .addText(text => + text + .setPlaceholder("#6366F1") + .setValue(settings.ownerBubbleColor ?? "") + .onChange(async value => { + const color = value.trim() || null; + await this.callbacks.saveSettings({ ownerBubbleColor: color }); + }) + ); + + containerEl.createEl("h3", { text: "Debug" }); + + new Setting(containerEl) + .setName("Enable debug logging") + .setDesc("Log toggle and render details to the developer console for troubleshooting.") + .addToggle(toggle => + toggle.setValue(settings.debugLogging).onChange(async value => { + await this.callbacks.saveSettings({ debugLogging: value }); + }) + ); + + containerEl.createEl("h3", { text: "Usage" }); + + const usageDiv = containerEl.createEl("div", { + cls: "speech-bubbles-usage", + }); + + usageDiv.createEl("p", { + text: "To use speech bubbles in your transcript notes:", + }); + + const list = usageDiv.createEl("ol"); + list.createEl("li", { + text: "Format your transcript with lines like: [[Speaker Name]]: Message text", + }); + list.createEl("li", { + text: "Add the transcript tag to the note frontmatter to enable speech bubbles", + }); + list.createEl("li", { + text: "Switch to Reading view to see the bubbles", + }); + + usageDiv.createEl("p", { text: "Example:" }); + + const codeBlock = usageDiv.createEl("pre"); + codeBlock.createEl("code", { + text: "[[John Smith]]: Hello!\n[[me]]: Hi there!\n[[John Smith]]: How are you doing?", + }); + + usageDiv.createEl("p", { text: "Advanced features:" }); + + const advancedList = usageDiv.createEl("ul"); + advancedList.createEl("li", { + text: "Timestamps: [[John]] [14:32]: Hello!", + }); + advancedList.createEl("li", { + text: "Date separators: --- 2024-01-15 ---", + }); + advancedList.createEl("li", { + text: "Per-speaker colors and icons via frontmatter (see README)", + }); + } +} diff --git a/src/styles.src.css b/src/styles.src.css index ad8123a..aba16df 100644 --- a/src/styles.src.css +++ b/src/styles.src.css @@ -1,17 +1,32 @@ /* Speech Bubbles Plugin Styles */ .speech-bubbles-container { + --speech-bubbles-max-width: 75%; + --speech-bubbles-bubble-radius: 18px; + --speech-bubbles-gap: 8px; + --speech-bubbles-name-size: 0.7em; + --speech-bubbles-message-size: 0.95em; + --speech-bubbles-padding: 10px 14px; + display: flex; flex-direction: column; - gap: 8px; + gap: var(--speech-bubbles-gap); padding: 10px 0; max-width: 100%; } +/* Compact mode */ +.speech-bubbles-container.speech-bubbles-compact { + --speech-bubbles-gap: 4px; + --speech-bubbles-name-size: 0.65em; + --speech-bubbles-message-size: 0.9em; + --speech-bubbles-padding: 6px 10px; +} + .speech-bubbles-wrapper { display: flex; flex-direction: column; - max-width: 75%; + max-width: var(--speech-bubbles-max-width); margin-bottom: 4px; animation: speech-bubbles-fade-in 0.2s ease-out; } @@ -26,17 +41,61 @@ align-items: flex-start; } +/* Header with name, avatar, timestamp */ +.speech-bubbles-header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 4px; +} + .speech-bubbles-name { - font-size: 0.7em; + font-size: var(--speech-bubbles-name-size); font-weight: 600; - margin-bottom: 4px; opacity: 0.7; color: var(--speech-bubbles-name-color); } +/* Avatar styles */ +.speech-bubbles-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.speech-bubbles-avatar-emoji { + font-size: 1em; + line-height: 1; +} + +.speech-bubbles-avatar-image { + width: 18px; + height: 18px; +} + +.speech-bubbles-avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +/* Timestamp styles */ +.speech-bubbles-timestamp { + font-size: 0.65em; + color: var(--text-muted); + opacity: 0.7; + margin-left: 4px; +} + +.speech-bubbles-owner .speech-bubbles-timestamp { + color: rgba(255, 255, 255, 0.6); +} + .speech-bubbles-bubble { - padding: 10px 14px; - border-radius: 18px; + padding: var(--speech-bubbles-padding); + border-radius: var(--speech-bubbles-bubble-radius); position: relative; word-wrap: break-word; box-shadow: @@ -62,7 +121,7 @@ } .speech-bubbles-message { - font-size: 0.95em; + font-size: var(--speech-bubbles-message-size); line-height: 1.4; } @@ -73,11 +132,31 @@ text-align: center; } +/* Date separator styles */ +.speech-bubbles-date-separator { + display: flex; + justify-content: center; + margin: 16px 0; +} + +.speech-bubbles-date-separator-pill { + background-color: var(--background-modifier-border); + color: var(--text-muted); + padding: 4px 12px; + border-radius: 12px; + font-size: 0.75em; + font-weight: 500; +} + /* Dark theme adjustments */ .theme-dark .speech-bubbles-name { opacity: 0.8; } +.theme-dark .speech-bubbles-bubble.speech-bubbles-other { + color: var(--text-normal); +} + /* Ensure proper spacing with other content */ .speech-bubbles-container + p, p + .speech-bubbles-container { @@ -109,7 +188,8 @@ p + .speech-bubbles-container { border-radius: 8px; } -.speech-bubbles-usage ol { +.speech-bubbles-usage ol, +.speech-bubbles-usage ul { margin-left: 20px; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e1c25a3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,88 @@ +export interface SpeechBubblesSettings { + ownerName: string; + ownerAliases: string[]; + debugLogging: boolean; + bubbleMaxWidth: number; + bubbleRadius: number; + showSpeakerNames: boolean; + compactMode: boolean; + ownerBubbleColor: string | null; +} + +export const DEFAULT_SETTINGS: SpeechBubblesSettings = { + ownerName: "me", + ownerAliases: [], + debugLogging: false, + bubbleMaxWidth: 75, + bubbleRadius: 18, + showSpeakerNames: true, + compactMode: false, + ownerBubbleColor: null, +}; + +export interface SpeakerColor { + start: string; + end: string; +} + +export interface SpeakerIcon { + type: "emoji" | "image"; + value: string; +} + +export interface SpeakerConfig { + color?: string; + icon?: SpeakerIcon; +} + +export interface SidesConfig { + left: Set; + right: Set; +} + +export interface SpeechBubblesFrontmatter { + speakers?: Record; + sides?: { + left?: string[]; + right?: string[]; + }; +} + +export interface TranscriptConfig { + settings: SpeechBubblesSettings; + speakerConfigs: Map; + sides: SidesConfig | null; +} + +export interface ParsedTimestamp { + raw: string; + hours: number; + minutes: number; + seconds: number | null; +} + +export interface ParsedSpeaker { + name: string; + normalizedName: string; +} + +export interface ParsedBubble { + type: "bubble"; + speaker: ParsedSpeaker; + timestamp: ParsedTimestamp | null; + messageNodes: Node[]; +} + +export interface ParsedDateSeparator { + type: "date-separator"; + raw: string; + date: Date; + formattedDate: string; +} + +export interface ParsedRegularText { + type: "regular-text"; + nodes: Node[]; +} + +export type ParsedLine = ParsedBubble | ParsedDateSeparator | ParsedRegularText;