mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-18 09:35:20 +02:00
e11e57f1d8
The implementation uses / symbol as a nesting separator. I.e. tag/subtag is a nested tag, where tag is the parent tag and subtag is its child. Creating a tag named tag/subtag/subsubtag creates three tags, one for each level. The tags are associated using parent_id field. In the app, viewing notes with a tag will also show all notes that are associated with any of the tag's descendant tags (same for the note count). Deleting a tag will also delete all its descendant tags. In the desktop app the tags are shown nested just like the notebooks.
1066 lines
31 KiB
JavaScript
1066 lines
31 KiB
JavaScript
const Note = require('lib/models/Note.js');
|
|
const Folder = require('lib/models/Folder.js');
|
|
const ArrayUtils = require('lib/ArrayUtils.js');
|
|
const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids');
|
|
const CommandService = require('lib/services/CommandService').default;
|
|
|
|
const defaultState = {
|
|
notes: [],
|
|
notesSource: '',
|
|
notesParentType: null,
|
|
folders: [],
|
|
tags: [],
|
|
masterKeys: [],
|
|
notLoadedMasterKeys: [],
|
|
searches: [],
|
|
selectedNoteIds: [],
|
|
selectedNoteHash: '',
|
|
selectedFolderId: null,
|
|
selectedTagId: null,
|
|
selectedSearchId: null,
|
|
selectedItemType: 'note',
|
|
lastSelectedNotesIds: {
|
|
Folder: {},
|
|
Tag: {},
|
|
Search: {},
|
|
},
|
|
showSideMenu: false,
|
|
screens: {},
|
|
historyCanGoBack: false,
|
|
syncStarted: false,
|
|
syncReport: {},
|
|
searchQuery: '',
|
|
settings: {},
|
|
sharedData: null,
|
|
appState: 'starting',
|
|
hasDisabledSyncItems: false,
|
|
hasDisabledEncryptionItems: false,
|
|
customCss: '',
|
|
templates: [],
|
|
collapsedFolderIds: [],
|
|
collapsedTagIds: [],
|
|
clipperServer: {
|
|
startState: 'idle',
|
|
port: null,
|
|
},
|
|
decryptionWorker: {
|
|
state: 'idle',
|
|
itemIndex: 0,
|
|
itemCount: 0,
|
|
decryptedItemCounts: {},
|
|
decryptedItemCount: 0,
|
|
skippedItemCount: 0,
|
|
},
|
|
selectedNoteTags: [],
|
|
resourceFetcher: {
|
|
toFetchCount: 0,
|
|
},
|
|
backwardHistoryNotes: [],
|
|
forwardHistoryNotes: [],
|
|
plugins: {},
|
|
provisionalNoteIds: [],
|
|
editorNoteStatuses: {},
|
|
isInsertingNotes: false,
|
|
};
|
|
|
|
const MAX_HISTORY = 200;
|
|
|
|
const stateUtils = {};
|
|
|
|
const derivedStateCache_ = {};
|
|
|
|
// Allows, for a given state, to return the same derived
|
|
// objects, to prevent unecessary updates on calling components.
|
|
const cacheEnabledOutput = (key, output) => {
|
|
key = `${key}_${JSON.stringify(output)}`;
|
|
if (derivedStateCache_[key]) return derivedStateCache_[key];
|
|
|
|
derivedStateCache_[key] = output;
|
|
return derivedStateCache_[key];
|
|
};
|
|
|
|
stateUtils.hasOneSelectedNote = function(state) {
|
|
return state.selectedNoteIds.length === 1;
|
|
};
|
|
|
|
stateUtils.notesOrder = function(stateSettings) {
|
|
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',
|
|
},
|
|
]);
|
|
}
|
|
};
|
|
|
|
stateUtils.foldersOrder = function(stateSettings) {
|
|
return cacheEnabledOutput('foldersOrder', [
|
|
{
|
|
by: stateSettings['folders.sortOrder.field'],
|
|
dir: stateSettings['folders.sortOrder.reverse'] ? 'DESC' : 'ASC',
|
|
},
|
|
]);
|
|
};
|
|
|
|
stateUtils.hasNotesBeingSaved = function(state) {
|
|
for (const id in state.editorNoteStatuses) {
|
|
if (state.editorNoteStatuses[id] === 'saving') return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
stateUtils.parentItem = function(state) {
|
|
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 };
|
|
};
|
|
|
|
stateUtils.lastSelectedNoteIds = function(state) {
|
|
const parent = stateUtils.parentItem(state);
|
|
if (!parent) return [];
|
|
const output = state.lastSelectedNotesIds[parent.type][parent.id];
|
|
return output ? output : [];
|
|
};
|
|
|
|
stateUtils.getCurrentNote = function(state) {
|
|
const selectedNoteIds = state.selectedNoteIds;
|
|
const notes = state.notes;
|
|
if (selectedNoteIds != null && selectedNoteIds.length > 0) {
|
|
const currNote = notes.find(note => note.id === selectedNoteIds[0]);
|
|
if (currNote != null) {
|
|
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;
|
|
};
|
|
|
|
function arrayHasEncryptedItems(array) {
|
|
for (let i = 0; i < array.length; i++) {
|
|
if (array[i].encryption_applied) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stateHasEncryptedItems(state) {
|
|
if (arrayHasEncryptedItems(state.notes)) return true;
|
|
if (arrayHasEncryptedItems(state.folders)) return true;
|
|
if (arrayHasEncryptedItems(state.tags)) return true;
|
|
return false;
|
|
}
|
|
|
|
function itemSetCollapsed(state, action) {
|
|
let collapsedItemsKey = null;
|
|
if (action.type.indexOf('TAG_') !== -1) collapsedItemsKey = 'collapsedTagIds';
|
|
else if (action.type.indexOf('FOLDER_') !== -1) collapsedItemsKey = 'collapsedFolderIds';
|
|
|
|
const collapsedItemIds = state[collapsedItemsKey].slice();
|
|
const idx = collapsedItemIds.indexOf(action.id);
|
|
|
|
if (action.collapsed) {
|
|
if (idx >= 0) return state;
|
|
collapsedItemIds.push(action.id);
|
|
} else {
|
|
if (idx < 0) return state;
|
|
collapsedItemIds.splice(idx, 1);
|
|
}
|
|
|
|
const newState = Object.assign({}, state);
|
|
newState[collapsedItemsKey] = collapsedItemIds;
|
|
return newState;
|
|
}
|
|
|
|
function removeAdjacentDuplicates(items) {
|
|
return items.filter((item, idx) => (idx >= 1) ? items[idx - 1].id !== item.id : true);
|
|
}
|
|
|
|
// When deleting a note, tag or folder
|
|
function handleItemDelete(state, action) {
|
|
const map = {
|
|
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];
|
|
|
|
const selectedItemKeys = isSingular ? [state[selectedItemKey]] : state[selectedItemKey];
|
|
const isSelected = selectedItemKeys.includes(action.id);
|
|
|
|
const items = state[listKey];
|
|
const newItems = [];
|
|
let newSelectedIndexes = [];
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
const newState = Object.assign({}, state);
|
|
newState[listKey] = newItems;
|
|
|
|
const newIds = [];
|
|
for (let i = 0; i < newSelectedIndexes.length; i++) {
|
|
newIds.push(newItems[newSelectedIndexes[i]].id);
|
|
}
|
|
newState[selectedItemKey] = isSingular ? newIds[0] : newIds;
|
|
|
|
if ((newIds.length == 0) && newState.notesParentType !== 'Folder') {
|
|
newState.notesParentType = 'Folder';
|
|
}
|
|
|
|
return newState;
|
|
}
|
|
|
|
function updateOneItem(state, action, 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 newItems = state[itemsKey].splice(0);
|
|
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] = Object.assign(newItems[i], item);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) newItems.push(item);
|
|
|
|
const newState = Object.assign({}, state);
|
|
|
|
newState[itemsKey] = newItems;
|
|
|
|
return newState;
|
|
}
|
|
|
|
function updateSelectedNotesFromExistingNotes(state) {
|
|
const newSelectedNoteIds = [];
|
|
for (const selectedNoteId of state.selectedNoteIds) {
|
|
for (const n of state.notes) {
|
|
if (n.id === selectedNoteId) {
|
|
newSelectedNoteIds.push(n.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.assign({}, state, {
|
|
selectedNoteIds: newSelectedNoteIds,
|
|
});
|
|
}
|
|
|
|
function defaultNotesParentType(state, exclusion) {
|
|
let newNotesParentType = null;
|
|
|
|
if (exclusion !== 'Folder' && state.selectedFolderId) {
|
|
newNotesParentType = 'Folder';
|
|
} else if (exclusion !== 'Tag' && state.selectedTagId) {
|
|
newNotesParentType = 'Tag';
|
|
} else if (exclusion !== 'Search' && state.selectedSearchId) {
|
|
newNotesParentType = 'Search';
|
|
}
|
|
|
|
return newNotesParentType;
|
|
}
|
|
|
|
function changeSelectedFolder(state, action, options = null) {
|
|
if (!options) options = {};
|
|
const newState = Object.assign({}, state);
|
|
newState.selectedFolderId = 'folderId' in action ? action.folderId : action.id;
|
|
if (!newState.selectedFolderId) {
|
|
newState.notesParentType = defaultNotesParentType(state, 'Folder');
|
|
} else {
|
|
newState.notesParentType = 'Folder';
|
|
}
|
|
|
|
if (newState.selectedFolderId === state.selectedFolderId && newState.notesParentType === state.notesParentType) return state;
|
|
|
|
if (options.clearSelectedNoteIds) newState.selectedNoteIds = [];
|
|
|
|
return newState;
|
|
}
|
|
|
|
function recordLastSelectedNoteIds(state, noteIds) {
|
|
const newOnes = Object.assign({}, state.lastSelectedNotesIds);
|
|
const parent = stateUtils.parentItem(state);
|
|
if (!parent) return state;
|
|
|
|
newOnes[parent.type][parent.id] = noteIds.slice();
|
|
|
|
return Object.assign({}, state, {
|
|
lastSelectedNotesIds: newOnes,
|
|
});
|
|
}
|
|
|
|
function changeSelectedNotes(state, action, options = null) {
|
|
if (!options) options = {};
|
|
|
|
let noteIds = [];
|
|
if (action.id) noteIds = [action.id];
|
|
if (action.ids) noteIds = action.ids;
|
|
if (action.noteId) noteIds = [action.noteId];
|
|
|
|
let newState = Object.assign({}, state);
|
|
|
|
if (action.type === 'NOTE_SELECT') {
|
|
if (JSON.stringify(newState.selectedNoteIds) === JSON.stringify(noteIds)) return state;
|
|
newState.selectedNoteIds = noteIds;
|
|
newState.selectedNoteHash = action.hash ? action.hash : '';
|
|
} else if (action.type === 'NOTE_SELECT_ADD') {
|
|
if (!noteIds.length) return state;
|
|
newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds));
|
|
} else if (action.type === 'NOTE_SELECT_REMOVE') {
|
|
if (!noteIds.length) return state; // Nothing to unselect
|
|
if (state.selectedNoteIds.length <= 1) return state; // Cannot unselect the last note
|
|
|
|
const newSelectedNoteIds = [];
|
|
for (let i = 0; i < newState.selectedNoteIds.length; i++) {
|
|
const id = newState.selectedNoteIds[i];
|
|
if (noteIds.indexOf(id) >= 0) continue;
|
|
newSelectedNoteIds.push(id);
|
|
}
|
|
newState.selectedNoteIds = newSelectedNoteIds;
|
|
} else if (action.type === 'NOTE_SELECT_TOGGLE') {
|
|
if (!noteIds.length) return state;
|
|
|
|
if (newState.selectedNoteIds.indexOf(noteIds[0]) >= 0) {
|
|
newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_REMOVE', id: noteIds[0] });
|
|
} else {
|
|
newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_ADD', id: noteIds[0] });
|
|
}
|
|
} else {
|
|
throw new Error('Unreachable');
|
|
}
|
|
|
|
newState = recordLastSelectedNoteIds(newState, newState.selectedNoteIds);
|
|
|
|
return newState;
|
|
}
|
|
|
|
function removeItemFromArray(array, property, value) {
|
|
for (let i = 0; i !== array.length; ++i) {
|
|
const currentItem = array[i];
|
|
if (currentItem[property] === value) {
|
|
array.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
return array;
|
|
}
|
|
|
|
const getContextFromHistory = (ctx) => {
|
|
const result = {};
|
|
result.notesParentType = ctx.notesParentType;
|
|
if (result.notesParentType === 'Folder') {
|
|
result.selectedFolderId = ctx.selectedFolderId;
|
|
} else if (result.notesParentType === 'Tag') {
|
|
result.selectedTagId = ctx.selectedTagId;
|
|
} 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 handleHistory(state, action) {
|
|
let newState = Object.assign({}, state);
|
|
let backwardHistoryNotes = newState.backwardHistoryNotes.slice();
|
|
let forwardHistoryNotes = newState.forwardHistoryNotes.slice();
|
|
const currentNote = stateUtils.getCurrentNote(state);
|
|
switch (action.type) {
|
|
case 'HISTORY_BACKWARD': {
|
|
const note = backwardHistoryNotes[backwardHistoryNotes.length - 1];
|
|
if (currentNote != null && (forwardHistoryNotes.length === 0 || currentNote.id != forwardHistoryNotes[forwardHistoryNotes.length - 1].id)) {
|
|
forwardHistoryNotes = forwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY);
|
|
}
|
|
|
|
newState = changeSelectedFolder(newState, Object.assign({}, action, { type: 'FOLDER_SELECT', folderId: note.parent_id }));
|
|
newState = changeSelectedNotes(newState, Object.assign({}, action, { type: 'NOTE_SELECT', noteId: note.id }));
|
|
|
|
const ctx = backwardHistoryNotes[backwardHistoryNotes.length - 1];
|
|
newState = Object.assign(newState, getContextFromHistory(ctx));
|
|
|
|
backwardHistoryNotes.pop();
|
|
break;
|
|
}
|
|
case 'HISTORY_FORWARD': {
|
|
const note = forwardHistoryNotes[forwardHistoryNotes.length - 1];
|
|
|
|
if (currentNote != null && (backwardHistoryNotes.length === 0 || currentNote.id != backwardHistoryNotes[backwardHistoryNotes.length - 1].id)) {
|
|
backwardHistoryNotes = backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY);
|
|
}
|
|
|
|
newState = changeSelectedFolder(newState, Object.assign({}, action, { type: 'FOLDER_SELECT', folderId: note.parent_id }));
|
|
newState = changeSelectedNotes(newState, Object.assign({}, action, { type: 'NOTE_SELECT', noteId: note.id }));
|
|
|
|
const ctx = forwardHistoryNotes[forwardHistoryNotes.length - 1];
|
|
newState = Object.assign(newState, getContextFromHistory(ctx));
|
|
|
|
|
|
forwardHistoryNotes.pop();
|
|
break;
|
|
}
|
|
case 'NOTE_SELECT':
|
|
if (currentNote != null && action.id != currentNote.id) {
|
|
forwardHistoryNotes = [];
|
|
backwardHistoryNotes = backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY);
|
|
}
|
|
// History should be free from duplicates.
|
|
if (backwardHistoryNotes != null && backwardHistoryNotes.length > 0 &&
|
|
action.id === backwardHistoryNotes[backwardHistoryNotes.length - 1].id) {
|
|
backwardHistoryNotes.pop();
|
|
}
|
|
break;
|
|
case 'TAG_SELECT':
|
|
case 'FOLDER_AND_NOTE_SELECT':
|
|
case 'FOLDER_SELECT':
|
|
if (currentNote != null) {
|
|
forwardHistoryNotes = [];
|
|
backwardHistoryNotes = backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY);
|
|
}
|
|
break;
|
|
case 'NOTE_UPDATE_ONE': {
|
|
const modNote = action.note;
|
|
|
|
backwardHistoryNotes = backwardHistoryNotes.map(note => {
|
|
if (note.id === modNote.id) {
|
|
return Object.assign({}, note, { parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id });
|
|
}
|
|
return note;
|
|
});
|
|
|
|
forwardHistoryNotes = forwardHistoryNotes.map(note => {
|
|
if (note.id === modNote.id) {
|
|
return Object.assign({}, note, { parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id });
|
|
}
|
|
return note;
|
|
});
|
|
|
|
break;
|
|
}
|
|
case 'SEARCH_UPDATE':
|
|
if (currentNote != null && (backwardHistoryNotes.length === 0 ||
|
|
backwardHistoryNotes[backwardHistoryNotes.length - 1].id != currentNote.id)) {
|
|
forwardHistoryNotes = [];
|
|
backwardHistoryNotes = backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY);
|
|
}
|
|
break;
|
|
case 'FOLDER_DELETE':
|
|
backwardHistoryNotes = backwardHistoryNotes.filter(note => note.parent_id != action.id);
|
|
forwardHistoryNotes = forwardHistoryNotes.filter(note => note.parent_id != action.id);
|
|
|
|
backwardHistoryNotes = removeAdjacentDuplicates(backwardHistoryNotes);
|
|
forwardHistoryNotes = removeAdjacentDuplicates(forwardHistoryNotes);
|
|
break;
|
|
case 'NOTE_DELETE': {
|
|
backwardHistoryNotes = backwardHistoryNotes.filter(note => note.id != action.id);
|
|
forwardHistoryNotes = forwardHistoryNotes.filter(note => note.id != action.id);
|
|
|
|
backwardHistoryNotes = removeAdjacentDuplicates(backwardHistoryNotes);
|
|
forwardHistoryNotes = removeAdjacentDuplicates(forwardHistoryNotes);
|
|
|
|
// Fix the case where after deletion the currently selected note is also the latest in history
|
|
const selectedNoteIds = newState.selectedNoteIds;
|
|
if (selectedNoteIds.length && backwardHistoryNotes.length && backwardHistoryNotes[backwardHistoryNotes.length - 1].id === selectedNoteIds[0]) {
|
|
backwardHistoryNotes = backwardHistoryNotes.slice(0, backwardHistoryNotes.length - 1);
|
|
}
|
|
if (selectedNoteIds.length && forwardHistoryNotes.length && forwardHistoryNotes[forwardHistoryNotes.length - 1].id === selectedNoteIds[0]) {
|
|
forwardHistoryNotes = forwardHistoryNotes.slice(0, forwardHistoryNotes.length - 1);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
// console.log('Unknown action in history reducer.' ,action.type);
|
|
return state;
|
|
}
|
|
|
|
newState.backwardHistoryNotes = backwardHistoryNotes;
|
|
newState.forwardHistoryNotes = forwardHistoryNotes;
|
|
return newState;
|
|
}
|
|
|
|
const reducer = (state = defaultState, action) => {
|
|
// if (!['SIDE_MENU_OPEN_PERCENT'].includes(action.type)) console.info('Action', action.type);
|
|
|
|
let newState = state;
|
|
|
|
// NOTE_DELETE requires post processing
|
|
if (action.type !== 'NOTE_DELETE') {
|
|
newState = handleHistory(newState, action);
|
|
}
|
|
|
|
try {
|
|
switch (action.type) {
|
|
|
|
case 'NOTE_SELECT':
|
|
case 'NOTE_SELECT_ADD':
|
|
case 'NOTE_SELECT_REMOVE':
|
|
case 'NOTE_SELECT_TOGGLE':
|
|
newState = changeSelectedNotes(newState, action);
|
|
break;
|
|
case 'NOTE_SELECT_EXTEND':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
|
|
if (!newState.selectedNoteIds.length) {
|
|
newState.selectedNoteIds = [action.id];
|
|
} else {
|
|
const selectRangeId1 = state.selectedNoteIds[state.selectedNoteIds.length - 1];
|
|
const selectRangeId2 = action.id;
|
|
if (selectRangeId1 === selectRangeId2) return state;
|
|
|
|
const newSelectedNoteIds = state.selectedNoteIds.slice();
|
|
let selectionStarted = false;
|
|
for (let i = 0; i < state.notes.length; i++) {
|
|
const id = state.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);
|
|
}
|
|
}
|
|
newState.selectedNoteIds = newSelectedNoteIds;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_SELECT_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.selectedNoteIds = newState.notes.map(n => n.id);
|
|
break;
|
|
|
|
case 'NOTE_SELECT_ALL_TOGGLE': {
|
|
newState = Object.assign({}, state);
|
|
const allSelected = state.notes.every(n => state.selectedNoteIds.includes(n.id));
|
|
if (allSelected) {
|
|
newState.selectedNoteIds = [];
|
|
} else {
|
|
newState.selectedNoteIds = newState.notes.map(n => n.id);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'SMART_FILTER_SELECT':
|
|
newState = Object.assign({}, state);
|
|
newState.notesParentType = 'SmartFilter';
|
|
newState.selectedSmartFilterId = action.id;
|
|
break;
|
|
|
|
case 'FOLDER_SELECT':
|
|
newState = changeSelectedFolder(newState, action, { clearSelectedNoteIds: true });
|
|
break;
|
|
|
|
case 'FOLDER_AND_NOTE_SELECT':
|
|
{
|
|
newState = changeSelectedFolder(newState, action);
|
|
const noteSelectAction = Object.assign({}, action, { type: 'NOTE_SELECT' });
|
|
newState = changeSelectedNotes(newState, noteSelectAction);
|
|
}
|
|
break;
|
|
|
|
case 'SETTING_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.settings = action.settings;
|
|
break;
|
|
|
|
case 'SETTING_UPDATE_ONE':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
const newSettings = Object.assign({}, state.settings);
|
|
newSettings[action.key] = action.value;
|
|
newState.settings = newSettings;
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_PROVISIONAL_FLAG_CLEAR':
|
|
{
|
|
const newIds = ArrayUtils.removeElement(state.provisionalNoteIds, action.id);
|
|
if (newIds !== state.provisionalNoteIds) {
|
|
newState = Object.assign({}, state, { provisionalNoteIds: newIds });
|
|
}
|
|
}
|
|
break;
|
|
|
|
// Replace all the notes with the provided array
|
|
case 'NOTE_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.notes = action.notes;
|
|
newState.notesSource = action.notesSource;
|
|
newState = updateSelectedNotesFromExistingNotes(newState);
|
|
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 = action.note;
|
|
const isViewingAllNotes = (state.notesParentType === 'SmartFilter' && state.selectedSmartFilterId === ALL_NOTES_FILTER_ID);
|
|
const isViewingConflictFolder = state.notesParentType === 'Folder' && state.selectedFolderId === Folder.conflictFolderId();
|
|
|
|
const noteIsInFolder = function(note, folderId) {
|
|
if (note.is_conflict && isViewingConflictFolder) return true;
|
|
if (!('parent_id' in modNote) || note.parent_id == folderId) return true;
|
|
return false;
|
|
};
|
|
|
|
let movedNotePreviousIndex = 0;
|
|
let noteFolderHasChanged = false;
|
|
let newNotes = state.notes.slice();
|
|
let found = false;
|
|
for (let i = 0; i < newNotes.length; i++) {
|
|
const n = newNotes[i];
|
|
if (n.id == modNote.id) {
|
|
// Note is still in the same folder
|
|
if (isViewingAllNotes || noteIsInFolder(modNote, n.parent_id)) {
|
|
// Merge the properties that have changed (in modNote) into
|
|
// the object we already have.
|
|
newNotes[i] = Object.assign({}, newNotes[i]);
|
|
|
|
for (const n in modNote) {
|
|
if (!modNote.hasOwnProperty(n)) continue;
|
|
newNotes[i][n] = modNote[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 (isViewingAllNotes || noteIsInFolder(modNote, state.selectedFolderId)) {
|
|
newNotes.push(modNote);
|
|
}
|
|
}
|
|
|
|
// newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
|
|
newNotes = Note.sortNotes(newNotes, stateUtils.notesOrder(state.settings), newState.settings.uncompletedTodosOnTop);
|
|
newState = Object.assign({}, state);
|
|
newState.notes = newNotes;
|
|
|
|
if (noteFolderHasChanged) {
|
|
let newIndex = movedNotePreviousIndex;
|
|
if (newIndex >= newNotes.length) newIndex = newNotes.length - 1;
|
|
if (!newNotes.length) newIndex = -1;
|
|
newState.selectedNoteIds = newIndex >= 0 ? [newNotes[newIndex].id] : [];
|
|
}
|
|
|
|
if (action.provisional) {
|
|
newState.provisionalNoteIds.push(modNote.id);
|
|
} else {
|
|
const idx = newState.provisionalNoteIds.indexOf(modNote.id);
|
|
if (idx >= 0) {
|
|
const t = newState.provisionalNoteIds.slice();
|
|
t.splice(idx, 1);
|
|
newState.provisionalNoteIds = t;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_DELETE':
|
|
|
|
{
|
|
newState = handleItemDelete(state, action);
|
|
|
|
const idx = newState.provisionalNoteIds.indexOf(action.id);
|
|
if (idx >= 0) {
|
|
const t = newState.provisionalNoteIds.slice();
|
|
t.splice(idx, 1);
|
|
newState.provisionalNoteIds = t;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_IS_INSERTING_NOTES':
|
|
|
|
if (state.isInsertingNotes !== action.value) {
|
|
newState = Object.assign({}, state);
|
|
newState.isInsertingNotes = action.value;
|
|
}
|
|
break;
|
|
|
|
case 'TAG_DELETE':
|
|
newState = handleItemDelete(state, action);
|
|
newState.selectedNoteTags = removeItemFromArray(newState.selectedNoteTags.splice(0), 'id', action.id);
|
|
break;
|
|
|
|
case 'FOLDER_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.folders = action.items;
|
|
break;
|
|
|
|
case 'FOLDER_SET_COLLAPSED':
|
|
newState = itemSetCollapsed(state, action);
|
|
break;
|
|
|
|
case 'FOLDER_TOGGLE':
|
|
if (state.collapsedFolderIds.indexOf(action.id) >= 0) {
|
|
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
|
|
} else {
|
|
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
|
|
}
|
|
break;
|
|
|
|
case 'FOLDER_SET_COLLAPSED_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.collapsedFolderIds = action.ids.slice();
|
|
break;
|
|
|
|
case 'TAG_SET_COLLAPSED':
|
|
newState = itemSetCollapsed(state, action);
|
|
break;
|
|
|
|
case 'TAG_TOGGLE':
|
|
if (state.collapsedTagIds.indexOf(action.id) >= 0) {
|
|
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
|
|
} else {
|
|
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
|
|
}
|
|
break;
|
|
|
|
case 'TAG_SET_COLLAPSED_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.collapsedTagIds = action.ids.slice();
|
|
break;
|
|
|
|
case 'TAG_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.tags = action.items;
|
|
break;
|
|
|
|
case 'TAG_SELECT':
|
|
newState.selectedTagId = action.id;
|
|
if (!action.id) {
|
|
newState.notesParentType = defaultNotesParentType(state, 'Tag');
|
|
} else {
|
|
newState.notesParentType = 'Tag';
|
|
}
|
|
newState.selectedNoteIds = [];
|
|
break;
|
|
|
|
case 'TAG_UPDATE_ONE':
|
|
{
|
|
// We only want to update the selected note tags if the tag belongs to the currently open note
|
|
const selectedNoteHasTag = !!state.selectedNoteTags.find(tag => tag.id === action.item.id);
|
|
newState = updateOneItem(state, action);
|
|
if (selectedNoteHasTag) newState = updateOneItem(newState, action, 'selectedNoteTags');
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_TAG_REMOVE':
|
|
{
|
|
newState = updateOneItem(state, action, 'tags');
|
|
const tagRemoved = action.item;
|
|
newState.selectedNoteTags = removeItemFromArray(newState.selectedNoteTags.splice(0), 'id', tagRemoved.id);
|
|
}
|
|
break;
|
|
|
|
case 'EDITOR_NOTE_STATUS_SET':
|
|
|
|
{
|
|
const newStatuses = Object.assign({}, state.editorNoteStatuses);
|
|
newStatuses[action.id] = action.status;
|
|
newState = Object.assign({}, state, { editorNoteStatuses: newStatuses });
|
|
}
|
|
break;
|
|
|
|
case 'EDITOR_NOTE_STATUS_REMOVE':
|
|
|
|
{
|
|
const newStatuses = Object.assign({}, state.editorNoteStatuses);
|
|
delete newStatuses[action.id];
|
|
newState = Object.assign({}, state, { editorNoteStatuses: newStatuses });
|
|
}
|
|
break;
|
|
|
|
case 'FOLDER_UPDATE_ONE':
|
|
case 'MASTERKEY_UPDATE_ONE':
|
|
newState = updateOneItem(state, action);
|
|
break;
|
|
|
|
case 'FOLDER_DELETE':
|
|
newState = handleItemDelete(newState, action);
|
|
break;
|
|
|
|
case 'MASTERKEY_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.masterKeys = action.items;
|
|
break;
|
|
|
|
case 'MASTERKEY_SET_NOT_LOADED':
|
|
newState = Object.assign({}, state);
|
|
newState.notLoadedMasterKeys = action.ids;
|
|
break;
|
|
|
|
case 'MASTERKEY_ADD_NOT_LOADED':
|
|
{
|
|
if (state.notLoadedMasterKeys.indexOf(action.id) < 0) {
|
|
newState = Object.assign({}, state);
|
|
const keys = newState.notLoadedMasterKeys.slice();
|
|
keys.push(action.id);
|
|
newState.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 = state.notLoadedMasterKeys.indexOf(id);
|
|
if (index >= 0) {
|
|
newState = Object.assign({}, state);
|
|
const keys = newState.notLoadedMasterKeys.slice();
|
|
keys.splice(index, 1);
|
|
newState.notLoadedMasterKeys = keys;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'SYNC_STARTED':
|
|
newState = Object.assign({}, state);
|
|
newState.syncStarted = true;
|
|
break;
|
|
|
|
case 'SYNC_COMPLETED':
|
|
newState = Object.assign({}, state);
|
|
newState.syncStarted = false;
|
|
break;
|
|
|
|
case 'SYNC_REPORT_UPDATE':
|
|
newState = Object.assign({}, state);
|
|
newState.syncReport = action.report;
|
|
break;
|
|
|
|
case 'SEARCH_QUERY':
|
|
newState = Object.assign({}, state);
|
|
newState.searchQuery = action.query.trim();
|
|
break;
|
|
|
|
case 'SEARCH_ADD':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
const searches = newState.searches.slice();
|
|
searches.push(action.search);
|
|
newState.searches = searches;
|
|
}
|
|
break;
|
|
|
|
case 'SEARCH_UPDATE':
|
|
{
|
|
const searches = newState.searches.slice();
|
|
let found = false;
|
|
for (let i = 0; i < searches.length; i++) {
|
|
if (searches[i].id === action.search.id) {
|
|
searches[i] = Object.assign({}, action.search);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) searches.push(action.search);
|
|
|
|
if (!action.search.query_pattern) {
|
|
newState.notesParentType = defaultNotesParentType(state, 'Search');
|
|
} else {
|
|
newState.notesParentType = 'Search';
|
|
}
|
|
|
|
newState.searches = searches;
|
|
}
|
|
break;
|
|
|
|
case 'SEARCH_DELETE':
|
|
newState = handleItemDelete(state, action);
|
|
break;
|
|
|
|
case 'SEARCH_SELECT':
|
|
newState = Object.assign({}, state);
|
|
newState.selectedSearchId = action.id;
|
|
if (!action.id) {
|
|
newState.notesParentType = defaultNotesParentType(state, 'Search');
|
|
} else {
|
|
newState.notesParentType = 'Search';
|
|
}
|
|
newState.selectedNoteIds = [];
|
|
break;
|
|
|
|
case 'APP_STATE_SET':
|
|
newState = Object.assign({}, state);
|
|
newState.appState = action.state;
|
|
break;
|
|
|
|
case 'SYNC_HAS_DISABLED_SYNC_ITEMS':
|
|
newState = Object.assign({}, state);
|
|
newState.hasDisabledSyncItems = true;
|
|
break;
|
|
|
|
case 'ENCRYPTION_HAS_DISABLED_ITEMS':
|
|
newState = Object.assign({}, state);
|
|
newState.hasDisabledEncryptionItems = action.value;
|
|
break;
|
|
|
|
case 'CLIPPER_SERVER_SET':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
const clipperServer = Object.assign({}, newState.clipperServer);
|
|
if ('startState' in action) clipperServer.startState = action.startState;
|
|
if ('port' in action) clipperServer.port = action.port;
|
|
newState.clipperServer = clipperServer;
|
|
}
|
|
break;
|
|
|
|
case 'DECRYPTION_WORKER_SET':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
const decryptionWorker = Object.assign({}, newState.decryptionWorker);
|
|
for (const n in action) {
|
|
if (!action.hasOwnProperty(n) || n === 'type') continue;
|
|
decryptionWorker[n] = action[n];
|
|
}
|
|
newState.decryptionWorker = decryptionWorker;
|
|
}
|
|
break;
|
|
|
|
case 'RESOURCE_FETCHER_SET':
|
|
{
|
|
newState = Object.assign({}, state);
|
|
const rf = Object.assign({}, action);
|
|
delete rf.type;
|
|
newState.resourceFetcher = rf;
|
|
}
|
|
break;
|
|
|
|
case 'LOAD_CUSTOM_CSS':
|
|
newState = Object.assign({}, state);
|
|
newState.customCss = action.css;
|
|
break;
|
|
|
|
case 'TEMPLATE_UPDATE_ALL':
|
|
newState = Object.assign({}, state);
|
|
newState.templates = action.templates;
|
|
break;
|
|
|
|
case 'SET_NOTE_TAGS':
|
|
newState = Object.assign({}, state);
|
|
newState.selectedNoteTags = action.items;
|
|
break;
|
|
|
|
case 'PLUGIN_DIALOG_SET':
|
|
{
|
|
if (!action.pluginName) throw new Error('action.pluginName not specified');
|
|
newState = Object.assign({}, state);
|
|
const newPlugins = Object.assign({}, newState.plugins);
|
|
const newPlugin = newState.plugins[action.pluginName] ? Object.assign({}, newState.plugins[action.pluginName]) : {};
|
|
if ('open' in action) newPlugin.dialogOpen = action.open;
|
|
newPlugins[action.pluginName] = newPlugin;
|
|
newState.plugins = newPlugins;
|
|
}
|
|
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) {
|
|
newState = Object.assign({}, newState);
|
|
newState.hasEncryptedItems = stateHasEncryptedItems(newState);
|
|
}
|
|
|
|
if (action.type === 'NOTE_DELETE') {
|
|
newState = handleHistory(newState, action);
|
|
}
|
|
|
|
CommandService.instance().scheduleMapStateToProps(newState);
|
|
|
|
return newState;
|
|
};
|
|
|
|
module.exports = { reducer, defaultState, stateUtils, MAX_HISTORY };
|