hyperbook 0.83.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,752 @@
1
+ window.hyperbook = window.hyperbook || {};
2
+ window.hyperbook.cloud = (function () {
3
+ // ===== Cloud Integration =====
4
+ const AUTH_TOKEN_KEY = "hyperbook_auth_token";
5
+ const AUTH_USER_KEY = "hyperbook_auth_user";
6
+ let isLoadingFromCloud = false;
7
+
8
+ // ===== Simple Mutex =====
9
+ class Mutex {
10
+ constructor() {
11
+ this._queue = [];
12
+ this._locked = false;
13
+ }
14
+ async runExclusive(fn) {
15
+ await new Promise((resolve) => {
16
+ if (!this._locked) {
17
+ this._locked = true;
18
+ resolve();
19
+ } else {
20
+ this._queue.push(resolve);
21
+ }
22
+ });
23
+ try {
24
+ return await fn();
25
+ } finally {
26
+ if (this._queue.length > 0) {
27
+ this._queue.shift()();
28
+ } else {
29
+ this._locked = false;
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ // ===== Smart Sync Strategy =====
36
+
37
+ class SyncManager {
38
+ constructor(options = {}) {
39
+ this.debounceDelay = options.debounceDelay || 2000;
40
+ this.maxWaitTime = options.maxWaitTime || 10000;
41
+ this.minSaveInterval = options.minSaveInterval || 1000;
42
+
43
+ this.isDirty = false;
44
+ this.lastSaveTime = 0;
45
+ this.lastChangeTime = 0;
46
+ this.debounceTimer = null;
47
+ this.maxWaitTimer = null;
48
+ this.saveInProgress = false;
49
+ this.saveMutex = new Mutex();
50
+ this.retryCount = 0;
51
+
52
+ this.dirtyStores = new Set();
53
+
54
+ this.offlineQueue = [];
55
+ this.isOnline = navigator.onLine;
56
+
57
+ // Snapshots for external DB polling
58
+ this.externalDBs = options.externalDBs || [];
59
+ this.externalSnapshots = {};
60
+ this.pollInterval = options.pollInterval || 5000;
61
+ this.pollTimer = null;
62
+
63
+ this.setupEventListeners();
64
+ }
65
+
66
+ /**
67
+ * Get a fingerprint of an external DB by hashing all row data.
68
+ */
69
+ _snapshotExternalDB(dbName) {
70
+ return new Promise((resolve) => {
71
+ const request = indexedDB.open(dbName);
72
+ request.onerror = () => resolve(null);
73
+ request.onsuccess = (event) => {
74
+ const db = event.target.result;
75
+ const storeNames = Array.from(db.objectStoreNames);
76
+ if (storeNames.length === 0) {
77
+ db.close();
78
+ resolve(null);
79
+ return;
80
+ }
81
+
82
+ const parts = [];
83
+ const tx = db.transaction(storeNames, "readonly");
84
+ let pending = storeNames.length;
85
+ storeNames.forEach((name) => {
86
+ const rows = [];
87
+ const cursorReq = tx.objectStore(name).openCursor();
88
+ cursorReq.onsuccess = (e) => {
89
+ const cursor = e.target.result;
90
+ if (cursor) {
91
+ rows.push(JSON.stringify(cursor.value));
92
+ cursor.continue();
93
+ } else {
94
+ parts.push(name + ":" + rows.join(","));
95
+ pending--;
96
+ if (pending === 0) {
97
+ db.close();
98
+ resolve(parts.join("|"));
99
+ }
100
+ }
101
+ };
102
+ cursorReq.onerror = () => {
103
+ pending--;
104
+ if (pending === 0) {
105
+ db.close();
106
+ resolve(parts.join("|"));
107
+ }
108
+ };
109
+ });
110
+ };
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Take initial snapshots and start polling external DBs for changes.
116
+ */
117
+ async startPolling() {
118
+ for (const dbName of this.externalDBs) {
119
+ this.externalSnapshots[dbName] = await this._snapshotExternalDB(dbName);
120
+ }
121
+ this.pollTimer = setInterval(
122
+ () => this._pollExternalDBs(),
123
+ this.pollInterval,
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Stop polling external DBs.
129
+ */
130
+ stopPolling() {
131
+ if (this.pollTimer) {
132
+ clearInterval(this.pollTimer);
133
+ this.pollTimer = null;
134
+ }
135
+ }
136
+
137
+ async _pollExternalDBs() {
138
+ if (isLoadingFromCloud || this.saveInProgress) return;
139
+ for (const dbName of this.externalDBs) {
140
+ const current = await this._snapshotExternalDB(dbName);
141
+ const prev = this.externalSnapshots[dbName];
142
+
143
+ if (current !== prev) {
144
+ this.externalSnapshots[dbName] = current;
145
+ this.markDirty(dbName);
146
+ }
147
+ }
148
+ }
149
+
150
+ markDirty(storeName = null) {
151
+ if (isLoadingFromCloud || isReadOnlyMode()) return;
152
+ if (storeName) {
153
+ this.dirtyStores.add(storeName);
154
+ }
155
+ this.isDirty = true;
156
+ this.lastChangeTime = Date.now();
157
+ this.updateUI("unsaved");
158
+ this.scheduleSave();
159
+ }
160
+
161
+ scheduleSave() {
162
+ if (this.debounceTimer) {
163
+ clearTimeout(this.debounceTimer);
164
+ }
165
+ if (!this.maxWaitTimer) {
166
+ this.maxWaitTimer = setTimeout(() => {
167
+ this.performSave("max-wait");
168
+ }, this.maxWaitTime);
169
+ }
170
+ this.debounceTimer = setTimeout(() => {
171
+ this.performSave("debounced");
172
+ }, this.debounceDelay);
173
+ }
174
+
175
+ async performSave(reason = "manual") {
176
+ if (!this.isDirty || this.saveInProgress) return;
177
+ if (!HYPERBOOK_CLOUD || !getAuthToken()) return;
178
+ if (isReadOnlyMode()) {
179
+ this.updateUI("readonly");
180
+ return;
181
+ }
182
+
183
+ const timeSinceLastSave = Date.now() - this.lastSaveTime;
184
+ if (timeSinceLastSave < this.minSaveInterval) {
185
+ setTimeout(
186
+ () => this.performSave(reason),
187
+ this.minSaveInterval - timeSinceLastSave,
188
+ );
189
+ return;
190
+ }
191
+
192
+ return this.saveMutex.runExclusive(async () => {
193
+ this.saveInProgress = true;
194
+ this.clearTimers();
195
+ this.updateUI("saving");
196
+
197
+ try {
198
+ const dataToSave = await this.exportStores();
199
+
200
+ if (!this.isOnline) {
201
+ this.offlineQueue.push({
202
+ data: dataToSave,
203
+ timestamp: Date.now(),
204
+ });
205
+ this.updateUI("offline-queued");
206
+ return;
207
+ }
208
+
209
+ await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
210
+ method: "POST",
211
+ body: JSON.stringify({ data: dataToSave }),
212
+ });
213
+
214
+ this.isDirty = false;
215
+ this.dirtyStores.clear();
216
+ this.lastSaveTime = Date.now();
217
+ this.retryCount = 0;
218
+ this.updateUI("saved");
219
+ console.log(`✓ Saved (${reason})`);
220
+ } catch (error) {
221
+ console.error("Save failed:", error);
222
+ this.updateUI("error");
223
+ this.scheduleRetry();
224
+ } finally {
225
+ this.saveInProgress = false;
226
+ }
227
+ });
228
+ }
229
+
230
+ async exportStores() {
231
+ const hyperbookExport = await store.export({ prettyJson: false });
232
+ const sqlIdeExport = await exportExternalDB("SQL-IDE");
233
+ const learnJExport = await exportExternalDB("LearnJ");
234
+ return {
235
+ version: 1,
236
+ origin: window.location.origin,
237
+ data: {
238
+ hyperbook: JSON.parse(await hyperbookExport.text()),
239
+ sqlIde: sqlIdeExport || {},
240
+ learnJ: learnJExport || {},
241
+ },
242
+ };
243
+ }
244
+
245
+ scheduleRetry() {
246
+ const retryDelay = Math.min(30000, 1000 * Math.pow(2, this.retryCount));
247
+ this.retryCount++;
248
+ setTimeout(() => {
249
+ if (this.isDirty) {
250
+ this.performSave("retry");
251
+ }
252
+ }, retryDelay);
253
+ }
254
+
255
+ setupEventListeners() {
256
+ window.addEventListener("online", () => {
257
+ this.isOnline = true;
258
+ this.processOfflineQueue();
259
+ if (this.isDirty) {
260
+ this.updateUI("unsaved");
261
+ this.scheduleSave();
262
+ } else {
263
+ this.updateUI("saved");
264
+ }
265
+ });
266
+
267
+ window.addEventListener("offline", () => {
268
+ this.isOnline = false;
269
+ this.updateUI("offline");
270
+ });
271
+
272
+ window.addEventListener("beforeunload", (e) => {
273
+ if (this.isDirty) {
274
+ this.performSave("unload");
275
+ e.preventDefault();
276
+ e.returnValue = "";
277
+ }
278
+ });
279
+
280
+ document.addEventListener("visibilitychange", () => {
281
+ if (document.hidden && this.isDirty) {
282
+ this.performSave("visibility-change");
283
+ }
284
+ });
285
+ }
286
+
287
+ async processOfflineQueue() {
288
+ if (this.offlineQueue.length === 0) return;
289
+
290
+ console.log(`Processing ${this.offlineQueue.length} queued saves...`);
291
+ this.offlineQueue.sort((a, b) => a.timestamp - b.timestamp);
292
+
293
+ // Only send the latest queued save
294
+ const latest = this.offlineQueue[this.offlineQueue.length - 1];
295
+ try {
296
+ await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
297
+ method: "POST",
298
+ body: JSON.stringify({ data: latest.data }),
299
+ });
300
+ this.offlineQueue = [];
301
+ this.lastSaveTime = Date.now();
302
+ console.log("✓ Offline queue processed");
303
+ } catch (error) {
304
+ console.error("Failed to process offline queue:", error);
305
+ }
306
+ }
307
+
308
+ clearTimers() {
309
+ if (this.debounceTimer) {
310
+ clearTimeout(this.debounceTimer);
311
+ this.debounceTimer = null;
312
+ }
313
+ if (this.maxWaitTimer) {
314
+ clearTimeout(this.maxWaitTimer);
315
+ this.maxWaitTimer = null;
316
+ }
317
+ }
318
+
319
+ updateUI(state) {
320
+ updateSaveStatus(state, {
321
+ isDirty: this.isDirty,
322
+ lastSaveTime: this.lastSaveTime,
323
+ isOnline: this.isOnline,
324
+ queuedSaves: this.offlineQueue.length,
325
+ });
326
+ }
327
+
328
+ async manualSave() {
329
+ this.isDirty = true;
330
+ this.clearTimers();
331
+ await this.performSave("manual");
332
+ }
333
+
334
+ reset() {
335
+ this.isDirty = false;
336
+ this.dirtyStores.clear();
337
+ this.clearTimers();
338
+ this.stopPolling();
339
+ this.offlineQueue = [];
340
+ this.retryCount = 0;
341
+ }
342
+ }
343
+
344
+ // Check URL hash for impersonation token (cross-domain handoff)
345
+ let isImpersonationLoad = false;
346
+ (function checkImpersonationHash() {
347
+ if (!HYPERBOOK_CLOUD) return;
348
+ const hash = window.location.hash;
349
+ const match = hash.match(/^#impersonate=(.+)$/);
350
+ if (match) {
351
+ const token = match[1];
352
+ try {
353
+ // Validate token format before decoding
354
+ if (token.split(".").length !== 3) {
355
+ throw new Error("Invalid token format");
356
+ }
357
+ const payload = JSON.parse(atob(token.split(".")[1]));
358
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
359
+ localStorage.setItem(
360
+ AUTH_USER_KEY,
361
+ JSON.stringify({
362
+ id: payload.id,
363
+ username: payload.username,
364
+ }),
365
+ );
366
+ isImpersonationLoad = true;
367
+ // Clean the hash from URL without triggering navigation
368
+ history.replaceState(
369
+ null,
370
+ "",
371
+ window.location.pathname + window.location.search,
372
+ );
373
+ } catch (e) {
374
+ console.error("Invalid impersonation token in URL:", e);
375
+ }
376
+ }
377
+ })();
378
+
379
+ /**
380
+ * Get current auth token
381
+ */
382
+ function getAuthToken() {
383
+ return localStorage.getItem(AUTH_TOKEN_KEY);
384
+ }
385
+
386
+ /**
387
+ * Set auth token
388
+ */
389
+ function setAuthToken(token, user) {
390
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
391
+ if (user) {
392
+ localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Clear auth token
398
+ */
399
+ function clearAuthToken() {
400
+ localStorage.removeItem(AUTH_TOKEN_KEY);
401
+ localStorage.removeItem(AUTH_USER_KEY);
402
+ }
403
+
404
+ /**
405
+ * Get current user
406
+ */
407
+ function getAuthUser() {
408
+ const userJson = localStorage.getItem(AUTH_USER_KEY);
409
+ return userJson ? JSON.parse(userJson) : null;
410
+ }
411
+
412
+ /**
413
+ * Check if current session is read-only (impersonation mode)
414
+ */
415
+ function isReadOnlyMode() {
416
+ const token = getAuthToken();
417
+ if (!token) return false;
418
+
419
+ try {
420
+ // Validate token format before decoding
421
+ if (token.split(".").length !== 3) {
422
+ return false;
423
+ }
424
+ // Decode JWT without verification (just to check readonly flag)
425
+ const payload = JSON.parse(atob(token.split(".")[1]));
426
+ return payload.readonly === true;
427
+ } catch (error) {
428
+ console.error("Error checking read-only mode:", error);
429
+ return false;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Make API request to cloud
435
+ */
436
+ async function apiRequest(endpoint, options = {}) {
437
+ if (!HYPERBOOK_CLOUD) return null;
438
+
439
+ const token = getAuthToken();
440
+ const headers = {
441
+ "Content-Type": "application/json",
442
+ ...options.headers,
443
+ };
444
+
445
+ if (token) {
446
+ headers["Authorization"] = `Bearer ${token}`;
447
+ }
448
+
449
+ try {
450
+ const response = await fetch(`${HYPERBOOK_CLOUD.url}${endpoint}`, {
451
+ ...options,
452
+ headers,
453
+ });
454
+
455
+ if (response.status === 401 || response.status === 403) {
456
+ // Token expired or invalid
457
+ clearAuthToken();
458
+ showLogin();
459
+ throw new Error("Authentication required");
460
+ }
461
+
462
+ if (response.status === 404) {
463
+ return null;
464
+ }
465
+
466
+ const data = await response.json();
467
+
468
+ if (!response.ok) {
469
+ throw new Error(data.error || "Request failed");
470
+ }
471
+
472
+ return data;
473
+ } catch (error) {
474
+ console.error("Cloud API error:", error);
475
+ throw error;
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Login to cloud
481
+ */
482
+ async function hyperbookLogin(username, password) {
483
+ if (!HYPERBOOK_CLOUD) {
484
+ throw new Error("Cloud not configured");
485
+ }
486
+
487
+ try {
488
+ const data = await apiRequest("/api/auth/login", {
489
+ method: "POST",
490
+ body: JSON.stringify({ username, password }),
491
+ });
492
+
493
+ setAuthToken(data.token, data.user);
494
+
495
+ // Load store data after login
496
+ await loadFromCloud();
497
+
498
+ // Reload so components pick up the imported state
499
+ window.location.reload();
500
+
501
+ return data.user;
502
+ } catch (error) {
503
+ throw error;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Logout from cloud
509
+ */
510
+ function hyperbookLogout() {
511
+ clearAuthToken();
512
+ updateUserUI(null);
513
+ }
514
+
515
+ /**
516
+ * Load store data from cloud
517
+ */
518
+ async function loadFromCloud() {
519
+ if (!HYPERBOOK_CLOUD || !getAuthToken()) return;
520
+
521
+ isLoadingFromCloud = true;
522
+
523
+ try {
524
+ const data = await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`);
525
+
526
+ if (data && data.data) {
527
+ // Import data into local stores
528
+ const storeData = data.data.data || data.data;
529
+ const { hyperbook, sqlIde, learnJ } = storeData;
530
+
531
+ if (hyperbook) {
532
+ const blob = new Blob([JSON.stringify(hyperbook)], {
533
+ type: "application/json",
534
+ });
535
+ await store.import(blob, { clearTablesBeforeImport: true });
536
+ }
537
+
538
+ if (sqlIde) {
539
+ await importExternalDB("SQL-IDE", sqlIde);
540
+ }
541
+
542
+ if (learnJ) {
543
+ await importExternalDB("LearnJ", learnJ);
544
+ }
545
+
546
+ console.log("✓ Store loaded from cloud");
547
+ }
548
+ } catch (error) {
549
+ if (error.message !== "No store data found") {
550
+ console.error("Failed to load from cloud:", error);
551
+ }
552
+ } finally {
553
+ isLoadingFromCloud = false;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Initialize cloud integration
559
+ */
560
+ async function initCloudIntegration() {
561
+ // Load from cloud if authenticated
562
+ if (HYPERBOOK_CLOUD && getAuthToken()) {
563
+ try {
564
+ await loadFromCloud();
565
+
566
+ // Reload page after impersonation data is imported so components pick up the new state
567
+ if (isImpersonationLoad) {
568
+ window.location.reload();
569
+ return;
570
+ }
571
+
572
+ // Show readonly indicator if in impersonation mode
573
+ if (isReadOnlyMode()) {
574
+ updateSaveStatus("readonly");
575
+ }
576
+ } catch (error) {
577
+ console.error("Initial cloud load failed:", error);
578
+ }
579
+ }
580
+
581
+ // Initialize SyncManager for cloud syncing
582
+ let syncManager = null;
583
+ if (HYPERBOOK_CLOUD && getAuthToken() && !isReadOnlyMode()) {
584
+ syncManager = new SyncManager({
585
+ debounceDelay: 2000,
586
+ maxWaitTime: 10000,
587
+ minSaveInterval: 1000,
588
+ externalDBs: ["SQL-IDE", "LearnJ"],
589
+ pollInterval: 5000,
590
+ });
591
+
592
+ // Hook Dexie tables to track changes (skip currentState — ephemeral UI data)
593
+ store.tables.forEach((table) => {
594
+ if (table.name === "currentState") return;
595
+ table.hook("creating", () => syncManager.markDirty(table.name));
596
+ table.hook("updating", () => syncManager.markDirty(table.name));
597
+ table.hook("deleting", () => syncManager.markDirty(table.name));
598
+ });
599
+
600
+ // Start polling external DBs for changes
601
+ syncManager.startPolling();
602
+
603
+ window.hyperbookManualSave = () => syncManager.manualSave();
604
+ }
605
+ }
606
+
607
+ // ===== Cloud UI Functions =====
608
+ const updateUserIconState = (state) => {
609
+ const icon = document.querySelector('.user-icon');
610
+ if (!icon) return;
611
+ icon.setAttribute('data-state', state);
612
+ };
613
+
614
+ const updateUserUI = (user) => {
615
+ const loginForm = document.getElementById('user-login-form');
616
+ const userInfo = document.getElementById('user-info');
617
+
618
+ if (!loginForm || !userInfo) return;
619
+
620
+ if (user) {
621
+ loginForm.classList.add('hidden');
622
+ userInfo.classList.remove('hidden');
623
+ document.getElementById('user-display-name').textContent = user.username;
624
+ updateUserIconState('logged-in');
625
+ } else {
626
+ loginForm.classList.remove('hidden');
627
+ userInfo.classList.add('hidden');
628
+ const passwordField = document.getElementById('user-password');
629
+ if (passwordField) passwordField.value = '';
630
+ updateUserIconState('not-logged-in');
631
+ }
632
+ };
633
+
634
+ const updateSaveStatus = (status, metadata = {}) => {
635
+ const statusEl = document.getElementById('user-save-status');
636
+ if (!statusEl) return;
637
+
638
+ statusEl.className = status;
639
+
640
+ if (status === 'unsaved') {
641
+ statusEl.textContent = i18n.get('user-unsaved', {}, 'Unsaved changes');
642
+ updateUserIconState('logged-in');
643
+ } else if (status === 'saving') {
644
+ statusEl.textContent = i18n.get('user-saving', {}, 'Saving...');
645
+ updateUserIconState('syncing');
646
+ } else if (status === 'saved') {
647
+ statusEl.textContent = i18n.get('user-saved', {}, 'Saved');
648
+ updateUserIconState('synced');
649
+ } else if (status === 'error') {
650
+ statusEl.textContent = i18n.get('user-save-error', {}, 'Save Error');
651
+ updateUserIconState('unsynced');
652
+ } else if (status === 'offline') {
653
+ statusEl.textContent = i18n.get('user-offline', {}, 'Offline');
654
+ updateUserIconState('unsynced');
655
+ } else if (status === 'offline-queued') {
656
+ statusEl.textContent = i18n.get('user-offline-queued', {}, 'Saved locally');
657
+ updateUserIconState('logged-in');
658
+ } else if (status === 'readonly') {
659
+ statusEl.textContent = i18n.get('user-readonly', {}, 'Read-Only Mode');
660
+ statusEl.className = 'readonly';
661
+ updateUserIconState('synced');
662
+ }
663
+ };
664
+
665
+ const showLogin = () => {
666
+ const drawer = document.getElementById('user-drawer');
667
+ if (drawer && !drawer.hasAttribute('open')) {
668
+ drawer.setAttribute('open', '');
669
+ updateUserUI(null);
670
+ }
671
+ };
672
+
673
+ const userToggle = () => {
674
+ const drawer = document.getElementById('user-drawer');
675
+ if (drawer) {
676
+ drawer.toggleAttribute('open');
677
+ const user = getAuthUser();
678
+ updateUserUI(user);
679
+ }
680
+ };
681
+
682
+ const doLogin = async () => {
683
+ const username = document.getElementById('user-username').value;
684
+ const password = document.getElementById('user-password').value;
685
+ const errorEl = document.getElementById('user-login-error');
686
+
687
+ if (!username || !password) {
688
+ errorEl.textContent = i18n.get('user-login-required', {}, 'Username and password required');
689
+ return;
690
+ }
691
+
692
+ try {
693
+ const user = await hyperbookLogin(username, password);
694
+ updateUserUI(user);
695
+ errorEl.textContent = '';
696
+ } catch (error) {
697
+ errorEl.textContent = error.message || i18n.get('user-login-failed', {}, 'Login failed');
698
+ }
699
+ };
700
+
701
+ const doLogout = () => {
702
+ if (confirm(i18n.get('user-logout-confirm', {}, 'Are you sure you want to logout?'))) {
703
+ hyperbookLogout();
704
+ updateUserUI(null);
705
+
706
+ const drawer = document.getElementById('user-drawer');
707
+ if (drawer) {
708
+ drawer.removeAttribute('open');
709
+ }
710
+ }
711
+ };
712
+
713
+ // Initialize user UI on load
714
+ document.addEventListener('DOMContentLoaded', () => {
715
+ const user = getAuthUser();
716
+ if (user) {
717
+ updateUserUI(user);
718
+ }
719
+
720
+ // Show impersonation banner if in readonly mode
721
+ if (isReadOnlyMode()) {
722
+ const banner = document.createElement('div');
723
+ banner.id = 'impersonation-banner';
724
+ banner.innerHTML = `
725
+ <span>${i18n.get('user-impersonating', {}, 'Impersonating')}: <strong>${user ? user.username : ''}</strong> — ${i18n.get('user-readonly', {}, 'Read-Only Mode')}</span>
726
+ <a href="#" id="exit-impersonation">${i18n.get('user-exit-impersonation', {}, 'Exit Impersonation')}</a>
727
+ `;
728
+ document.body.prepend(banner);
729
+
730
+ document.getElementById('exit-impersonation').addEventListener('click', (e) => {
731
+ e.preventDefault();
732
+ hyperbookLogout();
733
+ window.close();
734
+ window.location.reload();
735
+ });
736
+ }
737
+ });
738
+
739
+ return {
740
+ login: hyperbookLogin,
741
+ logout: hyperbookLogout,
742
+ getAuthUser,
743
+ isReadOnlyMode,
744
+ initCloudIntegration,
745
+ updateUserUI,
746
+ updateSaveStatus,
747
+ showLogin,
748
+ userToggle,
749
+ doLogin,
750
+ doLogout,
751
+ }
752
+ })();