Skip to content

fix: convert html-format enhanced notes to markdown#5

Open
shashankmehta wants to merge 4 commits into
magarcia:mainfrom
shashankmehta:fix/enhanced-html-content
Open

fix: convert html-format enhanced notes to markdown#5
shashankmehta wants to merge 4 commits into
magarcia:mainfrom
shashankmehta:fix/enhanced-html-content

Conversation

@shashankmehta

Copy link
Copy Markdown

Summary

granola meeting enhanced <id> produces empty output for meetings authored via the Granola mobile app's quick-notes feature. This PR makes the enhanced command produce well-formed Markdown for both panel content shapes the API can return, with no regression for desktop-authored meetings.

Root cause

The Granola API returns last_viewed_panel.content in two shapes:

  • Desktop-authored meetings: ProseMirror JSON document ({ "type": "doc", "content": [...] }).
  • Mobile quick-note meetings: raw HTML string ("<h3>...</h3><ul>...</ul>").

toMarkdown() in src/lib/prosemirror.ts defensively bails out when the input has no .content array, so users hit empty Markdown rather than a crash. The last_viewed_panel.content field was typed as ProseMirrorDoc everywhere, so the type system wasn't surfacing the divergence either.

Verifying this is upstream data, not a client issue

Before treating this as something the CLI should normalize, I confirmed the API itself is the source by sending direct requests (bypassing this CLI) to /v2/get-documents, /v1/get-documents-batch, and /v1/get-document-panels, each with four header configurations: no client headers, this CLI's headers, headers spoofed to look like the desktop app, and a generic browser User-Agent. Every endpoint returned byte-identical HTML strings for affected meetings and ProseMirror documents for unaffected meetings, regardless of headers. The CLI is faithfully transmitting whatever the server sends.

Fix

  • New src/lib/html.ts exporting htmlToMarkdown(html: string): string. Wraps turndown configured to match the existing prosemirror.ts output style (ATX headings, - bullets, fenced code blocks, */** emphasis, --- rules) plus a single regex post-process to normalize bullet spacing.
  • toMarkdown() in src/lib/prosemirror.ts widened to accept ProseMirrorDoc | string | null. Strings route through htmlToMarkdown(); the existing object/null paths are unchanged.
  • Type widening at four source sites where the union is empirically present: Meeting.last_viewed_panel.content, DocumentMetadata.last_viewed_panel.content, the return type of getEnhancedNotes(), and the local notes variable in src/commands/meeting/enhanced.ts.
  • docs/API.md, docs/INTERNALS.md, docs/GRANOLA-API-SPEC.md updated to describe both shapes.

Test fixture refactor: Several tests were using mockMeetingWithNotes.last_viewed_panel!.content to mock getNotes() — a minor smell predating this PR. The widening exposed it. Rather than cascading the union into getNotes() (no empirical evidence the API returns HTML for plain notes), mockProseMirrorContent is now a standalone exported constant in tests/fixtures/meetings.ts, and getNotes mocks reference mockMeetingWithNotes.notes! — properly typed as ProseMirrorDoc.

Design notes

Why the type widening reaches 4 source files. The wire format genuinely returns a union, so src/types.ts and src/lib/api.ts (which document the wire format) must reflect it. getEnhancedNotes() returns whatever the API returned; its return type follows. The command consumes the union via toMarkdown(). Each change is independently necessary; this isn't ripple-effect cost.

I considered keeping the types narrow with as ProseMirrorDoc casts at consumption sites, but that approach doesn't reflect what we actually receive at runtime — it would extend an existing pattern that's already been a source of confusion. I also considered converting HTML to synthetic ProseMirror inside getEnhancedNotes(), but that requires a full HTML-to-ProseMirror translator and would synthesize a doc that wasn't actually what the server returned, breaking the honesty of -o json.

Why structured outputs (-o json|yaml|toon) are unchanged. They serve scripts, archival, and wire-format debugging; their contract is "give me what the API returned." After this PR:

Mode HTML-on-wire ProseMirror-on-wire
Default (markdown) well-formed Markdown ✅ (was empty — the bug) unchanged
-o json|yaml|toon raw HTML string (unchanged) ProseMirror tree (unchanged)

A -o json | jq consumer may need to detect HTML vs. ProseMirror to drive downstream logic; silently converting either way would lose that signal.

The widening explicitly stops at last_viewed_panel.content. Meeting.notes and getNotes() stay narrow because there's no empirical evidence the API returns HTML for plain notes. If that surfaces later, the toMarkdown() string branch already handles it.

Test plan

  • npm run typecheck: clean
  • npm run ci (biome strict, what CI runs): 88 files clean
  • npm run test:coverage: 472 tests across 35 files, all passing; project coverage thresholds (95/90/95/95) hold
  • npm run build: success

Unit additions: 12 tests in tests/lib/html.test.ts covering empty input, paragraphs, headings, bullet/ordered lists (including nested), text marks, links, blockquotes, horizontal rules, and a real-world Granola mobile-app payload (src/lib/html.ts reaches 100% coverage). 2 tests in tests/lib/prosemirror.test.ts covering the new string-routing branch.

Live smoke: confirmed Markdown output for an affected mobile-quick-note meeting (was empty before), unchanged Markdown for a desktop-authored meeting, and unchanged passthrough for -o json in both cases.

Dependencies

  • turndown ^7.2.4 (MIT) — runtime. Tsup keeps it external; dist/main.js grew from 72.7 KB to 74.0 KB.
  • @types/turndown ^5.0.6 (MIT) — devDependencies only.

turndown has one transitive dep, @mixmark-io/domino (BSD-2-Clause / MPL-2.0), for server-side DOM parsing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant