0 directories, 46 files

util

Home / tinai / util
/**
 * One-time local storage migrations.
 *
 * Each migration rewrites data stored by an older version of the app so
 * runtime code never needs legacy fallbacks. Migrations run once per user
 * when the app loads, tracked under the MIGRATIONS key. Append new
 * migrations to the end of the MIGRATIONS list; never reorder or edit
 * existing entries.
 */

import Api from '../api.js';
import ScratchpadModel from '../scratchpad-model.js';
import { strip_tags } from './dom-utils.js';

/**
 * @typedef {Object} Migration
 * @property {string} id - Stable identifier recorded once the migration runs.
 * @property {function(Storage)} run - Applies the migration to local storage.
 * @property {boolean} [once] - Never re-run, even on a forced pass; for data
 *   fixes unrelated to conversation content, such as cache resets.
 */

/** @type {Migration[]} */
const MIGRATIONS = [
	{
		// Conversations created before the Notebook -> Documents rename
		id: 'notebook-type-rename',
		run: (storage) => {
			const index = storage.get_app_index();
			let changed = false;
			for (const item of index) {
				const guid = item[storage.KEY_INDEX_GUID];
				if (item[storage.KEY_CONVERSATION_TYPE] === 'NOTEBOOK') {
					item[storage.KEY_CONVERSATION_TYPE] = storage.CONVERSATION_TYPE_DOCUMENT;
					changed = true;
				}
				const raw = localStorage.getItem(storage.KEY_APP_CONVERSATION_PREFIX + guid);
				if (!raw) continue;
				const conversation = JSON.parse(raw);
				if (conversation[storage.KEY_CONVERSATION_TYPE] === 'NOTEBOOK') {
					conversation[storage.KEY_CONVERSATION_TYPE] = storage.CONVERSATION_TYPE_DOCUMENT;
					localStorage.setItem(storage.KEY_APP_CONVERSATION_PREFIX + guid, JSON.stringify(conversation));
					changed = true;
				}
			}
			if (changed) {
				localStorage.setItem(storage.KEY_APP_INDEX, JSON.stringify(index));
			}
		}
	},
	{
		// The Attachments document list moved from the 'DOCUMENTS' config key
		// to 'APP_DOCUMENTS', freeing 'DOCUMENTS' for the Documents feature toggle
		id: 'app-documents-key-rename',
		run: (storage) => {
			const config = storage.get_app_config();
			if (Array.isArray(config[storage.KEY_CONFIG_DOCUMENTS]) && config[storage.KEY_CONFIG_APP_DOCUMENTS] === undefined) {
				config[storage.KEY_CONFIG_APP_DOCUMENTS] = config[storage.KEY_CONFIG_DOCUMENTS];
				delete config[storage.KEY_CONFIG_DOCUMENTS];
				localStorage.setItem(storage.KEY_APP_CONFIG, JSON.stringify(config));
			}
		}
	},
	{
		// Conversations from before the fine-grained model keys store only the
		// MODEL preset key; derive family/version/thinking from the preset.
		// MODEL itself is kept: it still selects the preset.
		id: 'conversation-model-keys',
		run: (storage) => {
			const index = storage.get_app_index();
			for (const item of index) {
				const key = storage.KEY_APP_CONVERSATION_PREFIX + item[storage.KEY_INDEX_GUID];
				const raw = localStorage.getItem(key);
				if (!raw) continue;
				const conversation = JSON.parse(raw);
				if (!conversation[storage.KEY_CONVERSATION_MODEL]) continue;
				const preset = Api.PRESETS[conversation[storage.KEY_CONVERSATION_MODEL]];
				if (!preset) continue;
				let changed = false;
				if (conversation[storage.KEY_CONVERSATION_MODEL_FAMILY] === undefined) {
					conversation[storage.KEY_CONVERSATION_MODEL_FAMILY] = preset.provider || 'gemini';
					changed = true;
				}
				if (conversation[storage.KEY_CONVERSATION_MODEL_VERSION] === undefined) {
					conversation[storage.KEY_CONVERSATION_MODEL_VERSION] = preset.model || 'gemini-3.5-flash-lite';
					changed = true;
				}
				if (conversation[storage.KEY_CONVERSATION_THINKING_LEVEL] === undefined) {
					conversation[storage.KEY_CONVERSATION_THINKING_LEVEL] = preset.thinking !== undefined ? preset.thinking : null;
					changed = true;
				}
				if (changed) {
					localStorage.setItem(key, JSON.stringify(conversation));
				}
			}
		}
	},
		{
			// The experimental feature checkboxes once saved flags under literal
			// 'KEY_CONFIG_*' keys while readers use the plain feature keys
			id: 'feature-flag-key-fix',
			run: (storage) => {
				const features = ['BACKGROUND_KEEP_ALIVE', 'ENABLE_MISTRAL', 'ORGANIZER', 'ATTACHMENTS'];
				const config = storage.get_app_config();
				let changed = false;
				for (const key of features) {
					const legacy = 'KEY_CONFIG_' + key;
					if (config[legacy] === undefined) continue;
					if (config[key] === undefined) {
						config[key] = config[legacy];
					}
					delete config[legacy];
					changed = true;
				}
				if (changed) {
					localStorage.setItem(storage.KEY_APP_CONFIG, JSON.stringify(config));
				}
			}
		},
		{
			// Gemini 3.5 Flash was replaced by 3.6 Flash and 3.7 Flash was retired;
			// remap stored versions so conversations keep calling existing models
			id: 'deprecated-gemini-flash-versions',
			run: (storage) => {
				const replacements = {
					'gemini-3.5-flash': 'gemini-3.6-flash',
					'gemini-3.7-flash': 'gemini-3.8-flash'
				};
				const config = storage.get_app_config();
				if (replacements[config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION]]) {
					config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] = replacements[config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION]];
					localStorage.setItem(storage.KEY_APP_CONFIG, JSON.stringify(config));
				}
				const index = storage.get_app_index();
				for (const item of index) {
					const key = storage.KEY_APP_CONVERSATION_PREFIX + item[storage.KEY_INDEX_GUID];
					const raw = localStorage.getItem(key);
					if (!raw) continue;
					const conversation = JSON.parse(raw);
					if (replacements[conversation[storage.KEY_CONVERSATION_MODEL_VERSION]]) {
						conversation[storage.KEY_CONVERSATION_MODEL_VERSION] = replacements[conversation[storage.KEY_CONVERSATION_MODEL_VERSION]];
						localStorage.setItem(key, JSON.stringify(conversation));
					}
				}
			}
		},
		{
			// Scratchpad revisions switched from re-emitted content[] arrays to a
			// guid-addressed items model with an external table of contents:
			// convert every revision's content into items, seed the latest
			// revision's TOC from its headers, and drop the legacy content arrays.
			id: 'scratchpad-items-model',
			run: (storage) => {
				const index = storage.get_app_index();
				for (const item of index) {
					if (item[storage.KEY_CONVERSATION_TYPE] !== storage.CONVERSATION_TYPE_SCRATCHPAD) continue;
					const key = storage.KEY_APP_CONVERSATION_PREFIX + item[storage.KEY_INDEX_GUID];
					const raw = localStorage.getItem(key);
					if (!raw) continue;
					try {
						const conversation = JSON.parse(raw);
						const history = Array.isArray(conversation[storage.KEY_CONVERSATION_HISTORY]) ? conversation[storage.KEY_CONVERSATION_HISTORY] : [];
						let changed = false;
						for (const revision of history) {
							if (!revision || Array.isArray(revision.items) || !Array.isArray(revision.content)) continue;
							const model = new ScratchpadModel(revision.content, []);
							revision.items = model.get_state().items;
							delete revision.content;
							changed = true;
						}
						const latest = history[history.length - 1];
						if (latest && Array.isArray(latest.items) && !Array.isArray(latest.toc)) {
							latest.toc = latest.items
								.filter(entry => entry.type === 'header')
								.map(entry => ({
									guid: crypto.randomUUID(),
									target_guid: entry.guid,
									title: strip_tags(entry.value || '').replace(/\s+/g, ' ').trim().slice(0, 200)
								}))
								.filter(entry => entry.title);
							changed = true;
						}
						if (changed) {
							localStorage.setItem(key, JSON.stringify(conversation));
						}
					} catch (error) {
						console.warn('scratchpad-items-model migration skipped a conversation:', error);
					}
				}
			}
		},
		{
			// Voice settings switched from Google voice names to our own names
			id: 'tts-voice-names',
			run: (storage) => {
				const config = storage.get_app_config();
				const mapping = {
					ludo: 'avery',
					elio: 'bob',
					pulcherrima: 'charlie',
					nika: 'diana',
					laomedeia: 'edith'
				};
				const old = config[storage.KEY_CONFIG_TTS_VOICE];
				if (old && mapping[old]) {
					config[storage.KEY_CONFIG_TTS_VOICE] = mapping[old];
					localStorage.setItem(storage.KEY_APP_CONFIG, JSON.stringify(config));
				}
			}
		},
		{
			// Audio cached before the TTS voice request format fix was
			// synthesized with the wrong voices: purge it so readbacks
			// re-synthesize with the correct voices
			id: 'tts-audio-cache-reset',
			once: true,
			run: async (storage) => {
				await storage.clear_cached_audio();
			}
		},
		{
			// The per-conversation search toggle was stored under 'GOOGLE_SEARCH';
			// move the saved value to 'SEARCH'
			id: 'conversation-search-key',
			run: (storage) => {
				const index = storage.get_app_index();
				for (const item of index) {
					const key = storage.KEY_APP_CONVERSATION_PREFIX + item[storage.KEY_INDEX_GUID];
					const raw = localStorage.getItem(key);
					if (!raw) continue;
					const conversation = JSON.parse(raw);
					if (conversation.GOOGLE_SEARCH === undefined) continue;
					if (conversation.SEARCH === undefined) {
						conversation.SEARCH = conversation.GOOGLE_SEARCH;
					}
					delete conversation.GOOGLE_SEARCH;
					localStorage.setItem(key, JSON.stringify(conversation));
				}
			}
		},
		{
			// Gemini 2.5 Flash Lite was removed; remap stored versions so
			// conversations keep calling an existing model, and reset a saved
			// utility model of the removed gemini-2.5 key to the default
			id: 'removed-gemini-2-5',
			run: (storage) => {
				const replacements = {
					'gemini-2.5-flash-lite': 'gemini-3.1-flash-lite'
				};
				const config = storage.get_app_config();
				let configChanged = false;
				if (replacements[config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION]]) {
					config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] = replacements[config[storage.KEY_CONFIG_DEFAULT_MODEL_VERSION]];
					configChanged = true;
				}
				if (config[storage.KEY_CONFIG_UTILITY_MODEL] === 'gemini-2.5') {
					config[storage.KEY_CONFIG_UTILITY_MODEL] = 'gemini-3.5-fl';
					configChanged = true;
				}
				if (configChanged) {
					localStorage.setItem(storage.KEY_APP_CONFIG, JSON.stringify(config));
				}
				const index = storage.get_app_index();
				for (const item of index) {
					const key = storage.KEY_APP_CONVERSATION_PREFIX + item[storage.KEY_INDEX_GUID];
					const raw = localStorage.getItem(key);
					if (!raw) continue;
					const conversation = JSON.parse(raw);
					if (replacements[conversation[storage.KEY_CONVERSATION_MODEL_VERSION]]) {
						conversation[storage.KEY_CONVERSATION_MODEL_VERSION] = replacements[conversation[storage.KEY_CONVERSATION_MODEL_VERSION]];
						localStorage.setItem(key, JSON.stringify(conversation));
					}
				}
			}
		}
];

