1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

persist local-storage state in external json-file

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2021-03-04 15:18:35 +02:00
parent 852aa1147f
commit 597370ae07
2 changed files with 182 additions and 45 deletions

View File

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { createStorage, IStorageHelperOptions } from "../utils"; import { createStorage, StorageHelperOptions } from "../utils";
export function useStorage<T>(key: string, initialValue?: T, options?: IStorageHelperOptions) { export function useStorage<T>(key: string, initialValue?: T, options?: StorageHelperOptions<T>) {
const storage = createStorage(key, initialValue, options); const storage = createStorage(key, initialValue, options);
const [storageValue, setStorageValue] = useState(storage.get()); const [storageValue, setStorageValue] = useState(storage.get());
const setValue = (value: T) => { const setValue = (value: T) => {

View File

@ -1,73 +1,210 @@
// Helper to work with browser's local/session storage api // Helper for work with persistent local storage (default: window.localStorage)
// TODO: write unit/integration tests
export interface IStorageHelperOptions { import { app, remote } from "electron";
addKeyPrefix?: boolean; import path from "path";
useSession?: boolean; // use `sessionStorage` instead of `localStorage` import { ensureDirSync, readJsonSync, writeJson } from "fs-extra";
} import type { CreateObservableOptions } from "mobx/lib/api/observable";
import { action, comparer, observable, reaction, toJS, when } from "mobx";
import produce, { Draft, setAutoFreeze } from "immer";
import { isEqual, isFunction, isObject } from "lodash";
export function createStorage<T>(key: string, defaultValue?: T, options?: IStorageHelperOptions) { setAutoFreeze(false); // allow to merge observables
export function createStorage<T>(key: string, defaultValue?: T, options?: StorageHelperOptions<T>) {
return new StorageHelper(key, defaultValue, options); return new StorageHelper(key, defaultValue, options);
} }
export class StorageHelper<T> { export interface StorageHelperOptions<T> extends StorageConfiguration<T> {
static keyPrefix = "lens_"; autoInit?: boolean; // default: true
}
static defaultOptions: IStorageHelperOptions = { export interface StorageConfiguration<T> {
addKeyPrefix: true, storage?: StorageAdapter<T>;
useSession: false, observable?: CreateObservableOptions;
}
export interface StorageAdapter<T> {
[metadata: string]: any;
getItem(key: string): T; // import
setItem(key: string, value: T): void; // export
removeItem?(key: string): void; // if not provided setItem(key,undefined) will be used
onChange?(data: { key: string, value: T, oldValue?: T }): void;
}
export class StorageHelper<T> {
static defaultOptions: StorageHelperOptions<any> = {
autoInit: true,
get storage() {
return jsonFileSyncStorageAdapter;
},
observable: {
deep: true,
equals: comparer.shallow,
}
}; };
constructor(protected key: string, protected defaultValue?: T, protected options?: IStorageHelperOptions) { private data = observable.box<T>();
this.options = Object.assign({}, StorageHelper.defaultOptions, options); @observable.ref storage: StorageAdapter<T>;
@observable initialized = false;
whenReady = when(() => this.initialized);
if (this.options.addKeyPrefix) { constructor(readonly key: string, readonly defaultValue?: T, readonly options: StorageHelperOptions<T> = {}) {
this.key = StorageHelper.keyPrefix + key; this.options = { ...StorageHelper.defaultOptions, ...options };
this.configure();
this.reset();
if (this.options.autoInit) {
this.init();
} }
} }
protected get storage() { @action
if (this.options.useSession) return window.sessionStorage; init() {
if (this.initialized) return;
return window.localStorage; try {
const value = this.load();
const notEmpty = value != null;
const notDefault = !this.isDefaultValue(value);
if (notEmpty && notDefault) {
this.merge(value);
}
this.initialized = true;
} catch (error) {
console.error(`[init]: ${error}`, this);
}
}
isDefaultValue(value: T): boolean {
return isEqual(value, this.defaultValue);
}
@action
private configure({ storage, observable }: StorageConfiguration<T> = this.options): this {
if (storage) this.storage = storage;
if (observable) this.configureObservable(observable);
return this;
}
@action
private configureObservable(options: CreateObservableOptions = {}) {
this.data = observable.box<T>(this.data.get(), {
...StorageHelper.defaultOptions.observable, // inherit default observability options
...options,
});
this.data.observe(change => {
const { newValue, oldValue } = toJS(change, { recurseEverything: true });
this.onChange(newValue, oldValue);
});
}
protected onChange(value: T, oldValue?: T) {
if (!this.initialized) return;
this.storage.onChange?.({ value, oldValue, key: this.key });
this.storage.setItem(this.key, value);
}
load(): T {
return this.storage.getItem(this.key);
} }
get(): T { get(): T {
const strValue = this.storage.getItem(this.key); return this.data.get();
if (strValue != null) {
try {
return JSON.parse(strValue);
} catch (e) {
console.error(`Parsing json failed for pair: ${this.key}=${strValue}`);
}
}
return this.defaultValue;
} }
set(value: T) { set(value: T) {
this.storage.setItem(this.key, JSON.stringify(value)); this.data.set(value);
return this;
} }
merge(value: Partial<T>) { reset() {
const currentValue = this.get(); this.set(this.defaultValue);
return this.set(Object.assign(currentValue, value));
} }
clear() { clear() {
this.storage.removeItem(this.key); this.data.set(null);
return this;
} }
getDefaultValue() { merge(value: Partial<T> | ((draft: Draft<T>) => Partial<T> | void)) {
return this.defaultValue; const updater = isFunction(value) ? value : (state: Draft<T>) => {
if (isObject(state)) return Object.assign(state, value);
return value;
};
const currentValue = this.toJS();
const nextValue = produce(currentValue, updater) as T;
this.set(nextValue);
} }
restoreDefaultValue() { toJS() {
return this.set(this.defaultValue); return toJS(this.get(), { recurseEverything: true });
} }
} }
export const localStorageAdapter: StorageAdapter<object> = {
getItem(key: string) {
return JSON.parse(localStorage.getItem(key));
},
setItem(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
},
removeItem(key: string) {
localStorage.removeItem(key);
}
};
// TODO: remove after merge https://github.com/lensapp/lens/pull/2269
export const jsonFileSyncStorageAdapter: StorageAdapter<object> = {
cwd: path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"),
data: observable.map<string, any>([], { deep: false }),
initialized: false,
get filePath() {
const clusterId = location.hostname.split(".").slice(-2, -1)[0];
return path.resolve(this.cwd, `${clusterId ?? "app"}.json`);
},
init() {
if (this.initialized) return;
try {
ensureDirSync(this.cwd, { mode: 0o755 });
const data = readJsonSync(this.filePath, { throws: false }) || {};
this.data.replace(data);
this.bindAutoSave();
} catch (error) {
console.error(`[init]: ${this.filePath} failed: ${error}`, this);
} finally {
this.initialized = true;
}
},
bindAutoSave() {
return reaction(() => this.data.toJSON(), async (data) => {
try {
await writeJson(this.filePath, data, { spaces: 2 });
} catch (error) {
console.error(`[save]: ${this.filePath} failed: ${error}`, this);
}
});
},
getItem(key: string) {
this.init();
return this.data.get(key);
},
setItem(key: string, value: any) {
this.data.set(key, value);
},
removeItem(key: string) {
this.data.delete(key);
}
};