From 76c59a0ad4d5b3c9d295bbe1cb5a73de8e589a01 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Mon, 11 May 2026 20:00:26 -0700 Subject: [PATCH 1/2] docs: add markdown serialization design Signed-off-by: Jeremy lewi --- .../design/20260511_markdown_serialization.md | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs-dev/design/20260511_markdown_serialization.md diff --git a/docs-dev/design/20260511_markdown_serialization.md b/docs-dev/design/20260511_markdown_serialization.md new file mode 100644 index 0000000..714af32 --- /dev/null +++ b/docs-dev/design/20260511_markdown_serialization.md @@ -0,0 +1,393 @@ +# Markdown Serialization in the Web App + +Date: 2026-05-11 + +Builds on: + +- `docs-dev/design/20260403_drive_agentic_search.md` +- `docs-dev/design/20260409_track_drive_versions.md` + +## Summary + +Move notebook-to-Markdown serialization into the web app. + +Do not depend on `runme.parser.v1.ParserService.Serialize`, because the web app +no longer has a backend that can reliably provide it. + +Keep Markdown sidecar files for now. + +Decision: + +1. Implement a deterministic TypeScript serializer for Runme notebooks. +2. Continue syncing `.index.md` sidecars for Drive-backed notebooks. +3. Treat sidecars as the primary search artifact for Google Drive full-text + search. +4. Consider adding Drive `contentHints.indexableText` later as a supplemental + optimization, not as the only indexing mechanism. + +## Problem + +Today the local notebook mirror still tries to serialize notebooks to Markdown +by calling the parser service: + +- `LocalNotebooks.save(...)` persists notebook JSON locally, then enqueues + Markdown sync. +- `LocalNotebooks.syncMarkdownFile(...)` creates or updates + `.index.md` next to the Drive-backed notebook. +- That method currently calls `runmeClientManager.get().serializeNotebook(...)`, + which is just a thin RPC wrapper around `ParserService.Serialize`. + +That path fails when no backend implements the parser service. + +At the same time, the sidecar file is still useful product behavior because it +gives Google Drive a text-shaped artifact to search and gives agents or users a +human-readable representation of notebook content. + +## Questions We Need to Answer + +### 1) Does Google Drive support full-text search over JSON files? + +Not conclusively from the public docs. + +What Google does document: + +- Drive searches include file titles and content. +- Drive API `fullText contains '...'` matches against `name`, `description`, + `indexableText`, or text in the file's content or metadata. + +That strongly suggests uploaded JSON blob content may be indexed as text. + +However, Google does not explicitly document a notebook-JSON-specific or +`application/json`-specific full-text indexing guarantee, and the preview/help +docs list common previewable text/code types without naming JSON. + +So the right conclusion is: + +- We should not claim that Drive categorically cannot search JSON files. +- We also should not rely on raw notebook JSON being a good search target. + +### 2) Do we still need auxiliary Markdown files? + +Probably yes, for now. + +Even if Drive indexes JSON blobs, raw notebook JSON is a poor search corpus: + +- It contains structural noise (`cells`, metadata keys, protobuf-shaped fields). +- It interleaves content with machine-oriented schema. +- It is much less readable when opened from Drive search results. +- Query matches may rank poorly because important notebook text is diluted by + repeated field names and serialized structure. + +Markdown sidecars solve those problems by presenting notebook content as +natural text and code blocks instead of storage JSON. + +## Google Drive Findings + +As of 2026-05-11, the relevant Google documentation says: + +- The Drive help docs say search includes "titles and content of all files you + have permission to access." +- The Drive API search reference says `fullText` matches against `name`, + `description`, `indexableText`, or text in file content/metadata. +- The Drive file resource defines `contentHints.indexableText` as text indexed + to improve `fullText` queries, with a 128 KB limit. + +Sources: + +- +- +- + +These sources support two design conclusions: + +1. Full-text search is a real Drive capability we should design around. +2. `contentHints.indexableText` is a viable alternative or supplement, but its + 128 KB cap makes it insufficient as the only representation for large + notebooks. + +## Goals + +- Remove the web app's dependency on backend Markdown serialization. +- Preserve the existing Drive sidecar behavior for search. +- Keep the serializer deterministic so repeated saves produce stable Markdown. +- Preserve current product intent that notebook outputs can be searchable. +- Keep the implementation entirely browser-side. + +## Decision + +We are choosing Option C. + +Implement notebook Markdown serialization in the web app and keep the existing +Drive Markdown sidecar model. + +Do not rely on the old backend parser service. + +Do not assume Google Drive cannot search JSON at all, but also do not treat raw +JSON as a sufficient notebook search format. + +For efficient and readable notebook search, auxiliary Markdown files remain the +default design. + +## Non-Goals + +- Reproducing the old backend serializer byte-for-byte. +- Reworking notebook JSON persistence. +- Replacing the existing local-mirror / Drive sync architecture. +- Designing a general Markdown parser/importer in this document. +- Solving semantic search or vector retrieval. + +## Current Implementation Context + +Current behavior in `LocalNotebooks.syncMarkdownFile(...)`: + +1. Only Drive-backed files are eligible. +2. If no sidecar exists, create `.index.md` in the same Drive folder. +3. Deserialize the locally stored notebook JSON. +4. Call backend `Serialize` with outputs enabled and summary disabled. +5. Upload the resulting Markdown as `text/markdown`. + +This is already a best-effort sidecar sync, debounced by 20 seconds after save. + +The architectural issue is narrow: + +- local notebook persistence is already TypeScript-only, +- Drive upload is already browser-only, +- only the notebook-to-Markdown conversion is still backend-dependent. + +## Chosen Approach + +Render notebook content to Markdown in the browser, then keep uploading +`.index.md`. + +Reasons: + +- Removes the backend dependency immediately. +- Preserves the current Drive search strategy. +- Keeps a readable artifact in Drive. +- Avoids the `indexableText` size ceiling. +- Aligns with the existing agentic-search design that searches Markdown + sidecars and resolves them back to notebooks. + +Implement this now. + +Optionally add part of Option B later: + +- After generating Markdown, also write a truncated plain-text or Markdown + projection into `contentHints.indexableText` on the canonical JSON file. +- Keep the sidecar as the authoritative search/read artifact. + +This gives us three useful properties: + +1. no backend dependency, +2. readable searchable sidecar documents, +3. a possible future direct-hit path on the JSON file itself. + +## Proposed Design + +### 1) Add a browser-side serializer + +Add a new module, for example: + +- `app/src/lib/markdown/serializeNotebookToMarkdown.ts` + +The serializer should operate on `parser_pb.Notebook` and return a UTF-8 string. + +The output contract should optimize for readability and search, not for exact +parity with the removed backend implementation. + +### 2) Serialization rules + +Recommended first-pass rules: + +- Notebook-level frontmatter is not required. +- Markdown cells: + - emit cell text directly with minimal normalization. +- Code cells: + - emit fenced code blocks using the cell language when known. + - default to an untyped fence when language is missing. +- Textual outputs: + - preserve current intent by including outputs in the sidecar. + - render as fenced blocks after the source cell. +- Non-text/binary outputs: + - omit payload bytes. + - optionally include a short placeholder such as "binary output omitted". +- Cell boundaries: + - separate cells with a stable blank-line convention. + +Important: + +- We do not need exact reproduction of old parser Markdown. +- We do need deterministic and human-readable output. + +### 3) Keep the existing sidecar sync flow + +`LocalNotebooks.syncMarkdownFile(...)` should continue to: + +1. resolve or create `markdownUri`, +2. serialize from the locally persisted notebook JSON, +3. upload the Markdown sidecar with MIME type `text/markdown`. + +The only architectural change is replacing the RPC call with the local +serializer. + +### 4) Preserve naming for compatibility + +Keep the current sidecar naming convention: + +- `.index.md` + +Reasons: + +- Existing design docs already assume it. +- Existing records may already store `markdownUri`. +- Search helpers can continue filtering by name and MIME type. + +### 5) Defer `appProperties` linkage unless we need stronger resolution + +Today sidecars are linked to notebooks implicitly by: + +- same parent folder +- sibling naming convention + +That is acceptable for this change. + +If ambiguity becomes a real issue, add Drive `appProperties` later, for example: + +- sidecar stores the canonical notebook file ID +- canonical notebook stores the sidecar file ID + +That is useful, but not required to remove backend serialization. + +## Alternatives Considered + +## Option A: Search raw JSON notebooks only + +Do not keep sidecars. Do not set `indexableText`. Rely on Drive indexing the +JSON file itself. + +Rejected. + +Reasons: + +- The docs do not guarantee good ranking or extraction behavior for notebook + JSON. +- Search results would open a machine-oriented JSON blob, not a readable + notebook projection. +- This is the weakest path for both user experience and agentic search quality. + +## Option B: Stop creating sidecars and write `contentHints.indexableText` + +Render notebook text locally, then attach that text to the canonical JSON Drive +file via `contentHints.indexableText`. + +Advantages: + +- No duplicate Drive files. +- Search results point directly at the canonical notebook file. +- Explicitly uses a Drive feature intended to improve `fullText` queries. + +Disadvantages: + +- `indexableText` is capped at 128 KB. +- Our current Drive client abstraction does not expose `contentHints`. +- The indexed text is metadata, not a human-readable artifact users can open. +- We would lose the sidecar-based workflow already assumed by the Drive + agentic-search design. + +Conclusion: + +Worth evaluating later as a supplemental optimization, but too limiting to be +the only solution today. + +## Implementation Plan + +### Phase 1: local serializer + +1. Add the serializer module and tests. +2. Replace the parser RPC call in `LocalNotebooks.syncMarkdownFile(...)`. +3. Keep the existing debounce, create-if-missing, and Drive upload logic. + +### Phase 2: validation and parity checks + +1. Test notebooks containing: + - markdown cells + - code cells in several languages + - stdout/stderr text output + - rich/binary outputs +2. Verify sidecars remain readable in Drive. +3. Verify Drive search returns sidecars for notebook content terms. + +### Phase 3: optional Drive metadata enhancement + +If we want direct canonical-file matches in Drive search: + +1. Extend the Drive client abstraction to support `contentHints.indexableText`. +2. Write a truncated projection of notebook Markdown to the canonical JSON file. +3. Keep sidecars in place unless we prove the metadata-only approach is enough. + +## Drive Client Follow-Up for `indexableText` + +If we pursue the optional enhancement, current abstractions need expansion. + +Today `DriveDoc` only carries: + +- `name` +- `mimeType` +- `parents` +- `content` + +It does not carry `contentHints`, so neither the fetch-based nor gapi-based +Drive client can write `indexableText`. + +That should be a separate follow-up from local Markdown serialization, because +it is not required to unblock removal of the backend dependency. + +## Migration and Rollout + +- Existing notebooks with `markdownUri` keep using the same sidecar file. +- Existing notebooks without a sidecar continue current behavior and create one + on the next eligible sync. +- No data migration is required for local notebook JSON. +- No server rollout is required. + +## Test Plan + +- Unit tests for the serializer covering mixed notebook content. +- Local storage tests proving `syncMarkdownFile(...)` no longer calls the parser + client. +- Drive sync tests verifying the uploaded MIME type remains `text/markdown`. +- Manual verification: + - save a Drive-backed notebook, + - confirm `.index.md` is updated, + - search in Drive for notebook text and confirm the sidecar is returned. + +## Risks + +Risk: local serializer output differs from old backend output. + +Mitigation: + +- treat readability and determinism as the contract, +- do not require byte-for-byte parity. + +Risk: large notebooks generate very large sidecars. + +Mitigation: + +- acceptable for v1, +- optional future truncation or summarization policy can be added if needed. + +Risk: sidecar linkage remains naming-based. + +Mitigation: + +- preserve current behavior for now, +- add `appProperties` only if ambiguity appears in practice. + +Risk: duplicated Drive artifacts may annoy users. + +Mitigation: + +- this already exists today, +- the sidecar has clear product value for search, +- revisit after evaluating `indexableText`. From 860850e5b3f09ab9711b343e37642b569e019614 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Mon, 11 May 2026 20:07:49 -0700 Subject: [PATCH 2/2] app: serialize markdown sidecars locally Signed-off-by: Jeremy lewi --- app/src/lib/aisreClient.ts | 20 --- .../serializeNotebookToMarkdown.test.ts | 134 ++++++++++++++ .../markdown/serializeNotebookToMarkdown.ts | 168 ++++++++++++++++++ app/src/storage/local.test.ts | 76 ++++++++ app/src/storage/local.ts | 19 +- 5 files changed, 381 insertions(+), 36 deletions(-) create mode 100644 app/src/lib/markdown/serializeNotebookToMarkdown.test.ts create mode 100644 app/src/lib/markdown/serializeNotebookToMarkdown.ts diff --git a/app/src/lib/aisreClient.ts b/app/src/lib/aisreClient.ts index 6bd4b00..f5368ce 100644 --- a/app/src/lib/aisreClient.ts +++ b/app/src/lib/aisreClient.ts @@ -22,7 +22,6 @@ import { import { ParserService, type Notebook, - type SerializeRequestOptions, type DeserializeRequestOptions, } from "@buf/stateful_runme.bufbuild_es/runme/parser/v1/parser_pb.js"; import { timestampDate } from "@bufbuild/protobuf/wkt"; @@ -185,25 +184,6 @@ export class AisreClient { return response.notebook!; } - /** - * Serializes a notebook via the parser service and returns the raw bytes - * produced by the backend (e.g. Markdown content for an index file). - */ - async serializeNotebook( - notebook: Notebook, - serializeOptions?: SerializeRequestOptions, - requestOptions?: RequestOptions, - ): Promise { - const response = await this.parserClient.serialize( - { - notebook, - options: serializeOptions, - }, - this.mergeCallOptions(requestOptions), - ); - return response.result; - } - private mergeCallOptions( overrides?: RequestOptions, ): CallOptions | undefined { diff --git a/app/src/lib/markdown/serializeNotebookToMarkdown.test.ts b/app/src/lib/markdown/serializeNotebookToMarkdown.test.ts new file mode 100644 index 0000000..3c20842 --- /dev/null +++ b/app/src/lib/markdown/serializeNotebookToMarkdown.test.ts @@ -0,0 +1,134 @@ +import { create } from '@bufbuild/protobuf' +import { describe, expect, it } from 'vitest' + +import { MimeType, parser_pb } from '../../runme/client' +import { serializeNotebookToMarkdown } from './serializeNotebookToMarkdown' + +const textEncoder = new TextEncoder() + +describe('serializeNotebookToMarkdown', () => { + it('renders markdown cells, code cells, and text outputs', () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.MARKUP, + languageId: 'markdown', + value: '# Title\n\nSome notes.', + }), + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.CODE, + languageId: 'python', + value: 'print("hello")', + outputs: [ + create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.VSCodeNotebookStdOut, + type: 'Buffer', + data: textEncoder.encode('hello\n'), + }), + create(parser_pb.CellOutputItemSchema, { + mime: 'application/json', + type: 'Buffer', + data: textEncoder.encode('{"ok":true}'), + }), + ], + }), + ], + }), + ], + }) + + expect(serializeNotebookToMarkdown(notebook)).toBe( + [ + '# Title', + '', + 'Some notes.', + '', + '```python', + 'print("hello")', + '```', + '', + '```stdout', + 'hello', + '```', + '', + '```json', + '{"ok":true}', + '```', + '', + ].join('\n') + ) + }) + + it('treats code cells tagged as markdown as prose', () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.CODE, + languageId: 'markdown', + value: 'A paragraph with **bold** text.', + }), + ], + }) + + expect(serializeNotebookToMarkdown(notebook)).toBe( + 'A paragraph with **bold** text.\n' + ) + }) + + it('skips binary and internal output payloads', () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.CODE, + languageId: 'bash', + value: 'echo hi', + outputs: [ + create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.StatefulRunmeTerminal, + type: 'Buffer', + data: textEncoder.encode('ignored'), + }), + create(parser_pb.CellOutputItemSchema, { + mime: 'image/png', + type: 'Buffer', + data: new Uint8Array([0, 1, 2, 3]), + }), + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.VSCodeNotebookStdErr, + type: 'Buffer', + data: textEncoder.encode('warn\n'), + }), + ], + }), + ], + }), + ], + }) + + expect(serializeNotebookToMarkdown(notebook)).toBe( + ['```bash', 'echo hi', '```', '', '```stderr', 'warn', '```', ''].join( + '\n' + ) + ) + }) + + it('uses a longer fence when content already contains triple backticks', () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.CODE, + languageId: 'javascript', + value: 'console.log("```inside```")', + }), + ], + }) + + expect(serializeNotebookToMarkdown(notebook)).toBe( + ['````javascript', 'console.log("```inside```")', '````', ''].join('\n') + ) + }) +}) diff --git a/app/src/lib/markdown/serializeNotebookToMarkdown.ts b/app/src/lib/markdown/serializeNotebookToMarkdown.ts new file mode 100644 index 0000000..6b6fc88 --- /dev/null +++ b/app/src/lib/markdown/serializeNotebookToMarkdown.ts @@ -0,0 +1,168 @@ +import { MimeType, parser_pb } from '../../runme/client' + +const IOPUB_MIME_TYPE = 'application/vnd.jupyter.iopub+json' + +const outputTextDecoder = new TextDecoder() + +const MARKDOWN_LANGUAGES = new Set(['markdown', 'md']) +const INTERNAL_SKIP_MIMES = new Set([ + MimeType.StatefulRunmeOutputItems, + MimeType.StatefulRunmeTerminal, +]) + +export function serializeNotebookToMarkdown( + notebook: parser_pb.Notebook +): string { + const parts = notebook.cells + .map((cell) => serializeCell(cell)) + .filter((part) => part.trim().length > 0) + + if (parts.length === 0) { + return '' + } + + return `${parts.join('\n\n')}\n` +} + +function serializeCell(cell: parser_pb.Cell): string { + const body = isMarkupCell(cell) + ? normalizeMarkupCell(cell.value) + : renderFencedBlock(cell.value, normalizeCodeFenceLanguage(cell.languageId)) + const outputs = serializeCellOutputs(cell.outputs ?? []) + return [body, outputs].filter(Boolean).join('\n\n') +} + +function isMarkupCell(cell: parser_pb.Cell): boolean { + if (cell.kind === parser_pb.CellKind.MARKUP) { + return true + } + return MARKDOWN_LANGUAGES.has(cell.languageId.trim().toLowerCase()) +} + +function normalizeMarkupCell(value: string): string { + return value.replace(/\s+$/u, '') +} + +function normalizeCodeFenceLanguage(languageId: string): string { + return languageId.trim().toLowerCase() +} + +function serializeCellOutputs(outputs: parser_pb.CellOutput[]): string { + const rendered = outputs.flatMap((output) => + (output.items ?? []) + .map((item) => serializeOutputItem(item)) + .filter((value): value is string => Boolean(value)) + ) + + return rendered.join('\n\n') +} + +function serializeOutputItem(item: parser_pb.CellOutputItem): string | null { + const mime = (item.mime ?? '').trim() + if (!mime || INTERNAL_SKIP_MIMES.has(mime)) { + return null + } + + if (!isTextLikeMime(mime)) { + return null + } + + const text = decodeOutputText(item.data ?? new Uint8Array()) + if (!text) { + return null + } + + return renderFencedBlock(text, languageForOutputMime(mime)) +} + +function isTextLikeMime(mime: string): boolean { + if ( + mime === MimeType.VSCodeNotebookStdOut || + mime === MimeType.VSCodeNotebookStdErr + ) { + return true + } + if (mime === IOPUB_MIME_TYPE) { + return true + } + if (mime.startsWith('text/')) { + return true + } + if (mime === 'application/json' || mime.endsWith('+json')) { + return true + } + if ( + mime === 'application/javascript' || + mime === 'application/x-javascript' + ) { + return true + } + if (mime === 'application/xml' || mime.endsWith('+xml')) { + return true + } + if (mime === 'application/sql') { + return true + } + if (mime === 'application/yaml' || mime === 'application/x-yaml') { + return true + } + return false +} + +function languageForOutputMime(mime: string): string { + switch (mime) { + case MimeType.VSCodeNotebookStdOut: + return 'stdout' + case MimeType.VSCodeNotebookStdErr: + return 'stderr' + case IOPUB_MIME_TYPE: + case 'application/json': + return 'json' + case 'text/html': + return 'html' + case 'application/javascript': + case 'application/x-javascript': + return 'javascript' + case 'application/xml': + return 'xml' + case 'application/sql': + return 'sql' + case 'application/yaml': + case 'application/x-yaml': + return 'yaml' + default: + if (mime.startsWith('text/')) { + return mime.slice('text/'.length) + } + if (mime.endsWith('+json')) { + return 'json' + } + if (mime.endsWith('+xml')) { + return 'xml' + } + return '' + } +} + +function decodeOutputText(data: Uint8Array): string { + if (!(data instanceof Uint8Array) || data.length === 0) { + return '' + } + try { + return outputTextDecoder.decode(data).replace(/\s+$/u, '') + } catch { + return '' + } +} + +function renderFencedBlock(content: string, language = ''): string { + const fence = pickFence(content) + const info = language ? `${language}` : '' + return `${fence}${info}\n${content}\n${fence}` +} + +function pickFence(content: string): string { + const matches: string[] = content.match(/`+/gu) ?? [] + const longest = matches.reduce((max, run) => Math.max(max, run.length), 0) + return '`'.repeat(Math.max(3, longest + 1)) +} diff --git a/app/src/storage/local.test.ts b/app/src/storage/local.test.ts index 9c4c789..91294b8 100644 --- a/app/src/storage/local.test.ts +++ b/app/src/storage/local.test.ts @@ -1,13 +1,19 @@ /// // @vitest-environment node +import { create, toJsonString } from "@bufbuild/protobuf"; import { describe, expect, it, vi } from "vitest"; +import { MimeType, parser_pb } from "../runme/client"; import LocalNotebooks, { type LocalFileRecord, type LocalFolderRecord, } from "./local"; import { NotebookStoreItemType } from "./notebook"; +const NOTEBOOK_JSON_WRITE_OPTIONS = { + emitDefaultValues: true, +} as unknown as Parameters[2]; + function createMockTable() { const store = new Map(); return { @@ -272,3 +278,73 @@ describe("LocalNotebooks pending Drive create", () => { expect(driveStore.create).toHaveBeenCalledTimes(1); }); }); + +describe("LocalNotebooks markdown sidecar sync", () => { + it("serializes notebooks to markdown locally before uploading the sidecar", async () => { + const markdownUri = "https://drive.google.com/file/d/sidecar123/view"; + const remoteUri = "https://drive.google.com/file/d/notebook123/view"; + const driveStore = { + saveContent: vi.fn(async () => undefined), + }; + const store = createTestStore(driveStore); + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.MARKUP, + languageId: "markdown", + value: "# Searchable title", + }), + create(parser_pb.CellSchema, { + kind: parser_pb.CellKind.CODE, + languageId: "python", + value: 'print("hello")', + outputs: [ + create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.VSCodeNotebookStdOut, + type: "Buffer", + data: new TextEncoder().encode("hello\n"), + }), + ], + }), + ], + }), + ], + }); + + await store.files.put({ + id: "local://file/notebook", + name: "notebook.json", + remoteId: remoteUri, + markdownUri, + lastRemoteChecksum: "", + lastSynced: "", + doc: toJsonString( + parser_pb.NotebookSchema, + notebook, + NOTEBOOK_JSON_WRITE_OPTIONS, + ), + md5Checksum: "", + }); + + await store.syncMarkdownFile("local://file/notebook"); + + expect(driveStore.saveContent).toHaveBeenCalledWith( + markdownUri, + [ + "# Searchable title", + "", + "```python", + 'print("hello")', + "```", + "", + "```stdout", + "hello", + "```", + "", + ].join("\n"), + "text/markdown", + ); + }); +}); diff --git a/app/src/storage/local.ts b/app/src/storage/local.ts index 5a0ffd3..2609602 100644 --- a/app/src/storage/local.ts +++ b/app/src/storage/local.ts @@ -5,7 +5,7 @@ import md5 from "md5"; import { Subject, debounceTime } from "rxjs"; import { parser_pb } from "../runme/client"; -import { aisreClientManager as runmeClientManager } from "../lib/aisreClientManager"; +import { serializeNotebookToMarkdown } from "../lib/markdown/serializeNotebookToMarkdown"; import { appState } from "../lib/runtime/AppState"; import { appLogger } from "../lib/logging/runtime"; import { @@ -798,28 +798,15 @@ export class LocalNotebooks extends Dexie { await this.files.update(localUri, { markdownUri }); } - // Serialize the notebook to Markdown via the parser service. - let markdownBytes: Uint8Array; + let markdownContent: string; try { const notebook = deserializeNotebook(record.doc ?? ""); - const client = runmeClientManager.get(); - markdownBytes = await client.serializeNotebook( - notebook, - create(parser_pb.SerializeRequestOptionsSchema, { - outputs: create(parser_pb.SerializeRequestOutputOptionsSchema, { - enabled: true, - // Summary controls information about execution. I don't think we need that. - summary: false, - }), - }), - ); + markdownContent = serializeNotebookToMarkdown(notebook); } catch (error) { console.error("Failed to serialize notebook to markdown", error); return; } - const markdownContent = new TextDecoder().decode(markdownBytes); - try { await driveStore.saveContent(markdownUri, markdownContent, "text/markdown"); } catch (error) {