-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
/**
* 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
Type: Web, text/x-java
12.11 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File