2023-12-19 22:32:47 -05:00
|
|
|
/**
|
2023-12-27 17:23:17 -05:00
|
|
|
* Configurable options to change persistence behavior
|
2023-12-19 22:32:47 -05:00
|
|
|
*
|
|
|
|
* @group Options
|
|
|
|
*/
|
|
|
|
export type PersistOptions<T> = {
|
|
|
|
/** Default/Initial value if undefined */
|
|
|
|
default?: T,
|
|
|
|
/** Storage implementation, defaults to LocalStorage */
|
|
|
|
storage?: Storage,
|
2023-12-27 17:23:17 -05:00
|
|
|
/** Force value to have [proto]type */
|
2023-12-19 22:32:47 -05:00
|
|
|
type?: any,
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sync variable's value with persistent storage (LocalStorage by default)
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```ts
|
|
|
|
* const theme = new Persist('theme.current', {default: 'os'});
|
|
|
|
* console.log(theme.value); // Output: os
|
|
|
|
*
|
|
|
|
* theme.value = 'light'; // Any changes to `.value` will automatically sync with localStorage
|
|
|
|
*
|
|
|
|
* location.reload(); // Simulate refresh
|
|
|
|
* console.log(theme.value); // Output: light
|
|
|
|
* ```
|
|
|
|
*/
|
|
|
|
export class Persist<T> {
|
2023-12-27 17:23:17 -05:00
|
|
|
/** Backend service to store data, must implement `Storage` interface */
|
|
|
|
private readonly storage!: Storage;
|
|
|
|
/** Listeners which should be notified on changes */
|
|
|
|
private watches: { [key: string]: Function } = {};
|
|
|
|
|
2023-12-19 22:32:47 -05:00
|
|
|
/** Private value field */
|
|
|
|
private _value!: T;
|
|
|
|
/** Current value or default if undefined */
|
|
|
|
get value(): T { return this._value !== undefined ? this._value : <T>this.options?.default; }
|
|
|
|
/** Set value with proxy object wrapper to sync future changes */
|
|
|
|
set value(v: T | undefined) {
|
|
|
|
if(v == null || typeof v != 'object') this._value = <T>v;
|
|
|
|
// @ts-ignore
|
|
|
|
else this._value = new Proxy<T>(v, {
|
|
|
|
get: (target: T, p: string | symbol): any => {
|
|
|
|
const f = typeof (<any>target)[p] == 'function';
|
|
|
|
if(!f) return (<any>target)[p];
|
|
|
|
return (...args: any[]) => {
|
|
|
|
const value = (<any>target)[p](...args);
|
|
|
|
this.save();
|
|
|
|
return value;
|
|
|
|
};
|
|
|
|
},
|
|
|
|
set: (target: T, p: string | symbol, newValue: any): boolean => {
|
|
|
|
(<any>target)[p] = newValue;
|
|
|
|
this.save();
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-12-27 17:23:17 -05:00
|
|
|
* @param {string} key Primary key value will be stored under
|
2023-12-19 22:32:47 -05:00
|
|
|
* @param {PersistOptions<T>} options Configure using {@link PersistOptions}
|
|
|
|
*/
|
|
|
|
constructor(public readonly key: string, public options: PersistOptions<T> = {}) {
|
|
|
|
this.storage = options.storage || localStorage;
|
|
|
|
this.load();
|
|
|
|
}
|
|
|
|
|
2023-12-27 17:39:59 -05:00
|
|
|
/** Notify listeners of change */
|
|
|
|
private notify(value: T) {
|
|
|
|
Object.values(this.watches).forEach(watch => watch(value));
|
|
|
|
}
|
|
|
|
|
2023-12-19 22:32:47 -05:00
|
|
|
/** Delete value from storage */
|
|
|
|
clear() {
|
|
|
|
this.storage.removeItem(this.key);
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Save current value to storage */
|
|
|
|
save() {
|
|
|
|
if(this._value === undefined) this.clear();
|
|
|
|
else this.storage.setItem(this.key, JSON.stringify(this._value));
|
|
|
|
this.notify(this.value);
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Load value from storage */
|
|
|
|
load() {
|
|
|
|
if(this.storage[this.key] != undefined) {
|
|
|
|
let value = JSON.parse(<string>this.storage.getItem(this.key));
|
|
|
|
if(value != null && typeof value == 'object' && this.options.type) value.__proto__ = this.options.type.prototype;
|
|
|
|
this.value = value;
|
|
|
|
} else this.value = this.options.default || <T>undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Callback to listen for changes */
|
|
|
|
watch(fn: (value: T) => any): () => void {
|
|
|
|
const index = Object.keys(this.watches).length;
|
|
|
|
this.watches[index] = fn;
|
|
|
|
return () => { delete this.watches[index]; };
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Return value as JSON string */
|
|
|
|
toString() { return JSON.stringify(this.value); }
|
|
|
|
|
2023-12-27 17:23:17 -05:00
|
|
|
/** Return current value */
|
|
|
|
valueOf() { return this.value; }
|
2023-12-19 22:32:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sync class property with persistent storage (LocalStorage by default)
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```ts
|
|
|
|
* class ThemeEngine {
|
|
|
|
* @persist({default: 'os'}) current!: string;
|
|
|
|
* }
|
|
|
|
*
|
|
|
|
* const theme = new ThemeEngine();
|
|
|
|
* console.log(theme.current) // Output: os
|
|
|
|
*
|
|
|
|
* theme.current = 'light'; //Any changes will be automatically saved to localStorage
|
|
|
|
*
|
|
|
|
* location.reload(); // Simulate refresh
|
|
|
|
* console.log(theme.current) // Output: light
|
|
|
|
* ```
|
|
|
|
*
|
|
|
|
* @group Decorators
|
|
|
|
* @param {PersistOptions<T> & {key?: string}} options Configure using {@link PersistOptions}
|
|
|
|
* @returns Decorator function
|
|
|
|
*/
|
|
|
|
export function persist<T>(options?: PersistOptions<T> & { key?: string }) {
|
|
|
|
return (target: any, prop: any) => {
|
|
|
|
const key = options?.key || `${target.constructor.name}.${prop.toString()}`;
|
|
|
|
const wrapper = new Persist(key, options);
|
|
|
|
Object.defineProperty(target, prop, {
|
|
|
|
get: function() { return wrapper.value; },
|
|
|
|
set: function(v) { wrapper.value = v; }
|
|
|
|
});
|
|
|
|
};
|
|
|
|
}
|