9 directories, 58 files

tinai

Home / tinai
/**
 * Class ScratchpadModel
 * Pure data model for one scratchpad revision: a flat array of typed items
 * (header, text, code, quote, link, list-step, diagram-mermaid, color-hex,
 * table, equation) each carrying a stable guid, plus the table of contents
 * entries referencing item guids. Owns normalization, serialization, and the
 * structural mutations driven by the scratchpad editing tools. Item shapes
 * follow the conversation content format so the existing renderers consume
 * them directly.
 */
class ScratchpadModel {

	static TYPES = ['header', 'text', 'code', 'quote', 'link', 'list-step', 'diagram-mermaid', 'color-hex', 'table', 'equation'];

	#items = [];
	#toc = [];

	/**
	 * Initializes the model with the stored revision state.
	 * @param {Array} [items=[]] - The stored scratchpad items.
	 * @param {Array} [toc=[]] - The stored table of contents entries.
	 */
	constructor(items = [], toc = []) {
		this.load(items, toc);
	}

	/**
	 * Loads the stored revision state, normalizing item and entry shapes and
	 * backfilling missing guids.
	 * @param {Array} [items=[]] - The stored scratchpad items.
	 * @param {Array} [toc=[]] - The stored table of contents entries.
	 */
	load(items = [], toc = []) {
		this.#items = (Array.isArray(items) ? items : []).map(item => this.#normalize_item(item));
		this.#toc = (Array.isArray(toc) ? toc : []).map(entry => this.#normalize_toc_entry(entry));
	}

	/**
	 * The live items array. Treat as read-only; mutations go through the
	 * model methods so serialization stays consistent.
	 * @returns {Array<Object>} The item array.
	 */
	get items() {
		return this.#items;
	}

	/**
	 * The live table of contents entries. Treat as read-only.
	 * @returns {Array<Object>} The toc entry array.
	 */
	get toc() {
		return this.#toc;
	}

