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