fix: convert html-format enhanced notes to markdown#5
Open
shashankmehta wants to merge 4 commits into
Open
Conversation
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.contentin two shapes:{ "type": "doc", "content": [...] })."<h3>...</h3><ul>...</ul>").toMarkdown()insrc/lib/prosemirror.tsdefensively bails out when the input has no.contentarray, so users hit empty Markdown rather than a crash. Thelast_viewed_panel.contentfield was typed asProseMirrorDoceverywhere, 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
src/lib/html.tsexportinghtmlToMarkdown(html: string): string. Wrapsturndownconfigured to match the existingprosemirror.tsoutput style (ATX headings,-bullets, fenced code blocks,*/**emphasis,---rules) plus a single regex post-process to normalize bullet spacing.toMarkdown()insrc/lib/prosemirror.tswidened to acceptProseMirrorDoc | string | null. Strings route throughhtmlToMarkdown(); the existing object/null paths are unchanged.Meeting.last_viewed_panel.content,DocumentMetadata.last_viewed_panel.content, the return type ofgetEnhancedNotes(), and the localnotesvariable insrc/commands/meeting/enhanced.ts.docs/API.md,docs/INTERNALS.md,docs/GRANOLA-API-SPEC.mdupdated to describe both shapes.Test fixture refactor: Several tests were using
mockMeetingWithNotes.last_viewed_panel!.contentto mockgetNotes()— a minor smell predating this PR. The widening exposed it. Rather than cascading the union intogetNotes()(no empirical evidence the API returns HTML for plain notes),mockProseMirrorContentis now a standalone exported constant intests/fixtures/meetings.ts, andgetNotesmocks referencemockMeetingWithNotes.notes!— properly typed asProseMirrorDoc.Design notes
Why the type widening reaches 4 source files. The wire format genuinely returns a union, so
src/types.tsandsrc/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 viatoMarkdown(). Each change is independently necessary; this isn't ripple-effect cost.I considered keeping the types narrow with
as ProseMirrorDoccasts 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 insidegetEnhancedNotes(), 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:-o json|yaml|toonA
-o json | jqconsumer 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.notesandgetNotes()stay narrow because there's no empirical evidence the API returns HTML for plain notes. If that surfaces later, thetoMarkdown()string branch already handles it.Test plan
npm run typecheck: cleannpm run ci(biome strict, what CI runs): 88 files cleannpm run test:coverage: 472 tests across 35 files, all passing; project coverage thresholds (95/90/95/95) holdnpm run build: successUnit additions: 12 tests in
tests/lib/html.test.tscovering 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.tsreaches 100% coverage). 2 tests intests/lib/prosemirror.test.tscovering 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 jsonin both cases.Dependencies
turndown^7.2.4 (MIT) — runtime. Tsup keeps it external;dist/main.jsgrew from 72.7 KB to 74.0 KB.@types/turndown^5.0.6 (MIT) — devDependencies only.turndownhas one transitive dep,@mixmark-io/domino(BSD-2-Clause / MPL-2.0), for server-side DOM parsing.