UNPKG

decap-cms-core

Version:

Decap CMS core application, see decap-cms package for the main distribution.

1,288 lines (1,274 loc) 38.9 kB
import { fromJS, List, Map, Set } from 'immutable'; import isEqual from 'lodash/isEqual'; import { Cursor } from 'decap-cms-lib-util'; import { selectCollectionEntriesCursor } from '../reducers/cursors'; import { selectFields, selectField, updateFieldByKey, selectDefaultSortField, getFileFromSlug } from '../reducers/collections'; import { selectIntegration, selectPublishedSlugs } from '../reducers'; import { getIntegrationProvider } from '../integrations'; import { currentBackend } from '../backend'; import { serializeValues } from '../lib/serializeEntryValues'; import { createEntry } from '../valueObjects/Entry'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import ValidationErrorTypes from '../constants/validationErrorTypes'; import { addAssets, getAsset } from './media'; import { SortDirection } from '../types/redux'; import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary'; import { waitUntil } from './waitUntil'; import { selectIsFetching, selectEntriesSortFields, selectEntryByPath } from '../reducers/entries'; import { selectCustomPath } from '../reducers/entryDraft'; import { navigateToEntry } from '../routing/history'; import { getProcessSegment } from '../lib/formatters'; import { hasI18n, duplicateDefaultI18nFields, serializeI18n, I18N, I18N_FIELD } from '../lib/i18n'; import { addNotification } from './notifications'; import { FILES } from '../constants/collectionTypes'; function isNotesEnabled(collection, slug) { if (collection.get('type') === FILES) { const file = getFileFromSlug(collection, slug); const notesEnabled = file?.get('editor')?.get('notes'); if (notesEnabled != null) return notesEnabled; } return collection.get('editor')?.get('notes') ?? false; } /* * Constant Declarations */ export const ENTRY_REQUEST = 'ENTRY_REQUEST'; export const ENTRY_SUCCESS = 'ENTRY_SUCCESS'; export const ENTRY_FAILURE = 'ENTRY_FAILURE'; export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; export const SORT_ENTRIES_REQUEST = 'SORT_ENTRIES_REQUEST'; export const SORT_ENTRIES_SUCCESS = 'SORT_ENTRIES_SUCCESS'; export const SORT_ENTRIES_FAILURE = 'SORT_ENTRIES_FAILURE'; export const FILTER_ENTRIES_REQUEST = 'FILTER_ENTRIES_REQUEST'; export const FILTER_ENTRIES_SUCCESS = 'FILTER_ENTRIES_SUCCESS'; export const FILTER_ENTRIES_FAILURE = 'FILTER_ENTRIES_FAILURE'; export const GROUP_ENTRIES_REQUEST = 'GROUP_ENTRIES_REQUEST'; export const GROUP_ENTRIES_SUCCESS = 'GROUP_ENTRIES_SUCCESS'; export const GROUP_ENTRIES_FAILURE = 'GROUP_ENTRIES_FAILURE'; export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS'; export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED'; export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP'; export const DRAFT_CREATE_DUPLICATE_FROM_ENTRY = 'DRAFT_CREATE_DUPLICATE_FROM_ENTRY'; export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; export const ENTRY_DELETE_REQUEST = 'ENTRY_DELETE_REQUEST'; export const ENTRY_DELETE_SUCCESS = 'ENTRY_DELETE_SUCCESS'; export const ENTRY_DELETE_FAILURE = 'ENTRY_DELETE_FAILURE'; export const ADD_DRAFT_ENTRY_MEDIA_FILE = 'ADD_DRAFT_ENTRY_MEDIA_FILE'; export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE'; export const CHANGE_VIEW_STYLE = 'CHANGE_VIEW_STYLE'; export const DRAFT_NOTES_LOAD = 'DRAFT_NOTES_LOAD'; export const DRAFT_NOTE_ADD = 'DRAFT_NOTE_ADD'; export const DRAFT_NOTE_UPDATE = 'DRAFT_NOTE_UPDATE'; export const DRAFT_NOTE_DELETE = 'DRAFT_NOTE_DELETE'; export const NOTES_REQUEST = 'NOTES_REQUEST'; export const NOTES_SUCCESS = 'NOTES_SUCCESS'; export const NOTES_FAILURE = 'NOTES_FAILURE'; export const NOTE_PERSIST_REQUEST = 'NOTE_PERSIST_REQUEST'; export const NOTE_PERSIST_SUCCESS = 'NOTE_PERSIST_SUCCESS'; export const NOTE_PERSIST_FAILURE = 'NOTE_PERSIST_FAILURE'; export const NOTE_DELETE_REQUEST = 'NOTE_DELETE_REQUEST'; export const NOTE_DELETE_SUCCESS = 'NOTE_DELETE_SUCCESS'; export const NOTE_DELETE_FAILURE = 'NOTE_DELETE_FAILURE'; export const NOTES_POLLING_START = 'NOTES_POLLING_START'; export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP'; export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE'; export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED'; /* * Simple Action Creators (Internal) * We still need to export them for tests */ export function entryLoading(collection, slug) { return { type: ENTRY_REQUEST, payload: { collection: collection.get('name'), slug } }; } export function entryLoaded(collection, entry) { return { type: ENTRY_SUCCESS, payload: { collection: collection.get('name'), entry } }; } export function entryLoadError(error, collection, slug) { return { type: ENTRY_FAILURE, payload: { error, collection: collection.get('name'), slug } }; } export function entriesLoading(collection) { return { type: ENTRIES_REQUEST, payload: { collection: collection.get('name') } }; } export function entriesLoaded(collection, entries, pagination, cursor, append = true) { return { type: ENTRIES_SUCCESS, payload: { collection: collection.get('name'), entries, page: pagination, cursor: Cursor.create(cursor), append } }; } export function entriesFailed(collection, error) { return { type: ENTRIES_FAILURE, error: 'Failed to load entries', payload: error.toString(), meta: { collection: collection.get('name') } }; } export async function getAllEntries(state, collection) { const backend = currentBackend(state.config); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; const entries = await provider.listAllEntries(collection); return entries; } export function sortByField(collection, key, direction = SortDirection.Ascending) { return async (dispatch, getState) => { const state = getState(); // if we're already fetching we update the sort key, but skip loading entries const isFetching = selectIsFetching(state.entries, collection.get('name')); dispatch({ type: SORT_ENTRIES_REQUEST, payload: { collection: collection.get('name'), key, direction } }); if (isFetching) { return; } try { const entries = await getAllEntries(state, collection); dispatch({ type: SORT_ENTRIES_SUCCESS, payload: { collection: collection.get('name'), key, direction, entries } }); } catch (error) { dispatch({ type: SORT_ENTRIES_FAILURE, payload: { collection: collection.get('name'), key, direction, error } }); } }; } export function filterByField(collection, filter) { return async (dispatch, getState) => { const state = getState(); // if we're already fetching we update the filter key, but skip loading entries const isFetching = selectIsFetching(state.entries, collection.get('name')); dispatch({ type: FILTER_ENTRIES_REQUEST, payload: { collection: collection.get('name'), filter } }); if (isFetching) { return; } try { const entries = await getAllEntries(state, collection); dispatch({ type: FILTER_ENTRIES_SUCCESS, payload: { collection: collection.get('name'), filter, entries } }); } catch (error) { dispatch({ type: FILTER_ENTRIES_FAILURE, payload: { collection: collection.get('name'), filter, error } }); } }; } export function groupByField(collection, group) { return async (dispatch, getState) => { const state = getState(); const isFetching = selectIsFetching(state.entries, collection.get('name')); dispatch({ type: GROUP_ENTRIES_REQUEST, payload: { collection: collection.get('name'), group } }); if (isFetching) { return; } try { const entries = await getAllEntries(state, collection); dispatch({ type: GROUP_ENTRIES_SUCCESS, payload: { collection: collection.get('name'), group, entries } }); } catch (error) { dispatch({ type: GROUP_ENTRIES_FAILURE, payload: { collection: collection.get('name'), group, error } }); } }; } export function changeViewStyle(viewStyle) { return { type: CHANGE_VIEW_STYLE, payload: { style: viewStyle } }; } export function entryPersisting(collection, entry) { return { type: ENTRY_PERSIST_REQUEST, payload: { collectionName: collection.get('name'), entrySlug: entry.get('slug') } }; } export function entryPersisted(collection, entry, slug) { return { type: ENTRY_PERSIST_SUCCESS, payload: { collectionName: collection.get('name'), entrySlug: entry.get('slug'), /** * Pass slug from backend for newly created entries. */ slug } }; } export function entryPersistFail(collection, entry, error) { return { type: ENTRY_PERSIST_FAILURE, error: 'Failed to persist entry', payload: { collectionName: collection.get('name'), entrySlug: entry.get('slug'), error: error.toString() } }; } export function entryDeleting(collection, slug) { return { type: ENTRY_DELETE_REQUEST, payload: { collectionName: collection.get('name'), entrySlug: slug } }; } export function entryDeleted(collection, slug) { return { type: ENTRY_DELETE_SUCCESS, payload: { collectionName: collection.get('name'), entrySlug: slug } }; } export function entryDeleteFail(collection, slug, error) { return { type: ENTRY_DELETE_FAILURE, payload: { collectionName: collection.get('name'), entrySlug: slug, error: error.toString() } }; } export function emptyDraftCreated(entry) { return { type: DRAFT_CREATE_EMPTY, payload: entry }; } /* * Exported simple Action Creators */ export function createDraftFromEntry(entry) { return { type: DRAFT_CREATE_FROM_ENTRY, payload: { entry } }; } export function draftDuplicateEntry(entry) { return { type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY, payload: createEntry(entry.get('collection'), '', '', { data: entry.get('data'), i18n: entry.get('i18n'), mediaFiles: entry.get('mediaFiles').toJS() }) }; } export function discardDraft() { return { type: DRAFT_DISCARD }; } export function changeDraftField({ field, value, metadata, entries, i18n }) { return { type: DRAFT_CHANGE_FIELD, payload: { field, value, metadata, entries, i18n } }; } export function changeDraftFieldValidation(uniquefieldId, errors) { return { type: DRAFT_VALIDATION_ERRORS, payload: { uniquefieldId, errors } }; } export function clearFieldErrors(uniqueFieldId) { return { type: DRAFT_CLEAR_ERRORS, payload: { uniqueFieldId } }; } export function localBackupRetrieved(entry) { return { type: DRAFT_LOCAL_BACKUP_RETRIEVED, payload: { entry } }; } export function loadLocalBackup() { return { type: DRAFT_CREATE_FROM_LOCAL_BACKUP }; } export function addDraftEntryMediaFile(file) { return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file }; } export function removeDraftEntryMediaFile({ id }) { return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } }; } export function loadNotesForEntry(notes) { return { type: DRAFT_NOTES_LOAD, payload: { notes } }; } export function addNote(note) { return { type: DRAFT_NOTE_ADD, payload: { note } }; } export function updateNote(noteId, updates) { return { type: DRAFT_NOTE_UPDATE, payload: { id: noteId, updates } }; } export function deleteNote(noteId) { return { type: DRAFT_NOTE_DELETE, payload: { id: noteId } }; } export function persistLocalBackup(entry, collection) { return (_dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); return backend.persistLocalDraftBackup(entry, collection); }; } export function createDraftDuplicateFromEntry(entry) { return dispatch => { dispatch(waitUntil({ predicate: ({ type }) => type === DRAFT_CREATE_EMPTY, run: () => dispatch(draftDuplicateEntry(entry)) })); }; } export function retrieveLocalBackup(collection, slug) { return async (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); const { entry } = await backend.getLocalDraftBackup(collection, slug); if (entry) { // load assets from backup const mediaFiles = entry.mediaFiles || []; const assetProxies = await Promise.all(mediaFiles.map(file => { if (file.file || file.url) { return createAssetProxy({ path: file.path, file: file.file, url: file.url, field: file.field }); } else { return getAsset({ collection, entry: fromJS(entry), path: file.path, field: file.field })(dispatch, getState); } })); dispatch(addAssets(assetProxies)); return dispatch(localBackupRetrieved(entry)); } }; } export function deleteLocalBackup(collection, slug) { return (_dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); return backend.deleteLocalDraftBackup(collection, slug); }; } /* * Exported Thunk Action Creators */ export function loadEntry(collection, slug) { return async (dispatch, getState) => { await waitForMediaLibraryToLoad(dispatch, getState()); dispatch(entryLoading(collection, slug)); try { const loadedEntry = await tryLoadEntry(getState(), collection, slug); dispatch(entryLoaded(collection, loadedEntry)); dispatch(createDraftFromEntry(loadedEntry)); } catch (error) { dispatch(addNotification({ message: { details: error.message, key: 'ui.toast.onFailToLoadEntries' }, type: 'error', dismissAfter: 8000 })); dispatch(entryLoadError(error, collection, slug)); } }; } export async function tryLoadEntry(state, collection, slug) { const backend = currentBackend(state.config); const loadedEntry = await backend.getEntry(state, collection, slug); return loadedEntry; } const appendActions = fromJS({ ['append_next']: { action: 'next', append: true } }); function addAppendActionsToCursor(cursor) { return Cursor.create(cursor).updateStore('actions', actions => { return actions.union(appendActions.filter(v => actions.has(v.get('action'))).keySeq()); }); } export function loadEntries(collection, page = 0) { return async (dispatch, getState) => { if (collection.get('isFetching')) { return; } const state = getState(); const sortFields = selectEntriesSortFields(state.entries, collection.get('name')); // If user has already set a sort, use it if (sortFields && sortFields.length > 0) { const field = sortFields[0]; return dispatch(sortByField(collection, field.get('key'), field.get('direction'))); } // Otherwise, check for a default sort field in the collection configuration const defaultSort = selectDefaultSortField(collection); if (defaultSort) { const direction = defaultSort.direction === 'desc' ? SortDirection.Descending : SortDirection.Ascending; return dispatch(sortByField(collection, defaultSort.field, direction)); } const backend = currentBackend(state.config); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; const append = !!(page && !isNaN(page) && page > 0); dispatch(entriesLoading(collection)); try { const loadAllEntries = collection.has('nested') || hasI18n(collection); let response = await (loadAllEntries ? // nested collections require all entries to construct the tree provider.listAllEntries(collection).then(entries => ({ entries })) : provider.listEntries(collection, page)); response = { ...response, // The only existing backend using the pagination system is the // Algolia integration, which is also the only integration used // to list entries. Thus, this checking for an integration can // determine whether or not this is using the old integer-based // pagination API. Other backends will simply store an empty // cursor, which behaves identically to no cursor at all. cursor: integration ? Cursor.create({ actions: ['next'], meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } }) : Cursor.create(response.cursor) }; dispatch(entriesLoaded(collection, response.cursor.meta.get('usingOldPaginationAPI') ? response.entries.reverse() : response.entries, response.pagination, addAppendActionsToCursor(response.cursor), append)); } catch (err) { dispatch(addNotification({ message: { details: err, key: 'ui.toast.onFailToLoadEntries' }, type: 'error', dismissAfter: 8000 })); return Promise.reject(dispatch(entriesFailed(collection, err))); } }; } function traverseCursor(backend, cursor, action) { if (!cursor.actions.has(action)) { throw new Error(`The current cursor does not support the pagination action "${action}".`); } return backend.traverseCursor(cursor, action); } export function traverseCollectionCursor(collection, action) { return async (dispatch, getState) => { const state = getState(); const collectionName = collection.get('name'); if (state.entries.getIn(['pages', `${collectionName}`, 'isFetching'])) { return; } const backend = currentBackend(state.config); const { action: realAction, append } = appendActions.has(action) ? appendActions.get(action).toJS() : { action, append: false }; const cursor = selectCollectionEntriesCursor(state.cursors, collection.get('name')); // Handle cursors representing pages in the old, integer-based // pagination API if (cursor.meta.get('usingOldPaginationAPI', false)) { return dispatch(loadEntries(collection, cursor.data.get('nextPage'))); } try { dispatch(entriesLoading(collection)); const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction); const pagination = newCursor.meta?.get('page'); return dispatch(entriesLoaded(collection, entries, pagination, addAppendActionsToCursor(newCursor), append)); } catch (err) { console.error(err); dispatch(addNotification({ message: { details: err, key: 'ui.toast.onFailToLoadEntries' }, type: 'error', dismissAfter: 8000 })); return Promise.reject(dispatch(entriesFailed(collection, err))); } }; } function escapeHtml(unsafe) { return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;'); } function processValue(unsafe) { if (['true', 'True', 'TRUE'].includes(unsafe)) { return true; } if (['false', 'False', 'FALSE'].includes(unsafe)) { return false; } return escapeHtml(unsafe); } function getDataFields(fields) { return fields.filter(f => !f.get('meta')).toList(); } function getMetaFields(fields) { return fields.filter(f => f.get('meta') === true).toList(); } export function createEmptyDraft(collection, search) { return async (dispatch, getState) => { const params = new URLSearchParams(search); const uniqueKeys = Set([...params.keys()]).toArray(); uniqueKeys.forEach(key => { const field = selectField(collection, key); const isMultiple = field?.get('multiple', false); const values = params.getAll(key); collection = updateFieldByKey(collection, key, field => { if (isMultiple) { const allValues = values.flatMap(v => v.split(',')).map(processValue); return field.set('default', List(allValues)); } else { return field.set('default', processValue(values[values.length - 1])); } }); }); const fields = collection.get('fields', List()); const dataFields = getDataFields(fields); const data = createEmptyDraftData(dataFields); const metaFields = getMetaFields(fields); const meta = createEmptyDraftData(metaFields); const state = getState(); const backend = currentBackend(state.config); if (!collection.has('media_folder')) { await waitForMediaLibraryToLoad(dispatch, getState()); } const i18nFields = createEmptyDraftI18nData(collection, dataFields); let newEntry = createEntry(collection.get('name'), '', '', { data, i18n: i18nFields, mediaFiles: [], // eslint-disable-next-line @typescript-eslint/no-explicit-any meta: meta }); newEntry = await backend.processEntry(state, collection, newEntry); dispatch(emptyDraftCreated(newEntry)); }; } export function createEmptyDraftData(fields, skipField = () => false) { return fields.reduce((reduction, value) => { const acc = reduction; const item = value; if (skipField(item)) { return acc; } const subfields = item.get('field') || item.get('fields'); const list = item.get('widget') == 'list'; const name = item.get('name'); const defaultValue = item.get('default', null); function isEmptyDefaultValue(val) { return [[{}], {}].some(e => isEqual(val, e)); } const hasSubfields = List.isList(subfields) || Map.isMap(subfields); if (hasSubfields) { if (list && List.isList(defaultValue)) { acc[name] = defaultValue; } else { const asList = List.isList(subfields) ? subfields : List([subfields]); const subDefaultValue = list ? [createEmptyDraftData(asList, skipField)] : createEmptyDraftData(asList, skipField); if (!isEmptyDefaultValue(subDefaultValue)) { acc[name] = subDefaultValue; } } return acc; } if (defaultValue !== null) { acc[name] = defaultValue; } return acc; }, {}); } function createEmptyDraftI18nData(collection, dataFields) { if (!hasI18n(collection)) { return {}; } function skipField(field) { return field.get(I18N) !== I18N_FIELD.DUPLICATE && field.get(I18N) !== I18N_FIELD.TRANSLATE; } const i18nData = createEmptyDraftData(dataFields, skipField); return duplicateDefaultI18nFields(collection, i18nData); } export function getMediaAssets({ entry }) { const filesArray = entry.get('mediaFiles').toArray(); const assets = filesArray.filter(file => file.get('draft')).map(file => createAssetProxy({ path: file.get('path'), file: file.get('file'), url: file.get('url'), field: file.get('field') })); return assets; } export function getSerializedEntry(collection, entry) { /** * Serialize the values of any fields with registered serializers, and * update the entry and entryDraft with the serialized values. */ const fields = selectFields(collection, entry.get('slug')); // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeData(data) { return serializeValues(data, fields); } const serializedData = serializeData(entry.get('data')); let serializedEntry = entry.set('data', serializedData); if (hasI18n(collection)) { serializedEntry = serializeI18n(collection, serializedEntry, serializeData); } return serializedEntry; } export function persistEntry(collection) { return async (dispatch, getState) => { const state = getState(); const entryDraft = state.entryDraft; const fieldsErrors = entryDraft.get('fieldsErrors'); const usedSlugs = selectPublishedSlugs(state, collection.get('name')); // Early return if draft contains validation errors if (!fieldsErrors.isEmpty()) { const hasPresenceErrors = fieldsErrors.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE)); if (hasPresenceErrors) { dispatch(addNotification({ message: { key: 'ui.toast.missingRequiredField' }, type: 'error', dismissAfter: 8000 })); } return Promise.reject(); } const backend = currentBackend(state.config); const entry = entryDraft.get('entry'); const assetProxies = getMediaAssets({ entry }); const serializedEntry = getSerializedEntry(collection, entry); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); dispatch(entryPersisting(collection, serializedEntry)); return backend.persistEntry({ config: state.config, collection, entryDraft: serializedEntryDraft, assetProxies, usedSlugs }).then(async newSlug => { dispatch(addNotification({ message: { key: 'ui.toast.entrySaved' }, type: 'success', dismissAfter: 4000 })); // re-load media library if entry had media files if (assetProxies.length > 0) { await dispatch(loadMedia()); } dispatch(entryPersisted(collection, serializedEntry, newSlug)); if (collection.has('nested')) { await dispatch(loadEntries(collection)); } if (entry.get('slug') !== newSlug) { await dispatch(loadEntry(collection, newSlug)); navigateToEntry(collection.get('name'), newSlug); } }).catch(error => { console.error(error); dispatch(addNotification({ message: { details: error, key: 'ui.toast.onFailToPersist' }, type: 'error', dismissAfter: 8000 })); return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error))); }); }; } export function deleteEntry(collection, slug) { return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); dispatch(entryDeleting(collection, slug)); return backend.deleteEntry(state, collection, slug).then(() => { return dispatch(entryDeleted(collection, slug)); }).catch(error => { dispatch(addNotification({ message: { details: error, key: 'ui.toast.onFailToDelete' }, type: 'error', dismissAfter: 8000 })); console.error(error); return Promise.reject(dispatch(entryDeleteFail(collection, slug, error))); }); }; } export function notesLoading(collection, slug) { return { type: NOTES_REQUEST, payload: { collection: collection.get('name'), slug } }; } export function notesLoaded(collection, slug, notes) { return { type: NOTES_SUCCESS, payload: { collection: collection.get('name'), slug, notes } }; } export function notesLoadError(error, collection, slug) { return { type: NOTES_FAILURE, payload: { error, collection: collection.get('name'), slug } }; } export function notePersisting(collection, slug, note) { return { type: NOTE_PERSIST_REQUEST, payload: { collection: collection.get('name'), slug, noteId: note.id } }; } export function notePersisted(collection, slug, note) { return { type: NOTE_PERSIST_SUCCESS, payload: { collection: collection.get('name'), slug, note } }; } export function notePersistFail(collection, slug, note, error) { return { type: NOTE_PERSIST_FAILURE, error: 'Failed to persist note', payload: { collection: collection.get('name'), slug, noteId: note.id, error: error.toString() } }; } export function noteDeleting(collection, slug, noteId) { return { type: NOTE_DELETE_REQUEST, payload: { collection: collection.get('name'), slug, noteId } }; } export function noteDeleted(collection, slug, noteId) { return { type: NOTE_DELETE_SUCCESS, payload: { collection: collection.get('name'), slug, noteId } }; } export function noteDeleteFail(collection, slug, noteId, error) { return { type: NOTE_DELETE_FAILURE, payload: { collection: collection.get('name'), slug, noteId, error: error.toString() } }; } export function loadNotes(collection, slug) { return async (dispatch, getState) => { dispatch(notesLoading(collection, slug)); try { const state = getState(); const backend = currentBackend(state.config); const notes = await backend.getNotes(collection.get('name'), slug); // Set entrySlug for all notes const notesWithSlug = notes.map(note => ({ ...note, entrySlug: slug })); dispatch(notesLoaded(collection, slug, notesWithSlug)); dispatch(loadNotesForEntry(notesWithSlug)); dispatch(startNotesPolling(collection, slug)); } catch (error) { dispatch(notesLoadError(error, collection, slug)); dispatch(loadNotesForEntry([])); // Start with empty notes } }; } export function pollingStarted(collection, slug) { return { type: NOTES_POLLING_START, payload: { collection: collection.get('name'), slug } }; } export function pollingStopped(collection, slug) { return { type: NOTES_POLLING_STOP, payload: { collection: collection.get('name'), slug } }; } export function notesUpdatedFromPolling(collection, slug, notes, changes) { return { type: NOTES_POLLING_UPDATE, payload: { collection: collection.get('name'), slug, notes, changes } }; } export function changeDetected(collection, slug, change) { return { type: NOTES_CHANGE_DETECTED, payload: { collection: collection.get('name'), slug, change } }; } export function startNotesPolling(collection, slug) { return async (dispatch, getState) => { try { const state = getState(); const backend = currentBackend(state.config); if (!backend.startNotesPolling) { console.warn('[DecapNotes Polling] Backend does not support notes polling'); return; } const callbacks = { onUpdate: (notes, changes) => { dispatch(notesUpdatedFromPolling(collection, slug, notes, changes)); dispatch(loadNotesForEntry(notes)); }, onChange: change => { // Dispatch action for individual change notifications dispatch(changeDetected(collection, slug, change)); } }; await backend.startNotesPolling(collection.get('name'), slug, callbacks); dispatch(pollingStarted(collection, slug)); } catch (error) { console.error('[DecapNotes Polling] Failed to start notes polling:', error); } }; } /** * Stop watching notes for an entry */ export function stopNotesPolling(collection, slug) { return async (dispatch, getState) => { try { const state = getState(); const backend = currentBackend(state.config); if (!backend.stopNotesPolling) { return; } await backend.stopNotesPolling(collection.get('name'), slug); dispatch(pollingStopped(collection, slug)); } catch (error) { console.error('[Redux] Failed to stop notes polling:', error); } }; } /** * Manually refresh notes */ export function refreshNotesNow(collection, slug) { return async (dispatch, getState) => { try { const state = getState(); const backend = currentBackend(state.config); if (!backend.refreshNotesNow) { return dispatch(loadNotes(collection, slug)); } await backend.refreshNotesNow(collection.get('name'), slug); } catch (error) { console.error('[DecapNotes Polling] Failed to refresh notes:', error); } }; } export function persistNote(collection, slug, note) { return async (dispatch, getState) => { const backend = currentBackend(getState().config); dispatch(notePersisting(collection, slug, note)); try { const savedNote = await backend.addNote(collection.get('name'), slug, note); dispatch(notePersisted(collection, slug, savedNote)); dispatch(addNote(savedNote)); dispatch(addNotification({ message: { key: 'ui.toast.noteAdded' }, type: 'success', dismissAfter: 4000 })); // After adding a note, polling gets restarted. In case the note is the first ever note persisted (which is the moment a Github Issue is created for example) this will help the Polling system to pickup the source of the Notes. if (isNotesEnabled(collection, slug)) { // Small delay to let the issue creation propagate await new Promise(resolve => setTimeout(resolve, 1000)); await dispatch(startNotesPolling(collection, slug)); } return savedNote; } catch (error) { dispatch(notePersistFail(collection, slug, note, error)); dispatch(addNotification({ message: { details: error.message, key: 'ui.toast.onFailToAddNote' }, type: 'error', dismissAfter: 8000 })); } }; } export function updateNotePersist(collection, slug, noteId, updates) { return async (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); // Create a temporary note object for the persist actions const tempNote = { id: noteId, ...updates }; dispatch(notePersisting(collection, slug, tempNote)); try { const updatedNote = await backend.updateNote(collection.get('name'), slug, noteId, updates); dispatch(notePersisted(collection, slug, updatedNote)); dispatch(updateNote(noteId, updates)); dispatch(addNotification({ message: { key: 'ui.toast.noteUpdated' }, type: 'success', dismissAfter: 4000 })); return updatedNote; } catch (error) { dispatch(notePersistFail(collection, slug, tempNote, error)); dispatch(addNotification({ message: { details: error.message, key: 'ui.toast.onFailToUpdateNote' }, type: 'error', dismissAfter: 8000 })); } }; } export function deleteNotePersist(collection, slug, noteId) { return async (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); dispatch(noteDeleting(collection, slug, noteId)); try { await backend.deleteNote(collection.get('name'), slug, noteId); dispatch(noteDeleted(collection, slug, noteId)); dispatch(deleteNote(noteId)); dispatch(addNotification({ message: { key: 'ui.toast.noteDeleted' }, type: 'success', dismissAfter: 4000 })); } catch (error) { dispatch(noteDeleteFail(collection, slug, noteId, error)); dispatch(addNotification({ message: { details: error.message, key: 'ui.toast.onFailToDeleteNote' }, type: 'error', dismissAfter: 8000 })); } }; } export function toggleNoteResolutionPersist(collection, slug, noteId) { return async (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); // Create a temporary note object for the persist actions const tempNote = { id: noteId }; dispatch(notePersisting(collection, slug, tempNote)); try { const updatedNote = await backend.toggleNoteResolution(collection.get('name'), slug, noteId); dispatch(notePersisted(collection, slug, updatedNote)); dispatch(updateNote(noteId, { resolved: updatedNote.resolved })); dispatch(addNotification({ message: { key: updatedNote.resolved ? 'ui.toast.noteResolved' : 'ui.toast.noteReopened' }, type: 'success', dismissAfter: 4000 })); return updatedNote; } catch (error) { dispatch(notePersistFail(collection, slug, tempNote, error)); dispatch(addNotification({ message: { details: error.message, key: 'ui.toast.onFailToToggleNote' }, type: 'error', dismissAfter: 8000 })); } }; } function getPathError(path, key, t) { return { error: { type: ValidationErrorTypes.CUSTOM, message: t(`editor.editorControlPane.widget.${key}`, { path }) } }; } export function validateMetaField(state, collection, field, value, t) { if (field.get('meta') && field.get('name') === 'path') { if (!value) { return getPathError(value, 'invalidPath', t); } const sanitizedPath = value.split('/').map(getProcessSegment(state.config.slug)).join('/'); if (value !== sanitizedPath) { return getPathError(value, 'invalidPath', t); } const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path: value } } })); const existingEntry = customPath ? selectEntryByPath(state.entries, collection.get('name'), customPath) : undefined; const existingEntryPath = existingEntry?.get('path'); const draftPath = state.entryDraft?.getIn(['entry', 'path']); if (existingEntryPath && existingEntryPath !== draftPath) { return getPathError(value, 'pathExists', t); } } return { error: false }; }