mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
refactor: extract shared DB upgrade logic and encapsulate keyboard layout state
- Extract IndexedDB schema upgrade logic to shared db-upgrade.ts to prevent maintenance drift between OperationLogStoreService and ArchiveStoreService - Create KeyboardLayoutService to encapsulate global mutable keyboard layout state, improving testability and SSR compatibility - Maintain backwards compatibility with existing tests via fallback to deprecated userKbLayout when service is not connected
This commit is contained in:
parent
51f3c892e3
commit
3bb58f83fc
6 changed files with 200 additions and 114 deletions
|
|
@ -66,7 +66,8 @@ import { WorkContextThemeCfg } from './features/work-context/work-context.model'
|
|||
import { isInputElement } from './util/dom-element';
|
||||
import { MobileBottomNavComponent } from './core-ui/mobile-bottom-nav/mobile-bottom-nav.component';
|
||||
import { StartupService } from './core/startup/startup.service';
|
||||
import { saveUserKbLayout } from './util/check-key-combo';
|
||||
import { KeyboardLayoutService } from './core/keyboard-layout/keyboard-layout.service';
|
||||
import { setKeyboardLayoutService } from './util/check-key-combo';
|
||||
|
||||
const w = window as Window & { productivityTips?: string[][]; randomIndex?: number };
|
||||
const productivityTip: string[] | undefined =
|
||||
|
|
@ -129,6 +130,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
private _ngZone = inject(NgZone);
|
||||
private _document = inject(DOCUMENT, { optional: true });
|
||||
private _startupService = inject(StartupService);
|
||||
private _keyboardLayoutService = inject(KeyboardLayoutService);
|
||||
|
||||
readonly syncTriggerService = inject(SyncTriggerService);
|
||||
readonly imexMetaService = inject(ImexViewService);
|
||||
|
|
@ -228,7 +230,9 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
});
|
||||
|
||||
// ! For keyboard shortcuts to work correctly with any layouts (QWERTZ/AZERTY/etc) - user's keyboard layout must be presaved
|
||||
saveUserKbLayout();
|
||||
// Connect the service to the utility functions and save the layout
|
||||
setKeyboardLayoutService(this._keyboardLayoutService);
|
||||
this._keyboardLayoutService.saveUserLayout();
|
||||
}
|
||||
|
||||
skipInitialSync(): void {
|
||||
|
|
|
|||
75
src/app/core/keyboard-layout/keyboard-layout.service.ts
Normal file
75
src/app/core/keyboard-layout/keyboard-layout.service.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface NavigatorWithKeyboard {
|
||||
keyboard?: NavigatorKeyboard;
|
||||
}
|
||||
|
||||
export interface NavigatorKeyboard {
|
||||
getLayoutMap: () => Promise<Map<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Map where keys are string representations of key codes,
|
||||
* and values are the corresponding characters or symbols for this layout.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap#browser_compatibility
|
||||
*/
|
||||
export type KeyboardLayout = Map<string, string>;
|
||||
|
||||
/**
|
||||
* Service that manages the user's keyboard layout mapping.
|
||||
*
|
||||
* Encapsulates the keyboard layout state to avoid global mutable state,
|
||||
* which caused issues in parallel tests and SSR scenarios.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class KeyboardLayoutService {
|
||||
private _layout: KeyboardLayout = new Map();
|
||||
|
||||
/**
|
||||
* Gets the current keyboard layout map.
|
||||
* Returns an empty map if the layout hasn't been initialized.
|
||||
*/
|
||||
get layout(): KeyboardLayout {
|
||||
return this._layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the user's keyboard layout mapping from the browser's Keyboard API.
|
||||
* Should be called once during app initialization.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||
*/
|
||||
async saveUserLayout(): Promise<void> {
|
||||
// If browser doesn't support keyboard API
|
||||
if (!('keyboard' in navigator)) return;
|
||||
|
||||
const keyboard = (navigator as NavigatorWithKeyboard).keyboard;
|
||||
if (!keyboard) return;
|
||||
|
||||
const kbLayout = await keyboard.getLayoutMap();
|
||||
this._layout.clear();
|
||||
kbLayout.forEach((value, key) => this._layout.set(key, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the keyboard layout. Used for testing.
|
||||
* @internal
|
||||
*/
|
||||
clear(): void {
|
||||
this._layout.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the keyboard layout directly. Used for testing.
|
||||
* @internal
|
||||
*/
|
||||
setLayout(layout: KeyboardLayout): void {
|
||||
this._layout.clear();
|
||||
layout.forEach((value, key) => this._layout.set(key, value));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { IDBPDatabase, openDB } from 'idb';
|
||||
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
|
||||
import {
|
||||
DB_NAME,
|
||||
DB_VERSION,
|
||||
STORE_NAMES,
|
||||
SINGLETON_KEY,
|
||||
OPS_INDEXES,
|
||||
} from './db-keys.const';
|
||||
import { DB_NAME, DB_VERSION, STORE_NAMES, SINGLETON_KEY } from './db-keys.const';
|
||||
import { runDbUpgrade } from './db-upgrade';
|
||||
|
||||
/**
|
||||
* Entry stored in archive_young or archive_old object stores.
|
||||
|
|
@ -62,52 +57,15 @@ export class ArchiveStoreService {
|
|||
/**
|
||||
* Opens the SUP_OPS database for archive operations.
|
||||
*
|
||||
* IMPORTANT: This includes the COMPLETE upgrade callback to ensure ALL stores
|
||||
* are created if ArchiveStoreService opens the database before OperationLogStoreService.
|
||||
* IndexedDB only runs ONE upgrade callback per version transition, so whichever
|
||||
* service opens the DB first MUST create all stores.
|
||||
*
|
||||
* This is critical for test environments where browser state may be cleared between
|
||||
* test runs, causing a fresh database to be created by whichever service initializes first.
|
||||
* Uses shared runDbUpgrade() to ensure ALL stores are created if ArchiveStoreService
|
||||
* opens the database before OperationLogStoreService. IndexedDB only runs ONE upgrade
|
||||
* callback per version transition, so whichever service opens the DB first MUST create
|
||||
* all stores.
|
||||
*/
|
||||
private async _init(): Promise<void> {
|
||||
this._db = await openDB<ArchiveDBSchema>(DB_NAME, DB_VERSION, {
|
||||
upgrade: (db, oldVersion, _newVersion, transaction) => {
|
||||
// IMPORTANT: This must mirror OperationLogStoreService.init() upgrade logic exactly!
|
||||
// If this service opens the DB first, it must create ALL stores.
|
||||
|
||||
// Version 1: Create initial stores
|
||||
if (oldVersion < 1) {
|
||||
const opStore = db.createObjectStore(STORE_NAMES.OPS, {
|
||||
keyPath: 'seq',
|
||||
autoIncrement: true,
|
||||
});
|
||||
opStore.createIndex(OPS_INDEXES.BY_ID, 'op.id', { unique: true });
|
||||
opStore.createIndex(OPS_INDEXES.BY_SYNCED_AT, 'syncedAt');
|
||||
|
||||
db.createObjectStore(STORE_NAMES.STATE_CACHE, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.IMPORT_BACKUP, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
// Version 2: Add vector_clock store for atomic writes
|
||||
if (oldVersion < 2) {
|
||||
db.createObjectStore(STORE_NAMES.VECTOR_CLOCK);
|
||||
}
|
||||
|
||||
// Version 3: Add compound index for efficient source+status queries
|
||||
if (oldVersion < 3) {
|
||||
const opStore = transaction.objectStore(STORE_NAMES.OPS);
|
||||
opStore.createIndex(OPS_INDEXES.BY_SOURCE_AND_STATUS, [
|
||||
'source',
|
||||
'applicationStatus',
|
||||
]);
|
||||
}
|
||||
|
||||
// Version 4: Add archive stores for archiveYoung and archiveOld
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_YOUNG, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_OLD, { keyPath: 'id' });
|
||||
}
|
||||
runDbUpgrade(db, oldVersion, transaction);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
64
src/app/op-log/persistence/db-upgrade.ts
Normal file
64
src/app/op-log/persistence/db-upgrade.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Shared IndexedDB upgrade logic for SUP_OPS database.
|
||||
*
|
||||
* CRITICAL: This upgrade function MUST be used by ALL services that open the SUP_OPS database.
|
||||
* IndexedDB only runs ONE upgrade callback per version transition - whichever service opens
|
||||
* the database first MUST create ALL stores.
|
||||
*
|
||||
* This shared function ensures schema consistency regardless of service initialization order.
|
||||
*/
|
||||
|
||||
import { IDBPDatabase, IDBPTransaction } from 'idb';
|
||||
import { STORE_NAMES, OPS_INDEXES } from './db-keys.const';
|
||||
|
||||
/**
|
||||
* Performs the database upgrade for SUP_OPS.
|
||||
* Called from openDB's upgrade callback in both OperationLogStoreService and ArchiveStoreService.
|
||||
*
|
||||
* @param db The IDBPDatabase instance
|
||||
* @param oldVersion The previous version (0 for new databases)
|
||||
* @param transaction The upgrade transaction (needed to access existing stores)
|
||||
*/
|
||||
export const runDbUpgrade = (
|
||||
db: IDBPDatabase<any>,
|
||||
oldVersion: number,
|
||||
transaction: IDBPTransaction<any, any, 'versionchange'>,
|
||||
): void => {
|
||||
// Version 1: Create initial stores
|
||||
if (oldVersion < 1) {
|
||||
const opStore = db.createObjectStore(STORE_NAMES.OPS, {
|
||||
keyPath: 'seq',
|
||||
autoIncrement: true,
|
||||
});
|
||||
opStore.createIndex(OPS_INDEXES.BY_ID, 'op.id', { unique: true });
|
||||
opStore.createIndex(OPS_INDEXES.BY_SYNCED_AT, 'syncedAt');
|
||||
|
||||
db.createObjectStore(STORE_NAMES.STATE_CACHE, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.IMPORT_BACKUP, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
// Version 2: Add vector_clock store for atomic writes
|
||||
// This consolidates the vector clock from pf.META_MODEL into SUP_OPS
|
||||
// to enable single-transaction writes (op + vector clock together)
|
||||
if (oldVersion < 2) {
|
||||
db.createObjectStore(STORE_NAMES.VECTOR_CLOCK);
|
||||
}
|
||||
|
||||
// Version 3: Add compound index for efficient source+status queries
|
||||
// PERF: Enables O(results) queries for getPendingRemoteOps/getFailedRemoteOps
|
||||
// instead of O(all ops) full table scan
|
||||
if (oldVersion < 3) {
|
||||
const opStore = transaction.objectStore(STORE_NAMES.OPS);
|
||||
opStore.createIndex(OPS_INDEXES.BY_SOURCE_AND_STATUS, [
|
||||
'source',
|
||||
'applicationStatus',
|
||||
]);
|
||||
}
|
||||
|
||||
// Version 4: Add archive stores for archiveYoung and archiveOld
|
||||
// Consolidates archive data from legacy 'pf' database into SUP_OPS
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_YOUNG, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_OLD, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
BACKUP_KEY,
|
||||
OPS_INDEXES,
|
||||
} from './db-keys.const';
|
||||
import { runDbUpgrade } from './db-upgrade';
|
||||
|
||||
/**
|
||||
* Vector clock entry stored in the vector_clock object store.
|
||||
|
|
@ -172,43 +173,7 @@ export class OperationLogStoreService {
|
|||
async init(): Promise<void> {
|
||||
this._db = await openDB<OpLogDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade: (db, oldVersion, _newVersion, transaction) => {
|
||||
// Version 1: Create initial stores
|
||||
if (oldVersion < 1) {
|
||||
const opStore = db.createObjectStore(STORE_NAMES.OPS, {
|
||||
keyPath: 'seq',
|
||||
autoIncrement: true,
|
||||
});
|
||||
opStore.createIndex(OPS_INDEXES.BY_ID, 'op.id', { unique: true });
|
||||
opStore.createIndex(OPS_INDEXES.BY_SYNCED_AT, 'syncedAt');
|
||||
|
||||
db.createObjectStore(STORE_NAMES.STATE_CACHE, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.IMPORT_BACKUP, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
// Version 2: Add vector_clock store for atomic writes
|
||||
// This consolidates the vector clock from pf.META_MODEL into SUP_OPS
|
||||
// to enable single-transaction writes (op + vector clock together)
|
||||
if (oldVersion < 2) {
|
||||
db.createObjectStore(STORE_NAMES.VECTOR_CLOCK);
|
||||
}
|
||||
|
||||
// Version 3: Add compound index for efficient source+status queries
|
||||
// PERF: Enables O(results) queries for getPendingRemoteOps/getFailedRemoteOps
|
||||
// instead of O(all ops) full table scan
|
||||
if (oldVersion < 3) {
|
||||
const opStore = transaction.objectStore(STORE_NAMES.OPS);
|
||||
opStore.createIndex(OPS_INDEXES.BY_SOURCE_AND_STATUS, [
|
||||
'source',
|
||||
'applicationStatus',
|
||||
]);
|
||||
}
|
||||
|
||||
// Version 4: Add archive stores for archiveYoung and archiveOld
|
||||
// Consolidates archive data from legacy 'pf' database into SUP_OPS
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_YOUNG, { keyPath: 'id' });
|
||||
db.createObjectStore(STORE_NAMES.ARCHIVE_OLD, { keyPath: 'id' });
|
||||
}
|
||||
runDbUpgrade(db, oldVersion, transaction);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
export interface NavigatorWithKeyboard {
|
||||
keyboard?: NavigatorKeyboard;
|
||||
}
|
||||
import {
|
||||
KeyboardLayoutService,
|
||||
KeyboardLayout,
|
||||
NavigatorWithKeyboard,
|
||||
NavigatorKeyboard,
|
||||
} from '../core/keyboard-layout/keyboard-layout.service';
|
||||
|
||||
export interface NavigatorKeyboard {
|
||||
getLayoutMap: () => Promise<Map<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Map where keys are string representations of key codes,
|
||||
* and values are the corresponding characters or symbols for this layout.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap#browser_compatibility
|
||||
*/
|
||||
export type KeyboardLayout = Map<string, string>;
|
||||
// Re-export types for backwards compatibility
|
||||
export type { KeyboardLayout, NavigatorWithKeyboard, NavigatorKeyboard };
|
||||
|
||||
// Just an alias for better readability
|
||||
export const KEYS = {
|
||||
|
|
@ -26,18 +20,42 @@ export const KEYS = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
/** Saved user keyboard layout */
|
||||
/**
|
||||
* Module-level reference to the keyboard layout service.
|
||||
* Initialized lazily when first accessed.
|
||||
*/
|
||||
let _keyboardLayoutService: KeyboardLayoutService | null = null;
|
||||
|
||||
/**
|
||||
* Sets the keyboard layout service instance.
|
||||
* Called from app initialization to connect the service to these utility functions.
|
||||
*/
|
||||
export const setKeyboardLayoutService = (service: KeyboardLayoutService): void => {
|
||||
_keyboardLayoutService = service;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use KeyboardLayoutService.layout instead.
|
||||
* Provided for backwards compatibility with tests.
|
||||
*/
|
||||
export const userKbLayout: KeyboardLayout = new Map();
|
||||
|
||||
/** Try to save user kayboard layout mapping - https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap */
|
||||
/**
|
||||
* @deprecated Use KeyboardLayoutService.saveUserLayout() instead.
|
||||
* Provided for backwards compatibility.
|
||||
*/
|
||||
export const saveUserKbLayout = async (): Promise<void> => {
|
||||
// If browser doesn't support keyboard API https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||
if (!('keyboard' in navigator)) return;
|
||||
|
||||
const keyboard = navigator.keyboard as NavigatorKeyboard;
|
||||
const kbLayout = await keyboard.getLayoutMap();
|
||||
userKbLayout.clear();
|
||||
kbLayout.forEach((value, key) => userKbLayout.set(key, value));
|
||||
if (_keyboardLayoutService) {
|
||||
await _keyboardLayoutService.saveUserLayout();
|
||||
} else {
|
||||
// Fallback for when service is not available (e.g., tests without DI)
|
||||
if (!('keyboard' in navigator)) return;
|
||||
const keyboard = (navigator as NavigatorWithKeyboard).keyboard;
|
||||
if (!keyboard) return;
|
||||
const kbLayout = await keyboard.getLayoutMap();
|
||||
userKbLayout.clear();
|
||||
kbLayout.forEach((value, key) => userKbLayout.set(key, value));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -80,9 +98,11 @@ export const prepareKeyCode = (code: KeyboardEvent['code']): string => {
|
|||
},
|
||||
};
|
||||
|
||||
// Try to use user kayboard layout mapping - https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||
if (!rules.codeMapping[code] && userKbLayout.size) {
|
||||
const foundKey = userKbLayout.get(code);
|
||||
// Try to use user keyboard layout mapping - https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||
// Use service layout if available, fall back to deprecated userKbLayout for backwards compat
|
||||
const layout = _keyboardLayoutService?.layout ?? userKbLayout;
|
||||
if (!rules.codeMapping[code] && layout.size) {
|
||||
const foundKey = layout.get(code);
|
||||
if (foundKey) code = foundKey.toUpperCase();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue