import { produce, Draft, original } from 'immer'; import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer'; import shareServiceReducer, { stateRootKey as shareServiceStateRootKey, defaultState as shareServiceDefaultState, State as ShareServiceState } from './services/share/reducer'; import Note from './models/Note'; import Folder from './models/Folder'; import BaseModel from './BaseModel'; import { Store } from 'redux'; import { ProfileConfig } from './services/profileConfig/types'; import * as ArrayUtils from './ArrayUtils'; import { FolderEntity, NoteEntity, NoteTagEntity } from './services/database/types'; import { MasterKeyEntity } from './services/e2ee/types'; import type { ProgressReport } from './Synchronizer'; import type { SharedData } from './components/shared/note-screen-shared'; export interface SearchEntry { id: string; type_: number; title: string; query_pattern: string; query_type: number; parent_id?: string; } import { getListRendererIds } from './services/noteList/renderers'; import { ComplexTerm, ProcessResultsRow } from './services/search/SearchEngine'; import { getDisplayParentId, getTrashFolderId } from './services/trash'; import Logger from '@joplin/utils/Logger'; import { SettingsRecord } from './models/settings/types'; import { Toast, ToastType } from './services/plugins/api/types'; import { unique } from './array'; import fastDeepEqual = require('fast-deep-equal'); import { ALL_NOTES_FILTER_ID } from './reserved-ids'; import ItemChange from './models/ItemChange'; const { createSelectorCreator, defaultMemoize } = require('reselect'); const { createCachedSelector } = require('re-reselect'); const logger = Logger.create('lib/reducer'); // Reducers from plugins/share are immer-based and mutate Draft in-place; the per-sub-state shapes // differ across consumers so we keep this loosely typed at the registration layer. interface AdditionalReducer { stateRootKey: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See above defaultState: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See above reducer: (state: any, action: any)=> any; } const additionalReducers: AdditionalReducer[] = []; additionalReducers.push({ stateRootKey: pluginServiceStateRootKey, defaultState: pluginServiceDefaultState, reducer: pluginServiceReducer, }); additionalReducers.push({ stateRootKey: shareServiceStateRootKey, defaultState: shareServiceDefaultState, reducer: shareServiceReducer, }); interface StateLastSelectedNotesIds { Folder: Record; Tag: Record; Search: Record; } interface StateClipperServer { startState: string; port: number; } export interface StateDecryptionWorker { state: string; itemIndex: number; itemCount: number; decryptedItemCounts: Record; decryptedItemCount: number; skippedItemCount: number; } export interface StateResourceFetcher { toFetchCount: number; fetchingCount: number; } export interface StateLastDeletion { noteIds: string[]; folderIds: string[]; timestamp: number; } // The background note lock key migration, kept after it ends until its banner is dismissed. export interface NoteLockMigrationStatus { running: boolean; failed: number; skipped: number; awaitingDecryption: number; } export type HighlightedWord = ComplexTerm|string; export interface WindowState { windowId: string; notes: NoteEntity[]; noteSelectionEnabled?: boolean; notesSource: string; notesParentType: string; selectedNoteTags: NoteTagEntity[]; searchQuery: string; selectedNoteIds: string[]; selectedNoteHash: string; // Only used to disable menu actions that update the active note in the editor. Bulk actions // do not need to be disabled because they do not result in an error. activeNoteIsUndecryptable: boolean; selectedFolderId: string; selectedFolderIds: string[]; selectedTagId: string; selectedTagIds: string[]; selectedSearchId: string; selectedItemType: string; selectedSmartFilterId: string; highlightedWords: HighlightedWord[]; backwardHistoryNotes: NoteEntity[]; forwardHistoryNotes: NoteEntity[]; lastSelectedNotesIds: StateLastSelectedNotesIds; windowEditorNoteReloadTimeRequest: number; } export const defaultWindowId = 'default'; export const defaultWindowState: WindowState = { windowId: defaultWindowId, searchQuery: '', notes: [], notesSource: '', notesParentType: null, selectedNoteIds: [], selectedNoteHash: '', activeNoteIsUndecryptable: false, selectedFolderId: null, selectedFolderIds: [], selectedTagId: null, selectedTagIds: [], selectedSearchId: null, selectedSmartFilterId: null, selectedItemType: 'note', selectedNoteTags: [], highlightedWords: [], backwardHistoryNotes: [], forwardHistoryNotes: [], lastSelectedNotesIds: { Folder: {}, Tag: {}, Search: {}, }, windowEditorNoteReloadTimeRequest: 0, }; export interface EditorNoteStatuses { [id: string]: string; } export interface State extends WindowState { // Contains state specific to windows that currently don't have focus. // See spec/background_windows.md for details. backgroundWindows: Record; folders: FolderEntity[]; tags: NoteTagEntity[]; masterKeys: MasterKeyEntity[]; notLoadedMasterKeys: string[]; searches: SearchEntry[]; showSideMenu: boolean; screens: Record }>; historyCanGoBack: boolean; syncStarted: boolean; syncPending: boolean; showQuitSyncDialog: boolean; syncReport: ProgressReport; searchResults: ProcessResultsRow[]; settings: Partial; sharedData: SharedData; appState: string; biometricsDone: boolean; hasDisabledSyncItems: boolean; hasDisabledEncryptionItems: boolean; customViewerCss: string; customChromeCssPaths: string[]; collapsedFolderIds: string[]; clipperServer: StateClipperServer; decryptionWorker: StateDecryptionWorker; resourceFetcher: StateResourceFetcher; pluginsLegacy: Record; provisionalNoteIds: string[]; editorNoteStatuses: EditorNoteStatuses; isInsertingNotes: boolean; hasEncryptedItems: boolean; noteLockSessionUnlocked: boolean; noteLockMigrationStatus: NoteLockMigrationStatus|null; needApiAuth: boolean; profileConfig: ProfileConfig; noteListRendererIds: string[]; noteListLastSortTime: number; lastDeletion: StateLastDeletion; lastDeletionNotificationTime: number; mustUpgradeAppMessage: string; mustUpgradeAppMinVersion: string; mustAuthenticate: boolean; toast: Toast | null; editorNoteReloadTimeRequest: number; allowSelectionInOtherFolders: boolean; noteHtmlToMarkdownDone: string; // Extra reducer keys go here: pluginService: PluginServiceState; shareService: ShareServiceState; } export const defaultState: State = { ...defaultWindowState, backgroundWindows: {}, folders: [], tags: [], masterKeys: [], notLoadedMasterKeys: [], searches: [], highlightedWords: [], showSideMenu: false, screens: {}, historyCanGoBack: false, syncStarted: false, syncPending: false, showQuitSyncDialog: false, syncReport: { errors: [] }, searchQuery: '', searchResults: [], settings: {}, sharedData: null, appState: 'starting', biometricsDone: false, hasDisabledSyncItems: false, hasDisabledEncryptionItems: false, customViewerCss: '', customChromeCssPaths: [], collapsedFolderIds: [], clipperServer: { startState: 'idle', port: null, }, decryptionWorker: { state: 'idle', itemIndex: 0, itemCount: 0, decryptedItemCounts: {}, decryptedItemCount: 0, skippedItemCount: 0, }, resourceFetcher: { toFetchCount: 0, fetchingCount: 0, }, // pluginsLegacy is the original plugin system, which eventually was used only for GotoAnything. // GotoAnything should be refactored to part of core and when it's done the pluginsLegacy key can // be removed. It was originally named "plugins", then renamed "pluginsLegacy" so as not to conflict // with the new "plugins" key used for the new plugin system. pluginsLegacy: {}, provisionalNoteIds: [], editorNoteStatuses: {}, isInsertingNotes: false, hasEncryptedItems: false, noteLockSessionUnlocked: false, noteLockMigrationStatus: null, needApiAuth: false, profileConfig: null, noteListRendererIds: getListRendererIds(), noteListLastSortTime: 0, lastDeletion: { noteIds: [], folderIds: [], timestamp: 0, }, lastDeletionNotificationTime: 0, mustUpgradeAppMessage: '', mustUpgradeAppMinVersion: '', mustAuthenticate: false, editorNoteReloadTimeRequest: 0, allowSelectionInOtherFolders: false, noteHtmlToMarkdownDone: '', pluginService: pluginServiceDefaultState, shareService: shareServiceDefaultState, toast: null, }; for (const additionalReducer of additionalReducers) { (defaultState as unknown as Record)[additionalReducer.stateRootKey] = additionalReducer.defaultState; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Store is parametrised per-app (cli/desktop/mobile extend State); referenced loosely here let store_: Store = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See store_ above export function setStore(v: Store) { store_ = v; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See store_ above export function store(): Store { return store_; } export const MAX_HISTORY = 200; const derivedStateCache_: Record = {}; // Allows, for a given state, to return the same derived // objects, to prevent unnecessary updates on calling components. const cacheEnabledOutput = (key: string, output: T): T => { key = `${key}_${JSON.stringify(output)}`; if (derivedStateCache_[key]) return derivedStateCache_[key] as T; derivedStateCache_[key] = output; return derivedStateCache_[key] as T; }; const createShallowArrayEqualSelector = createSelectorCreator( defaultMemoize, (prev: unknown[], next: unknown[]) => { if (prev.length !== next.length) return false; for (let i = 0; i < prev.length; i++) { if (prev[i] !== next[i]) return false; } return true; }, ); const selectArrayShallow = createCachedSelector( (state: { array: unknown[] }) => state.array, (array: unknown[]) => array, )({ keySelector: (_state: { array: unknown[] }, cacheKey: string) => { return cacheKey; }, selectorCreator: createShallowArrayEqualSelector, }); class StateUtils { // Given an input array, this selector ensures that the same array is returned // if its content hasn't changed. public selectArrayShallow(props: { array: unknown[] }, cacheKey: string) { return selectArrayShallow(props, cacheKey); } public oneNoteSelected(state: WindowState): boolean { return state.selectedNoteIds.length === 1; } public notesOrder(stateSettings: Partial) { if (stateSettings['notes.sortOrder.field'] === 'order') { return cacheEnabledOutput('notesOrder', [ { by: 'order', dir: 'DESC', }, { by: 'user_created_time', dir: 'DESC', }, ]); } else { return cacheEnabledOutput('notesOrder', [ { by: stateSettings['notes.sortOrder.field'], dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC', }, ]); } } public foldersOrder(stateSettings: Partial) { return cacheEnabledOutput('foldersOrder', [ { by: stateSettings['folders.sortOrder.field'], dir: stateSettings['folders.sortOrder.reverse'] ? 'DESC' : 'ASC', }, ]); } public hasNotesBeingSaved(state: State): boolean { for (const id in state.editorNoteStatuses) { if (state.editorNoteStatuses[id] === 'saving') return true; } return false; } public parentItem(state: WindowState) { const t = state.notesParentType; let id = null; if (t === 'Folder') id = state.selectedFolderId; if (t === 'Tag') id = state.selectedTagId; if (t === 'Search') id = state.selectedSearchId; if (!t || !id) return null; return { type: t, id: id }; } public lastSelectedNoteIds(state: WindowState): string[] { const parent = this.parentItem(state); if (!parent) return []; const output = (state.lastSelectedNotesIds as unknown as Record>)[parent.type][parent.id]; return output ? output : []; } public selectedNote(state: WindowState): NoteEntity { const noteId = this.selectedNoteId(state); return noteId ? BaseModel.byId(state.notes, noteId) : null; } public selectedNoteId(state: WindowState): string|null { return state.selectedNoteIds.length ? state.selectedNoteIds[0] : null; } public activeWindowId(state: State) { return state.windowId; } private allWindowIds(state: State) { return [state.windowId, ...Object.keys(state.backgroundWindows)]; } public allWindowStates(state: T) { return this.allWindowIds(state).map(id => this.windowStateById(state, id)); } public windowStateById( state: StateType, id: string, ) { // States for the different Joplin apps can have different types for backgroundWindows -- this // makes sure that the correct type is returned. type AppWindowState = StateType['backgroundWindows'][keyof StateType['backgroundWindows']]; const result = id === state.windowId ? state : state.backgroundWindows[id]; return result as AppWindowState; } public mainWindowState(state: State) { return this.windowStateById(state, defaultWindowId); } public secondaryWindowStates(state: State) { const windowIds = [state.windowId, ...Object.keys(state.backgroundWindows)]; return windowIds .filter(id => (id !== defaultWindowId)) .map(id => this.windowStateById(state, id)); } public windowIdToSelectedNoteIds(state: State) { const result: Record = {}; for (const id of this.allWindowIds(state)) { result[id] = this.windowStateById(state, id).selectedNoteIds; } return result; } } export const stateUtils: StateUtils = new StateUtils(); function arrayHasEncryptedItems(array: { encryption_applied?: number | boolean }[]) { for (let i = 0; i < array.length; i++) { if (array[i].encryption_applied) return true; } return false; } function stateHasEncryptedItems(state: State) { if (arrayHasEncryptedItems(state.notes)) return true; if (arrayHasEncryptedItems(state.folders)) return true; if (arrayHasEncryptedItems(state.tags)) return true; return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- action shape varies (FOLDER_TOGGLE/FOLDER_SET_COLLAPSED); see handleItemDelete above function folderSetCollapsed(draft: Draft, action: any) { if (action.ids) { draft.collapsedFolderIds = action.ids; return; } const collapsedFolderIds = draft.collapsedFolderIds.slice(); const idx = collapsedFolderIds.indexOf(action.id); if (action.collapsed) { if (idx >= 0) return; collapsedFolderIds.push(action.id); } else { if (idx < 0) return; collapsedFolderIds.splice(idx, 1); } draft.collapsedFolderIds = collapsedFolderIds; } function removeAdjacentDuplicates(items: T[]) { return items.filter((item: T, idx: number) => (idx >= 1) ? items[idx - 1].id !== item.id : true); } // When deleting a note, tag or folder // eslint-disable-next-line @typescript-eslint/no-explicit-any -- action varies per type (FOLDER_DELETE, NOTE_DELETE, TAG_DELETE, SEARCH_DELETE) and carries the item id; reducer matches dynamically by type function handleItemDelete(draft: Draft, action: any) { type SelectionKey = 'selectedFolderId'|'selectedNoteIds'|'selectedTagId'|'selectedSearchId'; const map: Record = { FOLDER_DELETE: ['folders', 'selectedFolderId', true], NOTE_DELETE: ['notes', 'selectedNoteIds', false], TAG_DELETE: ['tags', 'selectedTagId', true], SEARCH_DELETE: ['searches', 'selectedSearchId', true], }; const listKey = map[action.type][0]; const selectedItemKey = map[action.type][1]; const isSingular = map[action.type][2]; for (const windowDraft of stateUtils.allWindowStates(draft)) { const selectedItemKeys = isSingular ? [windowDraft[selectedItemKey]] : windowDraft[selectedItemKey]; const isSelected = selectedItemKeys.includes(action.id); type ListItemSlice = { id: string }; const items = (listKey in windowDraft ? windowDraft[listKey as keyof WindowState] : draft[listKey]) as ListItemSlice[]; const newItems = []; let newSelectedIndexes: number[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (isSelected) { // the selected item is deleted so select the following item // if multiple items are selected then just use the first one if (selectedItemKeys[0] === item.id) { newSelectedIndexes.push(newItems.length); } } else { // the selected item/s is not deleted so keep it selected if (selectedItemKeys.includes(item.id)) { newSelectedIndexes.push(newItems.length); } } if (item.id === action.id) { continue; } newItems.push(item); } if (newItems.length === 0) { newSelectedIndexes = []; // no remaining items so no selection } else if (newSelectedIndexes.length === 0) { newSelectedIndexes.push(0); // no selection exists so select the top } else { // when the items at end of list are deleted then select the end for (let i = 0; i < newSelectedIndexes.length; i++) { if (newSelectedIndexes[i] >= newItems.length) { newSelectedIndexes = [newItems.length - 1]; break; } } } if (listKey in windowDraft) { (windowDraft as unknown as Record)[listKey] = newItems; } const newIds = []; for (let i = 0; i < newSelectedIndexes.length; i++) { newIds.push(newItems[newSelectedIndexes[i]].id); } (windowDraft as unknown as Record)[selectedItemKey] = isSingular ? newIds[0] : newIds; if ((newIds.length === 0) && windowDraft.notesParentType !== 'Folder') { windowDraft.notesParentType = 'Folder'; } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- action shape varies per case (TAG_UPDATE_ONE, FOLDER_UPDATE_ONE, MASTERKEY_UPDATE_ONE, plus per-window variants); each carries a typed `item` function updateOneItem(draft: Draft, action: any, keyName = '') { let itemsKey = null; if (keyName) { itemsKey = keyName; } else { if (action.type === 'TAG_UPDATE_ONE') itemsKey = 'tags'; if (action.type === 'FOLDER_UPDATE_ONE') itemsKey = 'folders'; if (action.type === 'MASTERKEY_UPDATE_ONE') itemsKey = 'masterKeys'; } const draftRecord = draft as unknown as Record; const newItems = draftRecord[itemsKey].slice(); const item = action.item; let found = false; for (let i = 0; i < newItems.length; i++) { const n = newItems[i]; if (n.id === item.id) { newItems[i] = { ...newItems[i], ...item }; found = true; break; } } if (!found) newItems.push(item); draftRecord[itemsKey] = newItems; } function updateSelectedNotesFromExistingNotes(draft: Draft) { const newSelectedNoteIds = []; for (const selectedNoteId of draft.selectedNoteIds) { for (const n of draft.notes) { if (n.id === selectedNoteId) { newSelectedNoteIds.push(n.id); } } } if (JSON.stringify(draft.selectedNoteIds) === JSON.stringify(newSelectedNoteIds)) return; draft.selectedNoteIds = newSelectedNoteIds; } function defaultNotesParentType(draft: Draft, exclusion: string) { let newNotesParentType = null; if (exclusion !== 'SmartFilter' && draft.selectedSmartFilterId) { newNotesParentType = 'SmartFilter'; } else if (exclusion !== 'Folder' && draft.selectedFolderId) { newNotesParentType = 'Folder'; } else if (exclusion !== 'Tag' && draft.selectedTagId) { newNotesParentType = 'Tag'; } else if (exclusion !== 'Search' && draft.selectedSearchId) { newNotesParentType = 'Search'; } return newNotesParentType; } export type NotesParentType = 'Folder' | 'Tag' | 'SmartFilter'; export interface NotesParent { type: NotesParentType; selectedItemId: string; } export const serializeNotesParent = (n: NotesParent) => { return JSON.stringify(n); }; export const parseNotesParent = (s: string, activeFolderId: string): NotesParent => { const defaultValue: NotesParent = { type: 'Folder', selectedItemId: activeFolderId, }; if (!s) return defaultValue; try { const parsed = JSON.parse(s); return parsed; } catch (error) { return defaultValue; } }; export const getNotesParent = (state: State): NotesParent => { let type = state.notesParentType as NotesParentType; let selectedItemId = ''; if (type === 'Folder') { selectedItemId = state.selectedFolderId; } else if (type === 'Tag') { selectedItemId = state.selectedTagId; } else if (type === 'SmartFilter') { selectedItemId = state.selectedSmartFilterId; } else { type = 'Folder'; selectedItemId = state.selectedFolderId; } return { type, selectedItemId }; }; interface ChangeSelectedTagOrFolderOptions { extendSelection?: boolean; } function changeSelectedTagOrFolder( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- action shape varies per FOLDER_SELECT*/TAG_SELECT* type (id, folderId, tagId, ids); discriminated dynamically draft: Draft, action: any, { extendSelection = false }: ChangeSelectedTagOrFolderOptions = {}, ) { const isFolder = action.type.startsWith('FOLDER'); const isTag = action.type.startsWith('TAG'); if (!isFolder && !isTag) { throw new Error(`Unable to determine item type from action. Action.type: ${action.type}`); } let itemIds = []; if ('folderId' in action) { itemIds = [action.folderId]; } else if ('tagId' in action) { itemIds = [action.tagId]; } else if ('ids' in action) { itemIds = [...action.ids]; } else { if (!('id' in action)) { throw new Error(`Missing id in ${action.type} action.`); } itemIds = [action.id]; } const propertyNameSingular: keyof State = isTag ? 'selectedTagId' : 'selectedFolderId'; const propertyNamePlural: keyof State = isTag ? 'selectedTagIds' : 'selectedFolderIds'; const lastParentType = draft.notesParentType; draft.notesParentType = isTag ? 'Tag' : 'Folder'; draft[propertyNamePlural] = extendSelection ? unique([...draft[propertyNamePlural], ...itemIds]) : itemIds; draft[propertyNameSingular] = itemIds[itemIds.length - 1]; const hasItem = !!draft[propertyNameSingular]; if (!hasItem) { draft.notesParentType = defaultNotesParentType(draft, isTag ? 'Tag' : 'Folder'); } const clearSelectedNoteIds = !extendSelection || lastParentType !== draft.notesParentType; if (clearSelectedNoteIds) draft.selectedNoteIds = []; } function recordLastSelectedNoteIds(draft: Draft, noteIds: string[]) { const newOnes: Record> = { ...(draft.lastSelectedNotesIds as unknown as Record>) }; const parent = stateUtils.parentItem(draft); if (!parent) return; newOnes[parent.type] = { ...newOnes[parent.type] }; newOnes[parent.type][parent.id] = noteIds.slice(); draft.lastSelectedNotesIds = newOnes as unknown as StateLastSelectedNotesIds; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- NOTE_SELECT/NOTE_SELECT_ADD/NOTE_SELECT_REMOVE action shapes vary; options bag holds reducer-internal flags function changeSelectedNotes(draft: Draft, action: any, options: any = null) { if (!options) options = {}; let noteIds = []; if (action.id) noteIds = [action.id]; if (action.ids) noteIds = action.ids; if (action.noteId) noteIds = [action.noteId]; if (action.index) noteIds = [draft.notes[action.index].id]; if (action.type === 'NOTE_SELECT') { if (JSON.stringify(draft.selectedNoteIds) === JSON.stringify(noteIds)) return; draft.selectedNoteIds = noteIds; draft.selectedNoteHash = action.hash ? action.hash : ''; } else if (action.type === 'NOTE_SELECT_ADD') { if (!noteIds.length) return; draft.selectedNoteIds = ArrayUtils.unique(draft.selectedNoteIds.concat(noteIds)); } else if (action.type === 'NOTE_SELECT_REMOVE') { if (!noteIds.length) return; // Nothing to unselect if (draft.selectedNoteIds.length <= 1) return; // Cannot unselect the last note const newSelectedNoteIds = []; for (let i = 0; i < draft.selectedNoteIds.length; i++) { const id = draft.selectedNoteIds[i]; if (noteIds.indexOf(id) >= 0) continue; newSelectedNoteIds.push(id); } draft.selectedNoteIds = newSelectedNoteIds; } else if (action.type === 'NOTE_SELECT_TOGGLE') { if (!noteIds.length) return; if (draft.selectedNoteIds.indexOf(noteIds[0]) >= 0) { changeSelectedNotes(draft, { type: 'NOTE_SELECT_REMOVE', id: noteIds[0] }); } else { changeSelectedNotes(draft, { type: 'NOTE_SELECT_ADD', id: noteIds[0] }); } } else { throw new Error('Unreachable'); } recordLastSelectedNoteIds(draft, draft.selectedNoteIds); } function removeItemFromArray>(array: T[], property: string, value: unknown) { for (let i = 0; i !== array.length; ++i) { const currentItem = array[i]; if (currentItem[property] === value) { array = array.slice(); array.splice(i, 1); break; } } return array; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- history note entries are NoteEntity augmented with `notesParentType` and the selected* fields above const getContextFromHistory = (ctx: any) => { const result: Record = {}; result.notesParentType = ctx.notesParentType; if (result.notesParentType === 'Folder') { result.selectedFolderId = ctx.selectedFolderId; result.selectedFolderIds = [result.selectedFolderId]; } else if (result.notesParentType === 'Tag') { result.selectedTagId = ctx.selectedTagId; result.selectedTagIds = [result.selectedTagIds]; } else if (result.notesParentType === 'Search') { result.selectedSearchId = ctx.selectedSearchId; result.searches = ctx.searches; } else if (result.notesParentType === 'SmartFilter') { result.selectedSmartFilterId = ctx.selectedSmartFilterId; } return result; }; function getNoteHistoryInfo(state: State) { const selectedNoteIds = state.selectedNoteIds; const notes = state.notes; if (selectedNoteIds && selectedNoteIds.length > 0) { const currNote = notes.find(note => note.id === selectedNoteIds[0]); if (currNote) { return { id: currNote.id, parent_id: currNote.parent_id, notesParentType: state.notesParentType, selectedFolderId: state.selectedFolderId, selectedTagId: state.selectedTagId, selectedSearchId: state.selectedSearchId, searches: state.searches, selectedSmartFilterId: state.selectedSmartFilterId, }; } } return null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- HISTORY_BACKWARD/FORWARD action shapes vary; reducer matches dynamically by type function handleHistory(draft: Draft, action: any) { const currentNote = getNoteHistoryInfo(draft); switch (action.type) { case 'HISTORY_BACKWARD': { const note = draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1]; if (currentNote && (draft.forwardHistoryNotes.length === 0 || currentNote.id !== draft.forwardHistoryNotes[draft.forwardHistoryNotes.length - 1].id)) { draft.forwardHistoryNotes = draft.forwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } changeSelectedTagOrFolder(draft, { ...action, type: 'FOLDER_SELECT', folderId: note.parent_id }); changeSelectedNotes(draft, { ...action, type: 'NOTE_SELECT', noteId: note.id }); const ctx = draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1]; Object.assign(draft, getContextFromHistory(ctx)); draft.backwardHistoryNotes.pop(); break; } case 'HISTORY_FORWARD': { const note = draft.forwardHistoryNotes[draft.forwardHistoryNotes.length - 1]; if (currentNote && (draft.backwardHistoryNotes.length === 0 || currentNote.id !== draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1].id)) { draft.backwardHistoryNotes = draft.backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } changeSelectedTagOrFolder(draft, { ...action, type: 'FOLDER_SELECT', folderId: note.parent_id }); changeSelectedNotes(draft, { ...action, type: 'NOTE_SELECT', noteId: note.id }); const ctx = draft.forwardHistoryNotes[draft.forwardHistoryNotes.length - 1]; Object.assign(draft, getContextFromHistory(ctx)); draft.forwardHistoryNotes.pop(); break; } case 'NOTE_SELECT': if (currentNote && action.id !== currentNote.id) { draft.forwardHistoryNotes = []; draft.backwardHistoryNotes = draft.backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } // History should be free from duplicates. if (draft.backwardHistoryNotes && draft.backwardHistoryNotes.length > 0 && action.id === draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1].id) { draft.backwardHistoryNotes.pop(); } break; case 'TAG_SELECT': case 'FOLDER_AND_NOTE_SELECT': case 'FOLDER_SELECT': if (currentNote) { if (draft.forwardHistoryNotes.length) draft.forwardHistoryNotes = []; draft.backwardHistoryNotes = draft.backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } break; case 'SEARCH_UPDATE': if (currentNote && (draft.backwardHistoryNotes.length === 0 || draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1].id !== currentNote.id)) { if (draft.forwardHistoryNotes.length) draft.forwardHistoryNotes = []; draft.backwardHistoryNotes = draft.backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } break; case 'SEARCH_RESULTS_SET': draft.searchResults = action.value; break; } const updateWindowHistory = (windowDraft: Draft) => { switch (action.type) { case 'NOTE_UPDATE_ONE': { const modNote = action.note; windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.map(note => { if (note.id === modNote.id) { return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; } return note; }); windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.map(note => { if (note.id === modNote.id) { return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; } return note; }); break; } case 'FOLDER_DELETE': windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.filter(note => note.parent_id !== action.id); windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.filter(note => note.parent_id !== action.id); windowDraft.backwardHistoryNotes = removeAdjacentDuplicates(windowDraft.backwardHistoryNotes); windowDraft.forwardHistoryNotes = removeAdjacentDuplicates(windowDraft.forwardHistoryNotes); break; case 'NOTE_DELETE': { windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.filter(note => note.id !== action.id); windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.filter(note => note.id !== action.id); windowDraft.backwardHistoryNotes = removeAdjacentDuplicates(windowDraft.backwardHistoryNotes); windowDraft.forwardHistoryNotes = removeAdjacentDuplicates(windowDraft.forwardHistoryNotes); // Fix the case where after deletion the currently selected note is also the latest in history const selectedNoteIds = windowDraft.selectedNoteIds; if (selectedNoteIds.length && windowDraft.backwardHistoryNotes.length && windowDraft.backwardHistoryNotes[windowDraft.backwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.slice(0, windowDraft.backwardHistoryNotes.length - 1); } if (selectedNoteIds.length && windowDraft.forwardHistoryNotes.length && windowDraft.forwardHistoryNotes[windowDraft.forwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.slice(0, windowDraft.forwardHistoryNotes.length - 1); } break; } } }; updateWindowHistory(draft); for (const id in draft.backgroundWindows) { updateWindowHistory(draft.backgroundWindows[id]); } } type WindowAction = { type: 'WINDOW_OPEN'; windowId: string; folderId: string; noteId: string; defaultAppWindowState: Record; }|{ type: 'WINDOW_FOCUS'|'WINDOW_CLOSE'; windowId: string; }; const handleWindowActions = (draft: Draft, action: WindowAction) => { const handleFocus = (windowId: string) => { // Only allow bringing a background window to the foreground if (draft.windowId !== windowId) { const previousWindowId = draft.windowId; const focusingWindowState = draft.backgroundWindows[windowId]; const previousWindowState = { ...defaultWindowState }; for (const key of Object.keys(focusingWindowState)) { const stateKey = key as keyof WindowState; type AssignableWindowState = Record; (previousWindowState as AssignableWindowState)[stateKey] = draft[stateKey]; (draft as AssignableWindowState)[stateKey] = focusingWindowState[stateKey]; } delete draft.backgroundWindows[windowId]; draft.backgroundWindows[previousWindowId] = previousWindowState; } }; switch (action.type) { case 'WINDOW_OPEN': { if (action.windowId in draft.backgroundWindows) { throw new Error(`Window with id ${action.windowId} is already open!`); } draft.backgroundWindows[action.windowId] = { ...defaultWindowState, ...action.defaultAppWindowState, lastSelectedNotesIds: { ...defaultWindowState.lastSelectedNotesIds, Folder: { [action.folderId]: [action.noteId], }, }, notesParentType: 'Folder', selectedFolderId: action.folderId, windowId: action.windowId, selectedNoteIds: [action.noteId], }; break; } case 'WINDOW_FOCUS': handleFocus(action.windowId); break; case 'WINDOW_CLOSE': { const isFocusedWindow = draft.windowId === action.windowId; if (isFocusedWindow) { const firstBackgroundWindow = Object.keys(draft.backgroundWindows)[0]; handleFocus(firstBackgroundWindow); } delete draft.backgroundWindows[action.windowId]; break; } } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Top-level reducer action is the union of all redux actions across the app; matched case-by-case below const reducer = produce((draft: Draft = defaultState, action: any) => { // const reducer = (state:State = defaultState, action:any) => { // if (!['SIDE_MENU_OPEN_PERCENT'].includes(action.type)) console.info('Action', action.type, action); // let newState = state; // NOTE_DELETE requires post processing if (action.type !== 'NOTE_DELETE') { handleHistory(draft, action); } handleWindowActions(draft, action); try { switch (action.type) { case 'NOTE_SELECT': case 'NOTE_SELECT_ADD': case 'NOTE_SELECT_REMOVE': case 'NOTE_SELECT_TOGGLE': changeSelectedNotes(draft, action); break; case 'NOTE_SELECT_EXTEND': { if (!draft.selectedNoteIds.length) { draft.selectedNoteIds = [action.id]; } else { const selectRangeId1 = draft.selectedNoteIds[draft.selectedNoteIds.length - 1]; const selectRangeId2 = action.id; if (selectRangeId1 === selectRangeId2) { // Nothing } else { const newSelectedNoteIds = draft.selectedNoteIds.slice(); let selectionStarted = false; for (let i = 0; i < draft.notes.length; i++) { const id = draft.notes[i].id; if (!selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) { selectionStarted = true; if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id); continue; } else if (selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) { if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id); break; } if (selectionStarted && newSelectedNoteIds.indexOf(id) < 0) { newSelectedNoteIds.push(id); } } draft.selectedNoteIds = newSelectedNoteIds; } } } break; case 'NOTE_SELECT_ALL': draft.selectedNoteIds = draft.notes.map(n => n.id); break; case 'NOTE_SELECT_ALL_TOGGLE': { const allSelected = draft.notes.every(n => draft.selectedNoteIds.includes(n.id)); if (allSelected) { draft.selectedNoteIds = []; } else { draft.selectedNoteIds = draft.notes.map(n => n.id); } break; } case 'SMART_FILTER_SELECT': draft.notesParentType = 'SmartFilter'; draft.selectedSmartFilterId = action.id; break; case 'FOLDER_SELECT_ADD': case 'FOLDER_SELECT': changeSelectedTagOrFolder(draft, action, { extendSelection: action.type === 'FOLDER_SELECT_ADD', }); break; case 'FOLDER_SELECT_REMOVE': draft.selectedFolderIds = draft.selectedFolderIds.filter(id => id !== action.id); if (!draft.selectedFolderIds.includes(draft.selectedFolderId)) { draft.selectedFolderId = draft.selectedFolderIds[draft.selectedFolderIds.length - 1]; } draft.selectedNoteIds = []; break; case 'FOLDER_AND_NOTE_SELECT': { changeSelectedTagOrFolder(draft, action); const noteSelectAction = { ...action, type: 'NOTE_SELECT' }; changeSelectedNotes(draft, noteSelectAction); } break; case 'SETTING_UPDATE_ALL': draft.settings = action.settings; break; case 'SETTING_UPDATE_ONE': { const newSettings = { ...draft.settings }; newSettings[action.key] = action.value; draft.settings = newSettings; // Reset the sync pending status when the user updates the sync target, because if the sync target has changed then the "dirty" // state is no longer relevant if (action.key === 'sync.target') { draft.syncPending = false; } } break; case 'NOTE_HTML_TO_MARKDOWN_DONE': draft.noteHtmlToMarkdownDone = action.value; break; case 'ITEMS_TRASHED': draft.lastDeletion = { ...action.value, timestamp: Date.now(), }; break; case 'DELETION_NOTIFICATION_DONE': draft.lastDeletionNotificationTime = Date.now(); break; case 'NOTE_PROVISIONAL_FLAG_CLEAR': { const newIds = ArrayUtils.removeElement(draft.provisionalNoteIds, action.id); if (newIds !== draft.provisionalNoteIds) { draft.provisionalNoteIds = newIds; } } break; // Replace all the notes with the provided array case 'NOTE_UPDATE_ALL': draft.notes = action.notes; draft.notesSource = action.notesSource; draft.noteListLastSortTime = Date.now(); // Notes are already sorted when they are set this way. if (!draft.allowSelectionInOtherFolders) { updateSelectedNotesFromExistingNotes(draft); } break; // Insert the note into the note list if it's new, or // update it within the note array if it already exists. case 'NOTE_UPDATE_ONE': { const modNote: NoteEntity = action.note; const handleWindowState = (windowDraft: Draft) => { const isSecondaryWindow = windowDraft.windowId !== defaultWindowId; const isViewingAllNotes = (windowDraft.notesParentType === 'SmartFilter' && windowDraft.selectedSmartFilterId === ALL_NOTES_FILTER_ID); const isViewingConflictFolder = windowDraft.notesParentType === 'Folder' && windowDraft.selectedFolderId === Folder.conflictFolderId(); const isOnlySelectedInSecondaryWindow = isSecondaryWindow && windowDraft.selectedNoteIds.length === 1 && windowDraft.selectedNoteIds[0] === modNote.id; const noteDisplayParentId = (note: NoteEntity) => { if (note.deleted_time) return getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); if (note.is_conflict) return Folder.conflictFolderId(); return getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); }; const noteIsInCurrentView = function(note: NoteEntity, folderId: string) { // Deleted conflicts belong to Trash. This check needs to happen before the // conflict check because is_conflict is only available after decryption. if (note.deleted_time) { const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); return folderId === noteDisplayParentId; } if (note.is_conflict) return isViewingConflictFolder; if (isViewingAllNotes) return true; const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); return folderId === noteDisplayParentId; }; let movedNotePreviousIndex = 0; let noteFolderHasChanged = false; const newNotes = windowDraft.notes.slice(); let found = false; for (let i = 0; i < newNotes.length; i++) { const n = newNotes[i]; if (n.id === modNote.id) { const previousDisplayParentId = ('parent_id' in n) ? noteDisplayParentId(n) : ''; // is_conflict is encrypted. During sync, retain the conflict identity from // the existing deleted note until the restored placeholder is decrypted. const isEncryptedRestoredConflict = !!n.is_conflict && !!n.deleted_time && !modNote.deleted_time && !!modNote.encryption_applied; const displayParentId = isEncryptedRestoredConflict ? Folder.conflictFolderId() : noteDisplayParentId(modNote); const conflictTrashStateChanged = !!n.is_conflict && (!!modNote.is_conflict || isEncryptedRestoredConflict) && !!n.deleted_time !== !!modNote.deleted_time; const conflictBecameRegularNote = !!n.is_conflict && !modNote.is_conflict; const regularNoteMoved = !n.is_conflict && !modNote.is_conflict && previousDisplayParentId !== displayParentId; const displayParentChanged = !!action.noteMovedToFolder || conflictTrashStateChanged || conflictBecameRegularNote || regularNoteMoved; const shouldFollowMovedNote = isOnlySelectedInSecondaryWindow && windowDraft.notesParentType === 'Folder' && displayParentChanged; if (shouldFollowMovedNote) { const parentFolder = draft.folders.find(f => f.id === displayParentId); const isVirtualFolder = displayParentId === getTrashFolderId() || displayParentId === Folder.conflictFolderId(); if (parentFolder) { windowDraft.notesParentType = 'Folder'; windowDraft.selectedSmartFilterId = null; windowDraft.selectedFolderId = displayParentId; windowDraft.selectedFolderIds = [displayParentId]; } else if (isVirtualFolder) { windowDraft.notesParentType = 'Folder'; windowDraft.selectedSmartFilterId = null; windowDraft.selectedFolderId = displayParentId; windowDraft.selectedFolderIds = [displayParentId]; } else if (action.changeSource === ItemChange.SOURCE_SYNC && displayParentId) { // Sync can deliver a note before its parent folder. Select the destination // by ID now; the folder list and focus refresh will populate it later. windowDraft.notesParentType = 'Folder'; windowDraft.selectedSmartFilterId = null; windowDraft.selectedFolderId = displayParentId; windowDraft.selectedFolderIds = [displayParentId]; } else { windowDraft.notesParentType = 'SmartFilter'; windowDraft.selectedSmartFilterId = ALL_NOTES_FILTER_ID; windowDraft.selectedFolderId = null; windowDraft.selectedFolderIds = []; } // The previous list belongs to the old folder. Until WINDOW_FOCUS refreshes // the destination, the only item known to belong to the new source is this note. newNotes.splice(0, newNotes.length, { ...newNotes[i], ...modNote }); windowDraft.notesSource = ''; } else if (n.is_conflict && !modNote.is_conflict) { // Note was a conflict but was moved outside of // the conflict folder newNotes.splice(i, 1); noteFolderHasChanged = true; movedNotePreviousIndex = i; } else if (noteIsInCurrentView(modNote, previousDisplayParentId)) { // Note is still in the same folder // Merge the properties that have changed (in modNote) into // the object we already have. newNotes[i] = { ...newNotes[i] }; for (const n in modNote) { if (!modNote.hasOwnProperty(n)) continue; (newNotes[i] as unknown as Record)[n] = (modNote as Record)[n]; } } else { // Note has moved to a different folder newNotes.splice(i, 1); noteFolderHasChanged = true; movedNotePreviousIndex = i; } found = true; break; } } // Note was not found - if the current folder is the same as the note folder, // add it to it. if (!found) { if (noteIsInCurrentView(modNote, windowDraft.selectedFolderId)) { newNotes.push(modNote); } } windowDraft.notes = newNotes; // Ensure that the selected note is still in the current folder. // For example, if the user drags the current note to a different folder, // a new note should be selected. // In some cases, however, the selection needs to be preserved (e.g. the mobile app, in secondary windows, or when an unselected note is moved by sync). const preserveSelection = action.preserveSelection ?? draft.allowSelectionInOtherFolders; const selectedNoteHasMoved = windowDraft.selectedNoteIds.length > 0 && !newNotes.some(o => windowDraft.selectedNoteIds.includes(o.id)); if (noteFolderHasChanged && !preserveSelection && !isSecondaryWindow && (action.changeSource !== ItemChange.SOURCE_SYNC || selectedNoteHasMoved)) { let newIndex = movedNotePreviousIndex; if (newIndex >= newNotes.length) newIndex = newNotes.length - 1; if (!newNotes.length) newIndex = -1; windowDraft.selectedNoteIds = newIndex >= 0 ? [newNotes[newIndex].id] : []; } if (!action.ignoreProvisionalFlag) { let newProvisionalNoteIds = draft.provisionalNoteIds; const idx = newProvisionalNoteIds.indexOf(modNote.id); if (action.provisional) { if (idx < 0) { newProvisionalNoteIds = newProvisionalNoteIds.slice(); newProvisionalNoteIds.push(modNote.id); } } else if (idx >= 0) { newProvisionalNoteIds = newProvisionalNoteIds.slice(); newProvisionalNoteIds.splice(idx, 1); } draft.provisionalNoteIds = newProvisionalNoteIds; } }; handleWindowState(draft); for (const backgroundWindow of Object.values(draft.backgroundWindows)) { handleWindowState(backgroundWindow); } } break; case 'NOTE_DELETE': { handleItemDelete(draft, action); const idx = draft.provisionalNoteIds.indexOf(action.id); if (idx >= 0) { const t = draft.provisionalNoteIds.slice(); t.splice(idx, 1); draft.provisionalNoteIds = t; } } break; case 'NOTE_SORT': { if (draft.notesParentType === 'Search') { logger.debug('Not sorting the note list -- sorting should be done by search.'); } else { draft.notes = Note.sortNotes(draft.notes, stateUtils.notesOrder(draft.settings), draft.settings.uncompletedTodosOnTop); } draft.noteListLastSortTime = Date.now(); } break; case 'NOTE_IS_INSERTING_NOTES': if (draft.isInsertingNotes !== action.value) { draft.isInsertingNotes = action.value; } break; case 'TAG_DELETE': handleItemDelete(draft, action); draft.selectedNoteTags = removeItemFromArray(draft.selectedNoteTags, 'id', action.id); break; case 'FOLDER_UPDATE_ALL': draft.folders = action.items; break; case 'FOLDER_SET_COLLAPSED': folderSetCollapsed(draft, action); break; case 'FOLDER_TOGGLE': if (draft.collapsedFolderIds.indexOf(action.id) >= 0) { folderSetCollapsed(draft, { collapsed: false, ...action }); } else { folderSetCollapsed(draft, { collapsed: true, ...action }); } break; case 'FOLDER_SET_COLLAPSED_ALL': draft.collapsedFolderIds = action.ids.slice(); break; case 'TAG_UPDATE_ALL': if (!fastDeepEqual(original(draft.tags), action.items)) { draft.tags = action.items; } break; case 'TAG_SELECT_ADD': case 'TAG_SELECT': changeSelectedTagOrFolder(draft, action, { extendSelection: action.type === 'TAG_SELECT_ADD' }); break; case 'TAG_SELECT_REMOVE': draft.selectedTagIds = draft.selectedTagIds.filter(id => id !== action.id); if (!draft.selectedTagIds.includes(draft.selectedTagId)) { draft.selectedTagId = draft.selectedTagIds[draft.selectedTagIds.length - 1]; } draft.selectedNoteIds = []; break; case 'TAG_UPDATE_ONE': { updateOneItem(draft, action); for (const windowStateDraft of stateUtils.allWindowStates(draft)) { // We only want to update the selected note tags if the tag belongs to the currently open note const selectedNoteHasTag = !!windowStateDraft.selectedNoteTags.find(tag => tag.id === action.item.id); if (selectedNoteHasTag) { updateOneItem(windowStateDraft, action, 'selectedNoteTags'); } } } break; case 'NOTE_TAG_REMOVE': { updateOneItem(draft, action, 'tags'); const tagRemoved = action.item; const noteId = action.noteId; for (const windowStateDraft of stateUtils.allWindowStates(draft)) { windowStateDraft.selectedNoteTags = removeItemFromArray(windowStateDraft.selectedNoteTags, 'id', tagRemoved.id); if (windowStateDraft.notesParentType === 'Tag' && windowStateDraft.selectedTagId === tagRemoved.id) { windowStateDraft.notes = windowStateDraft.notes.filter(note => note.id !== noteId); } } } break; case 'EDITOR_NOTE_STATUS_SET': { draft.editorNoteStatuses[action.id] = action.status; } break; case 'EDITOR_NOTE_STATUS_REMOVE': { delete draft.editorNoteStatuses[action.id]; } break; case 'FOLDER_UPDATE_ONE': case 'MASTERKEY_UPDATE_ONE': updateOneItem(draft, action); break; case 'FOLDER_DELETE': handleItemDelete(draft, action); break; // case 'MASTERKEY_UPDATE_ALL': // draft.masterKeys = action.items; // break; case 'MASTERKEY_SET_NOT_LOADED': draft.notLoadedMasterKeys = action.ids; break; case 'MASTERKEY_ADD_NOT_LOADED': { if (draft.notLoadedMasterKeys.indexOf(action.id) < 0) { const keys = draft.notLoadedMasterKeys.slice(); keys.push(action.id); draft.notLoadedMasterKeys = keys; } } break; case 'MASTERKEY_REMOVE_NOT_LOADED': { const ids = action.id ? [action.id] : action.ids; for (let i = 0; i < ids.length; i++) { const id = ids[i]; const index = draft.notLoadedMasterKeys.indexOf(id); if (index >= 0) { const keys = draft.notLoadedMasterKeys.slice(); keys.splice(index, 1); draft.notLoadedMasterKeys = keys; } } } break; case 'SYNC_STARTED': draft.syncStarted = true; break; case 'SYNC_COMPLETED': draft.syncStarted = false; break; case 'SYNC_PENDING_UPDATE': draft.syncPending = action.value; break; case 'QUIT_SYNC_DIALOG_OPEN': draft.showQuitSyncDialog = true; break; case 'QUIT_SYNC_DIALOG_CLOSE': draft.showQuitSyncDialog = false; break; case 'SYNC_REPORT_UPDATE': draft.syncReport = action.report; break; case 'SEARCH_QUERY': draft.searchQuery = action.query.trim(); break; case 'SEARCH_ADD': { const searches = draft.searches.slice(); searches.push(action.search); draft.searches = searches; } break; case 'SEARCH_UPDATE': { const searches = draft.searches.slice(); let found = false; for (let i = 0; i < searches.length; i++) { if (searches[i].id === action.search.id) { searches[i] = { ...action.search }; found = true; break; } } if (!found) searches.push(action.search); draft.notesParentType = 'Search'; draft.searches = searches; } break; case 'SEARCH_DELETE': handleItemDelete(draft, action); break; case 'SEARCH_SELECT': draft.selectedSearchId = action.id; if (!action.id) { draft.notesParentType = defaultNotesParentType(draft, 'Search'); } else { draft.notesParentType = 'Search'; } draft.selectedNoteIds = []; break; case 'SET_HIGHLIGHTED': draft.highlightedWords = action.words; break; case 'APP_STATE_SET': draft.appState = action.state; break; case 'BIOMETRICS_DONE_SET': draft.biometricsDone = action.value; break; case 'SYNC_HAS_DISABLED_SYNC_ITEMS': draft.hasDisabledSyncItems = 'value' in action ? action.value : true; break; case 'ENCRYPTION_HAS_DISABLED_ITEMS': draft.hasDisabledEncryptionItems = action.value; break; case 'SET_NOTE_LOCK_SESSION_UNLOCKED': draft.noteLockSessionUnlocked = action.value; break; case 'NOTE_LOCK_MIGRATION_STATUS_SET': draft.noteLockMigrationStatus = action.value; break; case 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE': { // The editor reporting this may be in a background window, so the flag follows that // window rather than whichever one currently has focus. const windowDraft = stateUtils.windowStateById(draft, action.windowId ?? draft.windowId); if (windowDraft) windowDraft.activeNoteIsUndecryptable = action.value; } break; case 'CLIPPER_SERVER_SET': { const clipperServer = { ...draft.clipperServer }; if ('startState' in action) clipperServer.startState = action.startState; if ('port' in action) clipperServer.port = action.port; draft.clipperServer = clipperServer; } break; case 'DECRYPTION_WORKER_SET': { const decryptionWorker = { ...draft.decryptionWorker }; for (const n in action) { if (!action.hasOwnProperty(n) || n === 'type') continue; (decryptionWorker as unknown as Record)[n] = action[n]; } draft.decryptionWorker = decryptionWorker; } break; case 'RESOURCE_FETCHER_SET': { const rf = { ...action }; delete rf.type; draft.resourceFetcher = rf; } break; case 'CUSTOM_VIEWER_CSS_APPEND': draft.customViewerCss += action.css; break; case 'CUSTOM_CHROME_CSS_ADD': // To enable/disable custom CSS, some plugins add the same chrome CSS file multiple times. // For performance, only apply the last copy of each file. draft.customChromeCssPaths = draft.customChromeCssPaths.filter(path => path !== action.filePath); draft.customChromeCssPaths.push(action.filePath); break; case 'SET_NOTE_TAGS': if (!fastDeepEqual(original(draft.selectedNoteTags), action.items)) { draft.selectedNoteTags = action.items; } break; case 'PLUGINLEGACY_DIALOG_SET': { if (!action.pluginName) throw new Error('action.pluginName not specified'); const existingPlugin = draft.pluginsLegacy[action.pluginName] as Record | undefined; const newPluginsLegacy = { ...draft.pluginsLegacy }; const newPlugin: Record = existingPlugin ? { ...existingPlugin } : {}; if ('open' in action) newPlugin.dialogOpen = action.open; if ('userData' in action) newPlugin.userData = action.userData; newPluginsLegacy[action.pluginName] = newPlugin; draft.pluginsLegacy = newPluginsLegacy; } break; case 'API_NEED_AUTH_SET': draft.needApiAuth = action.value; break; case 'PROFILE_CONFIG_SET': draft.profileConfig = action.value; break; case 'MUST_UPGRADE_APP': draft.mustUpgradeAppMessage = action.message; draft.mustUpgradeAppMinVersion = action.appMinVersion; break; case 'MUST_AUTHENTICATE': draft.mustAuthenticate = action.value; break; case 'NOTE_LIST_RENDERER_ADD': { const noteListRendererIds = draft.noteListRendererIds.slice(); if (noteListRendererIds.includes(action.value)) throw new Error(`Note list renderer is already registered: ${action.value}`); noteListRendererIds.push(action.value); draft.noteListRendererIds = noteListRendererIds; } break; case 'EDITOR_NOTE_NEEDS_RELOAD': { const nextReloadRequest = (previous: number) => Math.max(Date.now(), previous + 1); // Mobile uses the root field and supports reload requests without a note ID. if (!action.noteId || stateUtils.selectedNoteId(draft) === action.noteId) { draft.editorNoteReloadTimeRequest = nextReloadRequest(draft.editorNoteReloadTimeRequest); } // Desktop reload requests must identify the note so that only windows // displaying that note are invalidated. if (action.noteId) { for (const windowState of stateUtils.allWindowStates(draft)) { if (stateUtils.selectedNoteId(windowState) === action.noteId) { windowState.windowEditorNoteReloadTimeRequest = nextReloadRequest(windowState.windowEditorNoteReloadTimeRequest); } } } } break; case 'TOAST_SHOW': draft.toast = { duration: 6000, type: ToastType.Info, ...action.value, timestamp: Date.now(), }; break; case 'TOAST_HIDE': draft.toast = null; break; } } catch (error) { error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; throw error; } if (action.type.indexOf('NOTE_UPDATE') === 0 || action.type.indexOf('FOLDER_UPDATE') === 0 || action.type.indexOf('TAG_UPDATE') === 0) { draft.hasEncryptedItems = stateHasEncryptedItems(draft); } if (action.type === 'NOTE_DELETE') { handleHistory(draft, action); } if (action.type === 'SETTING_UPDATE_ALL' || (action.type === 'SETTING_UPDATE_ONE' && action.key === 'activeFolderId')) { // To allow creating notes when opening the app with all notes and/or tags, // a "last selected folder ID" needs to be set. if (!draft.selectedFolderId) { draft.selectedFolderId = draft.settings.activeFolderId; draft.selectedFolderIds = draft.selectedFolderId ? [draft.selectedFolderId] : []; } } for (const additionalReducer of additionalReducers) { additionalReducer.reducer(draft, action); } // if (Setting.value('env') === 'dev') { // return Object.freeze(newState); // } else { // return newState; // } }); export default reducer;