import { showNotification } from './util/notification-stack.js';
import { get_auth_storage_key } from './util/auth-storage.js';
import { customChoices } from './util/multi-choice-dialog.js';
import Migrations from './util/migrations.js';
const SYNC_DEBOUNCE_MS = 10000;
const CURSOR_ABSENT_MS = 5000;
const LAST_SYNC_KEY = 'LAST_SYNC';
const SYNC_STATE_KEY = 'SYNC_STATE';
const SYNC_CURSOR_KEY = 'SYNC_CURSOR';
/**
* Server-side conversation sync. Every save of a conversation whose SYNCED
* flag is set queues it for upload, so enabling sync is just flipping that
* conversation setting. Runs are requested by the trigger funnel (conversation
* save, focus, visibility, online, cursor reappearing after an absence,
* content panel shown, conversation switch, app start): a request starts a run
* immediately unless one is in flight or the last run started within the
* debounce window, in which case all requests in that window consolidate into
* one run at the window's end (or right after the in-flight run). Each run
* first retries server deletions requested by this device but not yet
* confirmed, then pulls server changes and pushes the queued uploads, and the
* cursor only advances when the whole run succeeds. Uploads are silent; while a
* conversation syncs in either direction its sync icons are dimmed, and
* downloads report progress as "Synchronizing (done/total)" through the bell
* notification system. The
* last sync timestamp, the per-conversation sync records, and the server
* cursor are kept in localStorage under the active user's session prefix.
*/
class Sync {
#app;
#inflight = false;
#pending = false;
#last_run_at = 0;
#trailing_timer = null;
#push_queue = new Set();
#suppress_save_hook = false;
#deleting = new Set();
#last_cursor_seen = Date.now();
#ran_post_sync_migrations = false;
constructor(app) {
this.#app = app;
this.#app.storage.on_conversation_saved = (guid) => this.#on_conversation_saved(guid);
update_last_sync_display();
window.addEventListener('focus', () => this.request());
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.request();
}
});
window.addEventListener('online', () => this.request());
window.addEventListener('mousemove', () => {
const now = Date.now();
if (now - this.#last_cursor_seen > CURSOR_ABSENT_MS) {
this.request();
}
this.#last_cursor_seen = now;
});
}
/**
* Requests a sync run. Starts one immediately unless a run is in flight or
* the last run started within the debounce window; in that case one
* consolidated run is queued for the end of the window, or right after the
* in-flight run finishes.
*/
request() {
if (this.#inflight) {
this.#pending = true;
return;
}
const elapsed = Date.now() - this.#last_run_at;
if (elapsed >= SYNC_DEBOUNCE_MS) {
void this.#run();
return;
}
if (!this.#trailing_timer) {
this.#trailing_timer = setTimeout(() => {
this.#trailing_timer = null;
void this.#run();
}, SYNC_DEBOUNCE_MS - elapsed);
}
}
/**
* Turns off server sync for a conversation: removes the server copy and
* the local sync record. The conversation's SYNCED flag is updated by the caller.
* @param {string} guid - Conversation guid.
* @returns {Promise<void>} Resolves after the server request settles.
*/
disable_conversation(guid) {
const state = this.#load_json(SYNC_STATE_KEY, {});
delete state[guid];
this.#save_json(SYNC_STATE_KEY, state);
this.#push_queue.delete(guid);
return this.#disable_on_server(guid).then(() => {});
}
/**
* Removes the server copy after a conversation was deleted locally and
* requests a sync run so the deletion settles. The guid stays flagged
* until the server confirms, so in-flight runs skip pulling it instead of
* showing the server-deleted dialog: this device already requested the
* deletion. A failed request is retried by the next run.
* @param {string} guid - Deleted conversation guid.
* @param {boolean} synced - Whether the conversation was synced.
*/
conversation_deleted(guid, synced) {
const state = this.#load_json(SYNC_STATE_KEY, {});
if (state[guid]) {
delete state[guid];
this.#save_json(SYNC_STATE_KEY, state);
}
this.#push_queue.delete(guid);
if (synced) {
void this.#disable_on_server(guid);
this.request();
}
}
/**
* Removes one guid's server copy and guards it against pulls until the
* server confirms the deletion.
* @param {string} guid - Conversation guid.
* @returns {Promise<boolean>} Whether the server confirmed the deletion.
* @private
*/
#disable_on_server(guid) {
this.#deleting.add(guid);
return this.#app.api.request({ action: 'disable', guid }, 'sync')
.then(() => {
this.#deleting.delete(guid);
return true;
})
.catch(() => false);
}
/**
* Queues a just-saved conversation for upload when it is synced and
* differs from the last synced copy.
* @param {string} guid - Saved conversation guid.
* @private
*/
#on_conversation_saved(guid) {
if (this.#suppress_save_hook) {
return;
}
const state = this.#load_json(SYNC_STATE_KEY, {});
if (!this.#needs_push(state, guid)) {
return;
}
this.#push_queue.add(guid);
this.request();
}
/**
* Returns whether a conversation is synced and differs from its last
* synced copy.
* @param {Object} state - Sync state map.
* @param {string} guid - Conversation guid.
* @returns {boolean}
* @private
*/
#needs_push(state, guid) {
const storage = this.#app.storage;
const conversation = storage.get_conversation(guid);
if (!conversation?.[storage.KEY_CONVERSATION_SYNCED]) {
return false;
}
return !state[guid] || state[guid].hash !== this.#hash_conversation(conversation);
}
/**
* Executes one sync run: pull server changes, then push queued uploads.
* Requests that arrived while it ran are re-requested afterwards, which
* either runs immediately or lands on the window's end.
* @private
*/
async #run() {
if (this.#inflight) {
this.#pending = true;
return;
}
this.#inflight = true;
this.#last_run_at = Date.now();
try {
await this.#sync_now();
} finally {
this.#inflight = false;
if (this.#pending) {
this.#pending = false;
this.request();
}
}
}
/**
* Runs one sync pass and reports progress per processed item. The cursor
* only advances when the whole pass succeeds, so failed items are retried
* on the next run.
* @private
*/
async #sync_now() {
const state = this.#load_json(SYNC_STATE_KEY, {});
for (const guid of [...this.#deleting]) {
try {
const removed = await this.#disable_on_server(guid);
if (!removed) {
this.#notify_failure(new Error('Could not delete conversation on server'));
return;
}
} catch (e) {
this.#notify_failure(e);
return;
}
}
let changes;
let cursor;
try {
const response = await this.#call({ action: 'changes', since: this.#load_json(SYNC_CURSOR_KEY, 0) });
changes = response.changes || [];
cursor = response.time;
} catch (e) {
this.#notify_failure(e);
return;
}
const actionable = changes.filter(change => state[change.guid]?.synced_at !== change.updated_at);
const total = actionable.length;
if (total === 0 && this.#push_queue.size === 0) {
this.#save_json(SYNC_CURSOR_KEY, cursor);
this.#touch_last_sync();
await this.#migrate_after_sync();
return;
}
let done = 0;
for (const change of actionable) {
if (this.#deleting.has(change.guid)) {
continue;
}
this.#set_syncing(change.guid, true);
try {
await this.#pull_conversation(state, change.guid, change.updated_at);
} catch (e) {
this.#set_syncing(change.guid, false);
this.#notify_failure(e);
return;
}
this.#set_syncing(change.guid, false);
done++;
showNotification(`Synchronizing (${done}/${total})`);
}
for (const guid of [...this.#push_queue]) {
this.#push_queue.delete(guid);
if (this.#needs_push(state, guid)) {
this.#set_syncing(guid, true);
try {
await this.#push_conversation(state, guid);
} catch (e) {
this.#push_queue.add(guid);
this.#set_syncing(guid, false);
this.#notify_failure(e);
return;
}
this.#set_syncing(guid, false);
}
}
this.#save_json(SYNC_CURSOR_KEY, cursor);
this.#touch_last_sync();
await this.#migrate_after_sync();
}
/**
* Re-runs all local storage migrations once per session, after the first
* successful sync pass, so conversations pulled in from the server are
* migrated too. Forced because the load-time pass already recorded the
* migrations as applied.
* @private
*/
async #migrate_after_sync() {
if (this.#ran_post_sync_migrations) return;
this.#ran_post_sync_migrations = true;
await new Migrations(this.#app.storage).run(true);
}
/**
* Dims or restores the sync icons of one conversation while it syncs: the
* icon in the content list item, and the one in the header when that
* conversation is currently open.
* @param {string} guid - Conversation being synced.
* @param {boolean} syncing - Whether the sync is in progress.
* @private
*/
#set_syncing(guid, syncing) {
document.querySelectorAll(`#content-${CSS.escape(guid)} .span-title-sync, #scratchpad-item-${CSS.escape(guid)} .span-title-sync`).forEach(el => {
el.classList.toggle('is-syncing', syncing);
});
const selected_guid = this.#app.storage.get_app_config()[this.#app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid === guid) {
document.querySelectorAll('#id-div-response-title .span-title-sync').forEach(el => {
el.classList.toggle('is-syncing', syncing);
});
}
}
/**
* Uploads a conversation, resolving conflict and unsynced-removal dialogs
* along the way. Transport errors throw; dialogs settle the item.
* @param {Object} state - Mutable sync state map.
* @param {string} guid - Conversation guid.
* @private
*/
async #push_conversation(state, guid) {
const storage = this.#app.storage;
const conversation = storage.get_conversation(guid);
let response = await this.#call({
action: 'push',
guid,
conversation,
known_updated_at: state[guid]?.synced_at || 0
});
if (response.conflict) {
const choice = await this.#ask_conflict(guid, 'push');
if (choice === 'mine') {
response = await this.#call({ action: 'push', guid, conversation, force: true });
} else if (choice === 'server' || choice === 'both') {
if (choice === 'both') {
this.#archive_local_copy(guid);
}
const server = await this.#call({ action: 'get', guid });
this.#write_server_conversation(state, guid, server.conversation, server.updated_at);
return;
} else {
return;
}
}
if (response.exists === false) {
await this.#resolve_server_deleted(state, guid);
return;
}
this.#record_synced(state, guid, response.updated_at, conversation);
}
/**
* Downloads one server change, asking on local conflicts.
* @param {Object} state - Mutable sync state map.
* @param {string} guid - Conversation guid.
* @param {number} updatedAt - Server updated_at for this change.
* @private
*/
async #pull_conversation(state, guid, updatedAt) {
const storage = this.#app.storage;
const known = state[guid];
if (known && known.synced_at === updatedAt) {
return;
}
const has_local = localStorage.getItem(storage.KEY_APP_CONVERSATION_PREFIX + guid) !== null;
if (has_local && (!known || known.hash !== this.#hash_conversation(storage.get_conversation(guid)))) {
const choice = await this.#ask_conflict(guid, 'pull');
if (choice === 'mine') {
const response = await this.#call({
action: 'push', guid, conversation: storage.get_conversation(guid), force: true
});
this.#record_synced(state, guid, response.updated_at, storage.get_conversation(guid));
return;
}
if (choice === 'both') {
this.#archive_local_copy(guid);
}
if (choice === null) {
return;
}
}
const response = await this.#call({ action: 'get', guid });
if (response.exists === false) {
if (known) {
await this.#resolve_server_deleted(state, guid);
}
return;
}
this.#write_server_conversation(state, guid, response.conversation, response.updated_at);
}
/**
* Asks which version wins after a sync conflict.
* @param {string} guid - Conversation guid.
* @param {('push'|'pull')} direction - Whether an upload or download hit the conflict.
* @returns {Promise<string|null>} 'mine', 'server', 'both', or null to skip.
* @private
*/
async #ask_conflict(guid, direction) {
const storage = this.#app.storage;
const conversation = storage.get_conversation(guid);
const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
const text = direction === 'push'
? 'The server copy has changed since your last sync. Which version should be kept?'
: 'A newer version exists on the server, and this copy has unsynced changes. Which version should be kept?';
const choice = await customChoices(`Sync conflict: ${title}`, text, [
{ label: 'Use my version', value: 'mine' },
{ label: 'Use server version', value: 'server' },
{ label: 'Keep both', value: 'both' },
{ label: 'Skip', value: 'skip' }
]);
return choice === 'skip' ? null : choice;
}
/**
* Stores a copy of the local conversation under a new guid so the server
* version can take over the original one.
* @param {string} guid - Original conversation guid.
* @private
*/
#archive_local_copy(guid) {
const storage = this.#app.storage;
const conversation = storage.get_conversation(guid);
const meta = storage.get_app_index().find(item => item[storage.KEY_INDEX_GUID] === guid);
const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
const copy = {
...conversation,
[storage.KEY_CONVERSATION_TITLE]: `${title} (archived)`,
[storage.KEY_CONVERSATION_ARCHIVED]: true,
[storage.KEY_CONVERSATION_SYNCED]: false
};
const new_guid = storage.update_app_index('', false, meta?.[storage.KEY_CONVERSATION_TYPE]);
this.#suppress_save_hook = true;
storage.save_conversation(new_guid, copy);
this.#suppress_save_hook = false;
}
/**
* Asks what happens to a local copy whose server copy was deleted
* elsewhere, so this device holds the only remaining version.
* @param {Object} state - Mutable sync state map.
* @param {string} guid - Conversation guid.
* @private
*/
async #resolve_server_deleted(state, guid) {
const storage = this.#app.storage;
const conversation = storage.get_conversation(guid);
const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
const choice = await customChoices(`Deleted from server: ${title}`, `"${title}" has been deleted from the server. The copy on this device is now the only remaining version. What should happen with it?`, [
{ label: 'Re-enable sync', value: 'enable' },
{ label: 'Delete this copy', value: 'delete' },
{ label: 'Make independent', value: 'independent' }
]);
if (choice === 'enable') {
const response = await this.#call({ action: 'push', guid, conversation });
this.#record_synced(state, guid, response.updated_at, conversation);
return;
}
if (choice === 'delete') {
this.#suppress_save_hook = true;
if (storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === guid) {
this.#app.close_conversation();
}
storage.index_delete(guid);
this.#suppress_save_hook = false;
this.#app.share?.conversation_deleted(guid);
return;
}
if (choice === 'independent') {
storage.update_conversation_field(guid, storage.KEY_CONVERSATION_SYNCED, false);
delete state[guid];
this.#save_json(SYNC_STATE_KEY, state);
this.#app.content_list?.on_app_index_updated?.();
}
}
/**
* Writes a server conversation into the local store, creating the index
* entry for unknown guids, and records its sync state.
* @param {Object} state - Mutable sync state map.
* @param {string} guid - Conversation guid.
* @param {Object} conversation - Server conversation data.
* @param {number} updatedAt - Server updated_at for this version.
* @private
*/
#write_server_conversation(state, guid, conversation, updatedAt) {
const storage = this.#app.storage;
this.#suppress_save_hook = true;
storage.save_conversation(guid, conversation);
this.#suppress_save_hook = false;
storage.update_app_index(guid, false, conversation?.[storage.KEY_CONVERSATION_TYPE]);
this.#record_synced(state, guid, updatedAt, storage.get_conversation(guid));
if (storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === guid) {
this.#app.chat_renderer.refresh_after_sync_down(guid);
}
}
/**
* Records a conversation's synced state: the server timestamp it matched
* and the local copy's hash.
* @param {Object} state - Mutable sync state map.
* @param {string} guid - Conversation guid.
* @param {number} updatedAt - The server updated_at now synced.
* @param {Object} conversation - The synced conversation data.
* @private
*/
#record_synced(state, guid, updatedAt, conversation) {
state[guid] = { synced_at: updatedAt, hash: this.#hash_conversation(conversation) };
this.#save_json(SYNC_STATE_KEY, state);
}
#hash_conversation(conversation) {
return this.#hash_string(JSON.stringify(conversation));
}
/**
* Computes a numeric hash of a string (cyrb53).
* @param {string} text
* @returns {number}
*/
#hash_string(text) {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (h2 >>> 0) + (h1 >>> 0);
}
/**
* Sends one sync API request, rejecting on transport or API errors.
* @param {Object} payload - Request payload including the action.
* @returns {Promise<Object>} The response object.
* @private
*/
#call(payload) {
return this.#app.api.request(payload, 'sync').then(response => {
if (!response || response.success !== true) {
throw new Error(response?.error || 'Sync request failed');
}
return response;
});
}
#notify_failure(error) {
console.error('Sync failed:', error);
showNotification('Sync failed');
}
#touch_last_sync() {
localStorage.setItem(get_auth_storage_key(LAST_SYNC_KEY), String(Date.now()));
update_last_sync_display();
}
#load_json(key, fallback) {
try {
const parsed = JSON.parse(localStorage.getItem(get_auth_storage_key(key)));
return parsed ?? fallback;
} catch {
return fallback;
}
}
#save_json(key, value) {
localStorage.setItem(get_auth_storage_key(key), JSON.stringify(value));
}
}
/**
* Shows the time of the last completed sync run in the footer, or a dash
* when this session has not synchronized yet.
*/
export function update_last_sync_display() {
const el = document.getElementById('id-span-footer-sync-time');
if (!el) return;
const stamp = parseInt(localStorage.getItem(get_auth_storage_key(LAST_SYNC_KEY)), 10);
el.textContent = Number.isFinite(stamp) && stamp > 0 ? new Date(stamp).toLocaleTimeString('en-US') : '—';
}
export default Sync;