/**
 * Applies pending migrations for the active user.
 */
class Migrations {

	KEY_MIGRATIONS = 'MIGRATIONS';

	#storage;
	#applied;

	/**
	 * Initializes the Migrations manager for the active user's storage.
	 * @param {Storage} storage - Storage manager instance (must be initialized).
	 */
	constructor(storage) {
		this.#storage = storage;
		this.#applied = new Set(this.get_applied_migrations());
	}

	/**
	 * Retrieves the ids of migrations already applied for the active user.
	 * @returns {string[]}
	 */
	get_applied_migrations() {
		return JSON.parse(localStorage.getItem(this.#storage.user_id_prefix + this.KEY_MIGRATIONS) || '[]');
	}

	/**
	 * Runs each migration, by default only those not yet applied for the
	 * active user. When forced, re-runs every migration so data that arrived
	 * after the load-time pass — such as conversations pulled in by the
	 * initial server sync — is migrated too; migrations flagged once are
	 * skipped. Asynchronous migrations are awaited before the next one starts.
	 * @param {boolean} [force=false] - Re-run all migrations, even applied ones.
	 * @returns {Promise<void>}
	 */
	async run(force = false) {
		const pending = MIGRATIONS.filter(migration => (force && !migration.once) || !this.#applied.has(migration.id));
		for (const migration of pending) {
			await migration.run(this.#storage);
			this.#applied.add(migration.id);
		}
		if (pending.length > 0) {
			localStorage.setItem(this.#storage.user_id_prefix + this.KEY_MIGRATIONS, JSON.stringify([...this.#applied]));
		}
	}
}

export default Migrations;
🌐
migrations.js ×
Type: Web, text/x-java
12.11 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File