hyperbook 0.83.0 → 0.84.1

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