All files / services/notebook notebook-serializer.ts

100% Statements 39/39
100% Branches 19/19
100% Functions 4/4
100% Lines 39/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128                                            1x       19x       19x 19x           17x       17x 17x   2x         17x       17x   17x 26x   26x 6x         17x   17x 5x 4x         17x 26x 26x         26x       26x       22x 23x 23x     22x 22x 22x     26x 26x   26x     17x 17x   17x       6x         6x 10x               6x    
import * as vscode from 'vscode';
import { container } from 'tsyringe';
import { ServiceToken } from '@src/services/tokens';
import { NOTEBOOK_TYPE } from './notebook-controller';
 
interface NotebookData {
	metadata?: Record<string, any>;
	cells: NotebookCell[]
}
 
interface NotebookCell {
	language: string;
	value: string;
	kind: vscode.NotebookCellKind;
	editable?: boolean;
	metadata?: Record<string, any>;
}
 
/**
 * A regular expression that validates a notebook cell slug.
 * Slugs must start with a lowercase letter or digit and may contain lowercase letters, digits, and hyphens.
 */
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
 
export class NotebookSerializer implements vscode.NotebookSerializer {
 
	public readonly label: string = 'Mentor Notebook Serializer';
 
	constructor() {
		// Self-register with the extension context for automatic disposal
		const context = container.resolve<vscode.ExtensionContext>(ServiceToken.ExtensionContext);
		context.subscriptions.push(
			vscode.workspace.registerNotebookSerializer(NOTEBOOK_TYPE, this, { transientOutputs: true })
		);
	}
 
	public async deserializeNotebook(data: Uint8Array, _token: vscode.CancellationToken): Promise<vscode.NotebookData> {
		const contents = new TextDecoder().decode(data);
 
		let raw: NotebookData;
 
		try {
			raw = JSON.parse(contents);
		} catch {
			raw = { cells: [] };
		}
 
		// The cell counter is a notebook-level monotonic counter used to generate unique auto-slugs.
		// It only ever increases so that deleted cells do not cause slug reuse.
		let cellCounter: number = raw.metadata?.cellCounter ?? 0;
 
		// Pass 1: Count all stored slugs (auto or explicit) that have valid format.
		// Slugs appearing more than once are duplicates and cannot be preserved.
		const slugCounts = new Map<string, number>();
 
		for (const item of raw.cells) {
			const slug = item.metadata?.slug;
 
			if (slug && SLUG_PATTERN.test(slug)) {
				slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
			}
		}
 
		// A set of all slugs that are already "taken" — used to ensure auto-slugs don't collide.
		const usedSlugs = new Set<string>();
 
		for (const [slug, count] of slugCounts) {
			if (count === 1) {
				usedSlugs.add(slug);
			}
		}
 
		// Pass 2: Build cells, assigning auto-slugs where needed.
		const cells = raw.cells.map(item => {
			const metadata: Record<string, any> = { ...(item.metadata ?? {}) };
			const existingSlug = metadata.slug as string | undefined;
 
			// Preserve any stored slug (whether auto or explicit) if it is valid format and unique.
			// slugIsAuto is a signal for the renumber command — not a reason to discard the slug.
			const isValidStored =
				existingSlug &&
				SLUG_PATTERN.test(existingSlug) &&
				slugCounts.get(existingSlug) === 1;
 
			if (!isValidStored) {
				// Assign a fresh auto-slug using the monotonic counter.
				let slug: string;
 
				do {
					cellCounter++;
					slug = `cell-${cellCounter}`;
				} while (usedSlugs.has(slug));
 
				usedSlugs.add(slug);
				metadata.slug = slug;
				metadata.slugIsAuto = true;
			}
 
			const cell = new vscode.NotebookCellData(item.kind, item.value, item.language);
			cell.metadata = metadata;
 
			return cell;
		});
 
		const notebookData = new vscode.NotebookData(cells);
		notebookData.metadata = { ...(raw.metadata ?? {}), cellCounter };
 
		return notebookData;
	}
 
	public async serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Promise<Uint8Array> {
		const contents: NotebookData = {
			metadata: data.metadata,
			cells: [],
		};
 
		for (const cell of data.cells) {
			contents.cells.push({
				kind: cell.kind,
				language: cell.languageId,
				metadata: cell.metadata,
				value: cell.value
			});
		}
 
		return new TextEncoder().encode(JSON.stringify(contents));
	}
}