1/* 2 * Copyright 2022, The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17export class PersistentStoreProxy { 18 static new<T extends object>( 19 key: string, 20 defaultState: T, 21 storage: Storage, 22 ): T { 23 const storedState = JSON.parse(storage.getItem(key) ?? '{}'); 24 const currentState = mergeDeep({}, structuredClone(defaultState)); 25 mergeDeepKeepingStructure(currentState, storedState); 26 return wrapWithPersistentStoreProxy(key, currentState, storage) as T; 27 } 28} 29 30function wrapWithPersistentStoreProxy( 31 storeKey: string, 32 object: object, 33 storage: Storage, 34 baseObject: object = object, 35): object { 36 const updatableProps: string[] = []; 37 38 for (const [key, value] of Object.entries(object)) { 39 if ( 40 typeof value === 'string' || 41 typeof value === 'boolean' || 42 value === undefined 43 ) { 44 if (!Array.isArray(object)) { 45 updatableProps.push(key); 46 } 47 } else { 48 (object as any)[key] = wrapWithPersistentStoreProxy( 49 storeKey, 50 value, 51 storage, 52 baseObject, 53 ); 54 } 55 } 56 57 const proxyObj = new Proxy(object, { 58 set: (target, prop, newValue) => { 59 if (typeof prop === 'symbol') { 60 throw Error("Can't use symbol keys only strings"); 61 } 62 if (Array.isArray(target) && typeof prop === 'number') { 63 target[prop] = newValue; 64 storage.setItem(storeKey, JSON.stringify(baseObject)); 65 return true; 66 } 67 if (!Array.isArray(target) && updatableProps.includes(prop)) { 68 (target as any)[prop] = newValue; 69 storage.setItem(storeKey, JSON.stringify(baseObject)); 70 return true; 71 } 72 throw Error( 73 `Object property '${prop}' is not updatable. Can only update leaf keys: [${updatableProps}]`, 74 ); 75 }, 76 }); 77 78 return proxyObj; 79} 80 81function isObject(item: any): boolean { 82 return item && typeof item === 'object' && !Array.isArray(item); 83} 84 85/** 86 * Merge sources into the target keeping the structure of the target. 87 * @param target the object we mutate by merging the data from source into, but keep the object structure of 88 * @param source the object we merge into target 89 * @return the mutated target object 90 */ 91function mergeDeepKeepingStructure(target: any, source: any): any { 92 if (isObject(target) && isObject(source)) { 93 for (const key in target) { 94 if (source[key] === undefined) { 95 continue; 96 } 97 98 if (isObject(target[key]) && isObject(source[key])) { 99 mergeDeepKeepingStructure(target[key], source[key]); 100 continue; 101 } 102 103 if (!isObject(target[key]) && !isObject(source[key])) { 104 Object.assign(target, {[key]: source[key]}); 105 continue; 106 } 107 } 108 } 109 110 return target; 111} 112 113function mergeDeep(target: any, ...sources: any): any { 114 if (!sources.length) return target; 115 const source = sources.shift(); 116 117 if (isObject(target) && isObject(source)) { 118 for (const key in source) { 119 if (isObject(source[key])) { 120 if (!target[key]) Object.assign(target, {[key]: {}}); 121 mergeDeep(target[key], source[key]); 122 } else { 123 Object.assign(target, {[key]: source[key]}); 124 } 125 } 126 } 127 128 return mergeDeep(target, ...sources); 129} 130