From 9956d254f4efa111a04159537072f5c3fb6a38e1 Mon Sep 17 00:00:00 2001 From: Jing Hua <59118459+ztjhz@users.noreply.github.com> Date: Sun, 2 Apr 2023 16:27:19 +0800 Subject: [PATCH] Refactor folder (#188) * refactor folders * add chat id --- .../ImportExportChat/ImportExportChat.tsx | 133 ++++++++++++++---- src/components/Menu/ChatFolder.tsx | 83 +++++------ src/components/Menu/ChatHistoryList.tsx | 83 ++++++----- .../Menu/MenuOptions/ClearConversation.tsx | 6 +- src/components/Menu/NewFolder.tsx | 32 +++-- src/constants/chat.ts | 2 + src/store/chat-slice.ts | 21 +-- src/store/migrate.ts | 31 ++++ src/store/store.ts | 9 +- src/types/chat.ts | 23 ++- src/types/export.ts | 12 ++ src/utils/chat.ts | 58 +------- src/utils/import.ts | 90 ++++++++++++ 13 files changed, 390 insertions(+), 193 deletions(-) create mode 100644 src/types/export.ts create mode 100644 src/utils/import.ts diff --git a/src/components/ImportExportChat/ImportExportChat.tsx b/src/components/ImportExportChat/ImportExportChat.tsx index b75b541..5c8aeec 100644 --- a/src/components/ImportExportChat/ImportExportChat.tsx +++ b/src/components/ImportExportChat/ImportExportChat.tsx @@ -1,13 +1,19 @@ import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { v4 as uuidv4 } from 'uuid'; import useStore from '@store/store'; import ExportIcon from '@icon/ExportIcon'; import downloadFile from '@utils/downloadFile'; import { getToday } from '@utils/date'; import PopupModal from '@components/PopupModal'; -import { validateAndFixChats } from '@utils/chat'; -import { ChatInterface } from '@type/chat'; +import { + isLegacyImport, + validateAndFixChats, + validateExportV1, +} from '@utils/import'; +import { ChatInterface, Folder, FolderCollection } from '@type/chat'; +import Export, { ExportBase, ExportV1 } from '@type/export'; const ImportExportChat = () => { const { t } = useTranslation(); @@ -43,8 +49,7 @@ const ImportExportChat = () => { const ImportChat = () => { const { t } = useTranslation(); const setChats = useStore.getState().setChats; - const setFoldersName = useStore.getState().setFoldersName; - const setFoldersExpanded = useStore.getState().setFoldersExpanded; + const setFolders = useStore.getState().setFolders; const inputRef = useRef(null); const [alert, setAlert] = useState<{ message: string; @@ -63,33 +68,100 @@ const ImportChat = () => { try { const parsedData = JSON.parse(data); - if (validateAndFixChats(parsedData)) { - const parsedFolders: string[] = []; - parsedData.forEach((data) => { - if (data.folder && !parsedFolders.includes(data.folder)) - parsedFolders.push(data.folder); - }); - setFoldersName([ - ...parsedFolders, - ...useStore.getState().foldersName, - ]); - setFoldersExpanded([ - ...new Array(parsedFolders.length).fill(false), - ...useStore.getState().foldersExpanded, - ]); + if (isLegacyImport(parsedData)) { + if (validateAndFixChats(parsedData)) { + // import new folders + const folderNameToIdMap: Record = {}; + const parsedFolders: string[] = []; - const prevChats = useStore.getState().chats; - if (prevChats) { - const updatedChats: ChatInterface[] = JSON.parse( - JSON.stringify(prevChats) + parsedData.forEach((data) => { + const folder = data.folder; + if (folder) { + if (!parsedFolders.includes(folder)) { + parsedFolders.push(folder); + folderNameToIdMap[folder] = uuidv4(); + } + data.folder = folderNameToIdMap[folder]; + } + }); + + const newFolders: FolderCollection = parsedFolders.reduce( + (acc, curr, index) => { + const id = folderNameToIdMap[curr]; + const _newFolder: Folder = { + id, + name: curr, + expanded: false, + order: index, + }; + return { [id]: _newFolder, ...acc }; + }, + {} ); - setChats(parsedData.concat(updatedChats)); + + // increment the order of existing folders + const offset = parsedFolders.length; + + const updatedFolders = useStore.getState().folders; + Object.values(updatedFolders).forEach((f) => (f.order += offset)); + + setFolders({ ...newFolders, ...updatedFolders }); + + // import chats + const prevChats = useStore.getState().chats; + if (prevChats) { + const updatedChats: ChatInterface[] = JSON.parse( + JSON.stringify(prevChats) + ); + setChats(parsedData.concat(updatedChats)); + } else { + setChats(parsedData); + } + setAlert({ message: 'Succesfully imported!', success: true }); } else { - setChats(parsedData); + setAlert({ + message: 'Invalid chats data format', + success: false, + }); } - setAlert({ message: 'Succesfully imported!', success: true }); } else { - setAlert({ message: 'Invalid chats data format', success: false }); + switch ((parsedData as ExportBase).version) { + case 1: + if (validateExportV1(parsedData)) { + // import folders + parsedData.folders; + // increment the order of existing folders + const offset = Object.keys(parsedData.folders).length; + + const updatedFolders = useStore.getState().folders; + Object.values(updatedFolders).forEach( + (f) => (f.order += offset) + ); + + setFolders({ ...parsedData.folders, ...updatedFolders }); + + // import chats + const prevChats = useStore.getState().chats; + if (parsedData.chats) { + if (prevChats) { + const updatedChats: ChatInterface[] = JSON.parse( + JSON.stringify(prevChats) + ); + setChats(parsedData.chats.concat(updatedChats)); + } else { + setChats(parsedData.chats); + } + } + + setAlert({ message: 'Succesfully imported!', success: true }); + } else { + setAlert({ + message: 'Invalid format', + success: false, + }); + } + break; + } } } catch (error: unknown) { setAlert({ message: (error as Error).message, success: false }); @@ -132,7 +204,7 @@ const ImportChat = () => { const ExportChat = () => { const { t } = useTranslation(); - const chats = useStore.getState().chats; + return (
@@ -141,7 +213,12 @@ const ExportChat = () => { @@ -192,7 +193,7 @@ const ChatFolder = ({
- {foldersExpanded[folderIndex] && + {isExpanded && folderChats.map((chat) => ( { const currentChatIndex = useStore((state) => state.currentChatIndex); const setChats = useStore((state) => state.setChats); + const setFolders = useStore((state) => state.setFolders); const chatTitles = useStore( (state) => state.chats?.map((chat) => chat.title), shallow ); const [isHover, setIsHover] = useState(false); - const [folders, setFolders] = useState({}); - const [noFolders, setNoFolders] = useState([]); + const [chatFolders, setChatFolders] = useState( + {} + ); + const [noChatFolders, setNoChatFolders] = useState( + [] + ); const [filter, setFilter] = useState(''); const chatsRef = useRef(useStore.getState().chats || []); - const foldersNameRef = useRef(useStore.getState().foldersName); + const foldersRef = useRef(useStore.getState().folders); const filterRef = useRef(filter); const updateFolders = useRef(() => { const _folders: ChatHistoryFolderInterface = {}; const _noFolders: ChatHistoryInterface[] = []; const chats = useStore.getState().chats; - const foldersName = useStore.getState().foldersName; + const folders = useStore.getState().folders; - foldersName.forEach((f) => (_folders[f] = [])); + Object.values(folders) + .sort((a, b) => a.order - b.order) + .forEach((f) => (_folders[f.id] = [])); if (chats) { chats.forEach((chat, index) => { - const filterLowerCase = filterRef.current.toLowerCase(); + const _filterLowerCase = filterRef.current.toLowerCase(); + const _chatTitle = chat.title.toLowerCase(); + const _chatFolderName = chat.folder + ? folders[chat.folder].name.toLowerCase() + : ''; + if ( - !chat.title.toLocaleLowerCase().includes(filterLowerCase) && - !chat.folder?.toLowerCase().includes(filterLowerCase) && + !_chatTitle.includes(_filterLowerCase) && + !_chatFolderName.includes(_filterLowerCase) && index !== useStore.getState().currentChatIndex ) return; if (!chat.folder) { - _noFolders.push({ title: chat.title, index: index }); + _noFolders.push({ title: chat.title, index: index, id: chat.id }); } else { - if (!_folders[chat.folder]) _folders[chat.folder] = []; - _folders[chat.folder].push({ title: chat.title, index: index }); + if (!_folders[chat.folder]) _folders[_chatFolderName] = []; + _folders[chat.folder].push({ + title: chat.title, + index: index, + id: chat.id, + }); } }); } - setFolders(_folders); - setNoFolders(_noFolders); + setChatFolders(_folders); + setNoChatFolders(_noFolders); }).current; useEffect(() => { @@ -72,9 +89,9 @@ const ChatHistoryList = () => { ) { updateFolders(); chatsRef.current = state.chats; - } else if (state.foldersName !== foldersNameRef.current) { + } else if (state.folders !== foldersRef.current) { updateFolders(); - foldersNameRef.current = state.foldersName; + foldersRef.current = state.folders; } }); }, []); @@ -85,20 +102,21 @@ const ChatHistoryList = () => { currentChatIndex >= 0 && currentChatIndex < chatTitles.length ) { + // set title document.title = chatTitles[currentChatIndex]; + // expand folder of current chat const chats = useStore.getState().chats; if (chats) { - const folderIndex = useStore - .getState() - .foldersName.findIndex((f) => f === chats[currentChatIndex].folder); + const folderId = chats[currentChatIndex].folder; - if (folderIndex) { - const updatedFolderExpanded = [ - ...useStore.getState().foldersExpanded, - ]; - updatedFolderExpanded[folderIndex] = true; - useStore.getState().setFoldersExpanded(updatedFolderExpanded); + if (folderId) { + const updatedFolders: FolderCollection = JSON.parse( + JSON.stringify(useStore.getState().folders) + ); + + updatedFolders[folderId].expanded = true; + setFolders(updatedFolders); } } } @@ -149,20 +167,15 @@ const ChatHistoryList = () => {
- {Object.keys(folders).map((folderName, folderIndex) => ( + {Object.keys(chatFolders).map((folderId) => ( ))} - {noFolders.map(({ title, index }) => ( - + {noChatFolders.map(({ title, index, id }) => ( + ))}
diff --git a/src/components/Menu/MenuOptions/ClearConversation.tsx b/src/components/Menu/MenuOptions/ClearConversation.tsx index c63835f..90f8f8d 100644 --- a/src/components/Menu/MenuOptions/ClearConversation.tsx +++ b/src/components/Menu/MenuOptions/ClearConversation.tsx @@ -10,15 +10,13 @@ const ClearConversation = () => { const { t } = useTranslation(); const initialiseNewChat = useInitialiseNewChat(); - const setFoldersName = useStore((state) => state.setFoldersName); - const setFoldersExpanded = useStore((state) => state.setFoldersExpanded); + const setFolders = useStore((state) => state.setFolders); const [isModalOpen, setIsModalOpen] = useState(false); const handleConfirm = () => { setIsModalOpen(false); - setFoldersName([]); - setFoldersExpanded([]); initialiseNewChat(); + setFolders({}); }; return ( diff --git a/src/components/Menu/NewFolder.tsx b/src/components/Menu/NewFolder.tsx index 2145200..c23be96 100644 --- a/src/components/Menu/NewFolder.tsx +++ b/src/components/Menu/NewFolder.tsx @@ -1,30 +1,44 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { v4 as uuidv4 } from 'uuid'; import useStore from '@store/store'; import NewFolderIcon from '@icon/NewFolderIcon'; +import { Folder, FolderCollection } from '@type/chat'; const NewFolder = () => { const { t } = useTranslation(); const generating = useStore((state) => state.generating); - const setFoldersName = useStore((state) => state.setFoldersName); - const setFoldersExpanded = useStore((state) => state.setFoldersExpanded); + const setFolders = useStore((state) => state.setFolders); const addFolder = () => { let folderIndex = 1; let name = `New Folder ${folderIndex}`; - while ( - useStore - .getState() - .foldersName.some((_folderName) => _folderName === name) - ) { + const folders = useStore.getState().folders; + + while (Object.values(folders).some((folder) => folder.name === name)) { folderIndex += 1; name = `New Folder ${folderIndex}`; } - setFoldersName([name, ...useStore.getState().foldersName]); - setFoldersExpanded([false, ...useStore.getState().foldersExpanded]); + const updatedFolders: FolderCollection = JSON.parse( + JSON.stringify(folders) + ); + + const id = uuidv4(); + const newFolder: Folder = { + id, + name, + expanded: false, + order: 0, + }; + + Object.values(updatedFolders).forEach((folder) => { + folder.order += 1; + }); + + setFolders({ [id]: newFolder, ...updatedFolders }); }; return ( diff --git a/src/constants/chat.ts b/src/constants/chat.ts index 5d1735e..9d70104 100644 --- a/src/constants/chat.ts +++ b/src/constants/chat.ts @@ -1,3 +1,4 @@ +import { v4 as uuidv4 } from 'uuid'; import { ChatInterface, ConfigInterface, ModelOptions } from '@type/chat'; import useStore from '@store/store'; @@ -55,6 +56,7 @@ export const _defaultChatConfig: ConfigInterface = { }; export const generateDefaultChat = (title?: string): ChatInterface => ({ + id: uuidv4(), title: title ? title : 'New Chat', messages: useStore.getState().defaultSystemMessage.length > 0 diff --git a/src/store/chat-slice.ts b/src/store/chat-slice.ts index 1aec79c..0a3b682 100644 --- a/src/store/chat-slice.ts +++ b/src/store/chat-slice.ts @@ -1,5 +1,5 @@ import { StoreSlice } from './store'; -import { ChatInterface, MessageInterface } from '@type/chat'; +import { ChatInterface, FolderCollection, MessageInterface } from '@type/chat'; export interface ChatSlice { messages: MessageInterface[]; @@ -7,15 +7,13 @@ export interface ChatSlice { currentChatIndex: number; generating: boolean; error: string; - foldersName: string[]; - foldersExpanded: boolean[]; + folders: FolderCollection; setMessages: (messages: MessageInterface[]) => void; setChats: (chats: ChatInterface[]) => void; setCurrentChatIndex: (currentChatIndex: number) => void; setGenerating: (generating: boolean) => void; setError: (error: string) => void; - setFoldersName: (foldersName: string[]) => void; - setFoldersExpanded: (foldersExpanded: boolean[]) => void; + setFolders: (folders: FolderCollection) => void; } export const createChatSlice: StoreSlice = (set, get) => ({ @@ -23,8 +21,7 @@ export const createChatSlice: StoreSlice = (set, get) => ({ currentChatIndex: -1, generating: false, error: '', - foldersName: [], - foldersExpanded: [], + folders: {}, setMessages: (messages: MessageInterface[]) => { set((prev: ChatSlice) => ({ ...prev, @@ -55,16 +52,10 @@ export const createChatSlice: StoreSlice = (set, get) => ({ error: error, })); }, - setFoldersName: (foldersName: string[]) => { + setFolders: (folders: FolderCollection) => { set((prev: ChatSlice) => ({ ...prev, - foldersName: foldersName, - })); - }, - setFoldersExpanded: (foldersExpanded: boolean[]) => { - set((prev: ChatSlice) => ({ - ...prev, - foldersExpanded: foldersExpanded, + folders: folders, })); }, }); diff --git a/src/store/migrate.ts b/src/store/migrate.ts index 7094c47..b96ba60 100644 --- a/src/store/migrate.ts +++ b/src/store/migrate.ts @@ -1,4 +1,8 @@ +import { v4 as uuidv4 } from 'uuid'; + import { + Folder, + FolderCollection, LocalStorageInterfaceV0ToV1, LocalStorageInterfaceV1ToV2, LocalStorageInterfaceV2ToV3, @@ -6,6 +10,7 @@ import { LocalStorageInterfaceV4ToV5, LocalStorageInterfaceV5ToV6, LocalStorageInterfaceV6ToV7, + LocalStorageInterfaceV7oV8, } from '@type/chat'; import { _defaultChatConfig, @@ -73,3 +78,29 @@ export const migrateV6 = (persistedState: LocalStorageInterfaceV6ToV7) => { if (!persistedState.apiKey || persistedState.apiKey.length === 0) persistedState.apiKey = ''; }; + +export const migrateV7 = (persistedState: LocalStorageInterfaceV7oV8) => { + let folders: FolderCollection = {}; + const folderNameToIdMap: Record = {}; + + // convert foldersExpanded and foldersName to folders + persistedState.foldersName.forEach((name, index) => { + const id = uuidv4(); + const folder: Folder = { + id, + name, + expanded: persistedState.foldersExpanded[index], + order: index, + }; + + folders = { [id]: folder, ...folders }; + folderNameToIdMap[name] = id; + }); + persistedState.folders = folders; + + // change the chat.folder from name to id + persistedState.chats.forEach((chat) => { + if (chat.folder) chat.folder = folderNameToIdMap[chat.folder]; + chat.id = uuidv4(); + }); +}; diff --git a/src/store/store.ts b/src/store/store.ts index f02cb88..c5ab6ab 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -13,6 +13,7 @@ import { LocalStorageInterfaceV4ToV5, LocalStorageInterfaceV5ToV6, LocalStorageInterfaceV6ToV7, + LocalStorageInterfaceV7oV8, } from '@type/chat'; import { migrateV0, @@ -22,6 +23,7 @@ import { migrateV4, migrateV5, migrateV6, + migrateV7, } from './migrate'; export type StoreState = ChatSlice & @@ -59,11 +61,10 @@ const useStore = create()( hideMenuOptions: state.hideMenuOptions, firstVisit: state.firstVisit, hideSideMenu: state.hideSideMenu, - foldersName: state.foldersName, - foldersExpanded: state.foldersExpanded, + folders: state.folders, enterToSubmit: state.enterToSubmit, }), - version: 7, + version: 8, migrate: (persistedState, version) => { switch (version) { case 0: @@ -80,6 +81,8 @@ const useStore = create()( migrateV5(persistedState as LocalStorageInterfaceV5ToV6); case 6: migrateV6(persistedState as LocalStorageInterfaceV6ToV7); + case 7: + migrateV7(persistedState as LocalStorageInterfaceV7oV8); break; } return persistedState as StoreState; diff --git a/src/types/chat.ts b/src/types/chat.ts index d20db1f..5ee49fd 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -10,6 +10,7 @@ export interface MessageInterface { } export interface ChatInterface { + id: string; title: string; folder?: string; messages: MessageInterface[]; @@ -29,10 +30,23 @@ export interface ConfigInterface { export interface ChatHistoryInterface { title: string; index: number; + id: string; } export interface ChatHistoryFolderInterface { - [folderName: string]: ChatHistoryInterface[]; + [folderId: string]: ChatHistoryInterface[]; +} + +export interface FolderCollection { + [folderId: string]: Folder; +} + +export interface Folder { + id: string; + name: string; + expanded: boolean; + order: number; + color?: string; } export type ModelOptions = 'gpt-4' | 'gpt-4-32k' | 'gpt-3.5-turbo'; @@ -120,3 +134,10 @@ export interface LocalStorageInterfaceV6ToV7 { firstVisit: boolean; hideSideMenu: boolean; } + +export interface LocalStorageInterfaceV7oV8 + extends LocalStorageInterfaceV6ToV7 { + foldersName: string[]; + foldersExpanded: boolean[]; + folders: FolderCollection; +} diff --git a/src/types/export.ts b/src/types/export.ts new file mode 100644 index 0000000..0c93b74 --- /dev/null +++ b/src/types/export.ts @@ -0,0 +1,12 @@ +import { ChatInterface, FolderCollection } from './chat'; + +export interface ExportBase { + version: number; +} + +export interface ExportV1 extends ExportBase { + chats?: ChatInterface[]; + folders: FolderCollection; +} + +export default ExportV1; diff --git a/src/utils/chat.ts b/src/utils/chat.ts index 997c9c4..9cc8cdd 100644 --- a/src/utils/chat.ts +++ b/src/utils/chat.ts @@ -1,63 +1,7 @@ import html2canvas from 'html2canvas'; // import jsPDF from 'jspdf'; -import { ChatInterface, ConfigInterface, MessageInterface } from '@type/chat'; -import { roles } from '@type/chat'; +import { ChatInterface } from '@type/chat'; import { Theme } from '@type/theme'; -import { - defaultModel, - modelOptions, - _defaultChatConfig, -} from '@constants/chat'; - -export const validateAndFixChats = (chats: any): chats is ChatInterface[] => { - if (!Array.isArray(chats)) return false; - - for (const chat of chats) { - if (!(typeof chat.title === 'string') || chat.title === '') return false; - - if (chat.titleSet === undefined) chat.titleSet = false; - if (!(typeof chat.titleSet === 'boolean')) return false; - - if (!validateMessage(chat.messages)) return false; - if (!validateAndFixChatConfig(chat.config)) return false; - } - - return true; -}; - -const validateMessage = (messages: MessageInterface[]) => { - if (!Array.isArray(messages)) return false; - for (const message of messages) { - if (!(typeof message.content === 'string')) return false; - if (!(typeof message.role === 'string')) return false; - if (!roles.includes(message.role)) return false; - } - return true; -}; - -const validateAndFixChatConfig = (config: ConfigInterface) => { - if (config === undefined) config = _defaultChatConfig; - if (!(typeof config === 'object')) return false; - - if (!config.temperature) config.temperature = _defaultChatConfig.temperature; - if (!(typeof config.temperature === 'number')) return false; - - if (!config.presence_penalty) - config.presence_penalty = _defaultChatConfig.presence_penalty; - if (!(typeof config.presence_penalty === 'number')) return false; - - if (!config.top_p) config.top_p = _defaultChatConfig.top_p; - if (!(typeof config.top_p === 'number')) return false; - - if (!config.frequency_penalty) - config.frequency_penalty = _defaultChatConfig.frequency_penalty; - if (!(typeof config.frequency_penalty === 'number')) return false; - - if (!config.model) config.model = defaultModel; - if (!modelOptions.includes(config.model)) return false; - - return true; -}; export const htmlToImg = async (html: HTMLDivElement) => { const needResize = window.innerWidth >= 1024; diff --git a/src/utils/import.ts b/src/utils/import.ts new file mode 100644 index 0000000..c5dc8d2 --- /dev/null +++ b/src/utils/import.ts @@ -0,0 +1,90 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { + ChatInterface, + ConfigInterface, + FolderCollection, + MessageInterface, +} from '@type/chat'; +import { roles } from '@type/chat'; +import { + defaultModel, + modelOptions, + _defaultChatConfig, +} from '@constants/chat'; +import { ExportV1 } from '@type/export'; + +export const validateAndFixChats = (chats: any): chats is ChatInterface[] => { + if (!Array.isArray(chats)) return false; + + for (const chat of chats) { + if (!(typeof chat.id === 'string')) chat.id = uuidv4(); + if (!(typeof chat.title === 'string') || chat.title === '') return false; + + if (chat.titleSet === undefined) chat.titleSet = false; + if (!(typeof chat.titleSet === 'boolean')) return false; + + if (!validateMessage(chat.messages)) return false; + if (!validateAndFixChatConfig(chat.config)) return false; + } + + return true; +}; + +const validateMessage = (messages: MessageInterface[]) => { + if (!Array.isArray(messages)) return false; + for (const message of messages) { + if (!(typeof message.content === 'string')) return false; + if (!(typeof message.role === 'string')) return false; + if (!roles.includes(message.role)) return false; + } + return true; +}; + +const validateAndFixChatConfig = (config: ConfigInterface) => { + if (config === undefined) config = _defaultChatConfig; + if (!(typeof config === 'object')) return false; + + if (!config.temperature) config.temperature = _defaultChatConfig.temperature; + if (!(typeof config.temperature === 'number')) return false; + + if (!config.presence_penalty) + config.presence_penalty = _defaultChatConfig.presence_penalty; + if (!(typeof config.presence_penalty === 'number')) return false; + + if (!config.top_p) config.top_p = _defaultChatConfig.top_p; + if (!(typeof config.top_p === 'number')) return false; + + if (!config.frequency_penalty) + config.frequency_penalty = _defaultChatConfig.frequency_penalty; + if (!(typeof config.frequency_penalty === 'number')) return false; + + if (!config.model) config.model = defaultModel; + if (!modelOptions.includes(config.model)) return false; + + return true; +}; + +export const isLegacyImport = (importedData: any) => { + if (Array.isArray(importedData)) return true; + return false; +}; + +export const validateFolders = ( + folders: FolderCollection +): folders is FolderCollection => { + if (typeof folders !== 'object') return false; + + for (const folderId in folders) { + if (typeof folders[folderId].id !== 'string') return false; + if (typeof folders[folderId].name !== 'string') return false; + if (typeof folders[folderId].order !== 'number') return false; + if (typeof folders[folderId].expanded !== 'boolean') return false; + } + + return true; +}; + +export const validateExportV1 = (data: ExportV1): data is ExportV1 => { + return validateAndFixChats(data.chats) && validateFolders(data.folders); +};