	/**
	 * Serializes the model back into the stored revision shape.
	 * @returns {{items: Array<Object>, toc: Array<Object>}} The revision state.
	 */
	get_state() {
		return {
			items: this.#serialize_items(),
			toc: this.#toc.map(entry => {
				const out = { guid: entry.guid, target_guid: entry.target_guid, title: entry.title };
				if (entry.description) out.description = entry.description;
				return out;
			})
		};
	}

	/**
	 * Resolves an edit anchor guid to an item index, supporting the dynamic
	 * "last" and "first" values.
	 * @param {string} guid - The item guid or "last"/"first".
	 * @returns {number} The item index, or -1 when not found.
	 */
	resolve_index(guid) {
		const key = String(guid || '').trim().toLowerCase();
		if (key === 'last') return this.#items.length - 1;
		if (key === 'first') return 0;
		return this.#items.findIndex(item => item.guid === guid);
	}

	/**
	 * Builds a model item from an edit_scratchpad item payload, assigning a
	 * fresh guid. Ordered list steps number themselves implicitly unless the
	 * payload carries an explicit index.
	 * @param {Object} item - The tool item payload.
	 * @returns {Object|null} The model item, or null when the payload is invalid.
	 */
	tool_item(item) {
		if (!item || typeof item.type !== 'string') return null;
		const type = ScratchpadModel.canonical_type(item.type);
		if (!type) return null;
		const value = typeof item.value === 'string' ? item.value : '';
		const hasRows = Array.isArray(item['table-rows']) && item['table-rows'].length > 0;
		if (value === '' && !(type === 'table' && hasRows)) return null;
		const built = { guid: this.#new_guid(), type: type, value: value };
		if (item.caption !== undefined && item.caption !== null && String(item.caption) !== '') built.caption = String(item.caption);
		if (type === 'header') {
			built.index = (Number.isInteger(item.index) && item.index >= 1 && item.index <= 6) ? item.index : null;
		}
		if (type === 'list-step') {
			built.ordered = !!item.ordered;
			built.index = (built.ordered && Number.isInteger(item.index)) ? item.index : null;
		}
		if (type === 'code' && typeof item.language === 'string' && item.language) {
			built.language = item.language;
		}
		if (type === 'table') {
			built['has-header'] = !!item['has-header'];
			built['table-rows'] = hasRows
				? item['table-rows'].map(row => Array.isArray(row) ? row.map(cell => String(cell ?? '')) : [])
				: [];
		}
		if (type === 'link' && Array.isArray(item.source) && item.source.length > 0) {
			const source = item.source.find(src => src && typeof src.url === 'string' && src.url);
			if (source) built.source = [{ title: String(source.title || source.url), url: source.url }];
		}
		return built;
	}

	/**
	 * Applies a normalized edit_scratchpad edit to the items: add (with an
	 * optional guid anchor, "last"/"first" dynamic anchors, appended at the
	 * end when absent), replace, move (with a target_guid anchor) and remove.
	 * Removing an item also removes the table of contents entries pointing at
	 * it.
	 * @param {Object} edit - The normalized edit { action, guid, target_guid, position, item }.
	 * @returns {Object|null} { guid, flashGuid, contentChanged } for the affected item, or null when the edit cannot apply.
	 */
	apply_edit(edit) {
		const action = String(edit?.action || '');
		const position = edit?.position === 'above' ? 'above' : 'below';
		if (action === 'add') {
			const item = this.tool_item(edit?.item);
			if (!item) return null;
			const anchor = this.resolve_index(edit?.guid);
			const at = anchor === -1 ? this.#items.length : (position === 'above' ? anchor : anchor + 1);
			this.#items.splice(at, 0, item);
			return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
		}
		const index = this.resolve_index(edit?.guid);
		if (index === -1) return null;
		if (action === 'replace') {
			const item = this.tool_item(edit?.item);
			if (!item) return null;
			item.guid = this.#items[index].guid;
			this.#items[index] = item;
			return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
		}
		if (action === 'remove') {
			const [removed] = this.#items.splice(index, 1);
			this.#toc = this.#toc.filter(entry => entry.target_guid !== removed?.guid);
			// the removed item is gone; settle on the item above it, or the
			// new first item when the top one was removed
			const above = this.#items[index - 1] || this.#items[0];
			return { guid: removed?.guid || edit.guid, flashGuid: above?.guid || null, contentChanged: true };
		}
		if (action === 'move') {
			const target = this.resolve_index(edit?.target_guid);
			if (target === -1 || target === index) return null;
			const [moved] = this.#items.splice(index, 1);
			let at = target;
			if (index < target) at -= 1;
			if (position === 'below') at += 1;
			this.#items.splice(at, 0, moved);
			return { guid: moved.guid, flashGuid: moved.guid, contentChanged: false };
		}
		return null;
	}

	/**
	 * Applies a normalized edit_scratchpad_toc edit to the table of contents:
	 * add (new entry referencing an item), edit (retitle, redescribe, or
	 * repoint an existing entry), remove (delete an entry).
	 * @param {Object} edit - The normalized edit { action, guid, target_guid, title, entry_description }.
	 * @returns {Object|null} { guid, flashGuid } for the affected entry, or null when the edit cannot apply.
	 */
	apply_toc_edit(edit) {
		const action = String(edit?.action || '');
		if (action === 'add') {
			const target = this.resolve_index(edit?.target_guid);
			if (target === -1) return null;
			const entry = {
				guid: this.#new_guid(),
				target_guid: this.#items[target].guid,
				title: String(edit?.title || this.#items[target].value || '').slice(0, 200)
			};
			if (edit?.entry_description) entry.description = String(edit.entry_description).slice(0, 300);
			this.#toc.push(entry);
			return { guid: entry.guid, flashGuid: entry.target_guid };
		}
		const index = this.#toc.findIndex(entry => entry.guid === edit?.guid);
		if (index === -1) return null;
		const entry = this.#toc[index];
		if (action === 'edit') {
			if (edit?.target_guid && this.resolve_index(edit.target_guid) !== -1) {
				entry.target_guid = edit.target_guid;
			}
			if (edit?.title) entry.title = String(edit.title).slice(0, 200);
			if (edit?.entry_description !== undefined) {
				if (edit.entry_description) entry.description = String(edit.entry_description).slice(0, 300);
				else delete entry.description;
			}
			return { guid: entry.guid, flashGuid: entry.target_guid };
		}
		if (action === 'remove') {
			this.#toc.splice(index, 1);
			return { guid: entry.guid, flashGuid: entry.target_guid };
		}
		return null;
	}

	/**
	 * Sorts table of contents entries by the position of their target item in
	 * the items array; entries whose target no longer exists keep their
	 * relative order after the resolvable ones.
	 * @param {Array} toc - The toc entries.
	 * @param {Array} items - The scratchpad items.
	 * @returns {Array<Object>} The sorted entries.
	 */
	static sort_toc(toc, items) {
		const position = new Map((Array.isArray(items) ? items : []).map((item, index) => [item.guid, index]));
		return (Array.isArray(toc) ? toc : [])
			.map((entry, order) => ({ entry, order, at: position.get(entry?.target_guid) }))
			.sort((a, b) => {
				const aAt = a.at === undefined ? Number.MAX_SAFE_INTEGER : a.at;
				const bAt = b.at === undefined ? Number.MAX_SAFE_INTEGER : b.at;
				if (aAt !== bAt) return aAt - bAt;
				return a.order - b.order;
			})
			.map(sorted => sorted.entry);
	}

	/**
	 * Maps a content type alias to the canonical scratchpad type, or null for
	 * unknown types.
	 * @param {string} type - The raw type.
	 * @returns {string|null} The canonical type.
	 */
	static canonical_type(type) {
		const key = String(type || '').toLowerCase();
		if (ScratchpadModel.TYPES.includes(key)) return key;
		switch (key) {
			case 'diagram_mermaid':
			case 'mermaid':
				return 'diagram-mermaid';
			case 'list_step':
			case 'list':
				return 'list-step';
			case 'color_hex':
			case 'color':
				return 'color-hex';
			case 'math':
				return 'equation';
			default:
				return null;
		}
	}

	/**
	 * Removes empty inline formatting tags left behind by editing.
	 * @param {string} html - The item's inline html.
	 * @returns {string} The cleaned html.
	 */
	clean_value(html) {
		let cleaned = html || '';
		let previous = null;
		while (cleaned !== previous) {
			previous = cleaned;
			cleaned = cleaned.replace(/<(b|i|u|strong|em|code|sup|sub)[^>]*>\s*<\/\1>/gi, '');
		}
		return cleaned;
	}

	/**
	 * Normalizes a stored item for the model, assigning a guid when missing.
	 * @param {Object} item - The stored item.
	 * @returns {Object} The normalized item.
	 */
	#normalize_item(item) {
		const type = ScratchpadModel.canonical_type(item?.type) || 'text';
		const normalized = { guid: item?.guid || this.#new_guid(), type: type, value: typeof item?.value === 'string' ? item.value : '' };
		if (item?.caption !== undefined && item?.caption !== null && String(item.caption) !== '') normalized.caption = String(item.caption);
		if (type === 'header') {
			normalized.index = (Number.isInteger(item?.index) && item.index >= 1 && item.index <= 6) ? item.index : null;
		}
		if (type === 'list-step') {
			normalized.ordered = !!item?.ordered;
			normalized.index = (normalized.ordered && Number.isInteger(item?.index)) ? item.index : null;
		}
		if (type === 'code' && typeof item?.language === 'string' && item.language) {
			normalized.language = item.language;
		}
		if (type === 'table') {
			normalized['has-header'] = !!item?.['has-header'];
			normalized['table-rows'] = Array.isArray(item?.['table-rows'])
				? item['table-rows'].map(row => Array.isArray(row) ? row.map(cell => String(cell ?? '')) : [])
				: [];
		}
		if (type === 'link') {
			const source = (Array.isArray(item?.source) && item.source[0]) || {};
			if (!normalized.value && source.title) normalized.value = String(source.title);
			normalized.source = source.url ? [{ title: String(source.title || source.url), url: String(source.url) }] : [];
		}
		return normalized;
	}

	/**
	 * Normalizes a stored table of contents entry, assigning a guid when
	 * missing and dropping entries without a target.
	 * @param {Object} entry - The stored entry.
	 * @returns {Object|null} The normalized entry, or null when invalid.
	 */
	#normalize_toc_entry(entry) {
		if (!entry || typeof entry !== 'object') return null;
		const target = typeof entry.target_guid === 'string' ? entry.target_guid : '';
		if (!target) return null;
		const normalized = {
			guid: entry.guid || this.#new_guid(),
			target_guid: target,
			title: String(entry.title || '').slice(0, 200)
		};
		if (entry.description) normalized.description = String(entry.description).slice(0, 300);
		return normalized;
	}

	/**
	 * Generates a fresh identifier for an item or toc entry.
	 * @returns {string} A new guid.
	 */
	#new_guid() {
		return crypto.randomUUID();
	}

	/**
	 * Serializes the items back into the stored conversation content shape.
	 * @returns {Array<Object>} The item array.
	 */
	#serialize_items() {
		return this.#items.map(item => {
			const out = { type: item.type, value: this.clean_value(item.value), guid: item.guid };
			if (item.caption) out.caption = item.caption;
			if (item.type === 'header') out.index = item.index;
			if (item.type === 'list-step') {
				out.ordered = item.ordered;
				out.index = item.index;
			}
			if (item.type === 'code' && item.language) out.language = item.language;
			if (item.type === 'table') {
				out['has-header'] = item['has-header'];
				out['table-rows'] = item['table-rows'];
			}
			if (item.type === 'link' && item.source && item.source.length > 0) out.source = item.source;
			return out;
		});
	}
}

export default ScratchpadModel;
🌐
scratchpad-model.js ×
Type: Web, text/plain
13.29 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File