mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 15:14:56 -04:00
fix: Move content into to frontend directory
Doing this BEFORE the merge prevents a lot of merge conflicts. Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
4e18ce38f3
commit
762a0a850e
1051 changed files with 0 additions and 35 deletions
25
frontend/src/redux/application-state.d.ts
vendored
Normal file
25
frontend/src/redux/application-state.d.ts
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { OptionalUserState } from './user/types'
|
||||
import type { Config } from '../api/config/types'
|
||||
import type { EditorConfig } from './editor/types'
|
||||
import type { DarkModeConfig } from './dark-mode/types'
|
||||
import type { NoteDetails } from './note-details/types/note-details'
|
||||
import type { RendererStatus } from './renderer-status/types'
|
||||
import type { HistoryEntryWithOrigin } from '../api/history/types'
|
||||
import type { RealtimeState } from './realtime/types'
|
||||
|
||||
export interface ApplicationState {
|
||||
user: OptionalUserState
|
||||
config: Config
|
||||
history: HistoryEntryWithOrigin[]
|
||||
editorConfig: EditorConfig
|
||||
darkMode: DarkModeConfig
|
||||
noteDetails: NoteDetails
|
||||
rendererStatus: RendererStatus
|
||||
realtime: RealtimeState
|
||||
}
|
17
frontend/src/redux/config/methods.ts
Normal file
17
frontend/src/redux/config/methods.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { Config } from '../../api/config/types'
|
||||
import type { SetConfigAction } from './types'
|
||||
import { ConfigActionType } from './types'
|
||||
|
||||
export const setConfig = (state: Config): void => {
|
||||
store.dispatch({
|
||||
type: ConfigActionType.SET_CONFIG,
|
||||
state: state
|
||||
} as SetConfigAction)
|
||||
}
|
42
frontend/src/redux/config/reducers.ts
Normal file
42
frontend/src/redux/config/reducers.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import type { Config } from '../../api/config/types'
|
||||
import type { ConfigActions } from './types'
|
||||
import { ConfigActionType } from './types'
|
||||
|
||||
export const initialState: Config = {
|
||||
allowAnonymous: true,
|
||||
allowRegister: true,
|
||||
authProviders: [],
|
||||
branding: {
|
||||
name: '',
|
||||
logo: ''
|
||||
},
|
||||
useImageProxy: false,
|
||||
specialUrls: {
|
||||
privacy: undefined,
|
||||
termsOfUse: undefined,
|
||||
imprint: undefined
|
||||
},
|
||||
version: {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0
|
||||
},
|
||||
plantumlServer: undefined,
|
||||
maxDocumentLength: 0
|
||||
}
|
||||
|
||||
export const ConfigReducer: Reducer<Config, ConfigActions> = (state: Config = initialState, action: ConfigActions) => {
|
||||
switch (action.type) {
|
||||
case ConfigActionType.SET_CONFIG:
|
||||
return action.state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
19
frontend/src/redux/config/types.ts
Normal file
19
frontend/src/redux/config/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
import type { Config } from '../../api/config/types'
|
||||
|
||||
export enum ConfigActionType {
|
||||
SET_CONFIG = 'config/set'
|
||||
}
|
||||
|
||||
export type ConfigActions = SetConfigAction
|
||||
|
||||
export interface SetConfigAction extends Action<ConfigActionType> {
|
||||
type: ConfigActionType.SET_CONFIG
|
||||
state: Config
|
||||
}
|
16
frontend/src/redux/dark-mode/methods.ts
Normal file
16
frontend/src/redux/dark-mode/methods.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { DarkModeConfigAction, DarkModePreference } from './types'
|
||||
import { DarkModeConfigActionType } from './types'
|
||||
|
||||
export const setDarkModePreference = (darkModePreference: DarkModePreference): void => {
|
||||
store.dispatch({
|
||||
type: DarkModeConfigActionType.SET_DARK_MODE,
|
||||
darkModePreference
|
||||
} as DarkModeConfigAction)
|
||||
}
|
27
frontend/src/redux/dark-mode/reducers.ts
Normal file
27
frontend/src/redux/dark-mode/reducers.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import type { DarkModeConfig, DarkModeConfigAction } from './types'
|
||||
import { DarkModeConfigActionType, DarkModePreference } from './types'
|
||||
|
||||
const initialState: DarkModeConfig = {
|
||||
darkModePreference: DarkModePreference.AUTO
|
||||
}
|
||||
|
||||
export const DarkModeConfigReducer: Reducer<DarkModeConfig, DarkModeConfigAction> = (
|
||||
state: DarkModeConfig = initialState,
|
||||
action: DarkModeConfigAction
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case DarkModeConfigActionType.SET_DARK_MODE:
|
||||
return {
|
||||
darkModePreference: action.darkModePreference
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
23
frontend/src/redux/dark-mode/types.ts
Normal file
23
frontend/src/redux/dark-mode/types.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
|
||||
export enum DarkModeConfigActionType {
|
||||
SET_DARK_MODE = 'dark-mode/set'
|
||||
}
|
||||
|
||||
export enum DarkModePreference {
|
||||
DARK,
|
||||
LIGHT,
|
||||
AUTO
|
||||
}
|
||||
|
||||
export interface DarkModeConfig {
|
||||
darkModePreference: DarkModePreference
|
||||
}
|
||||
|
||||
export type DarkModeConfigAction = Action<DarkModeConfigActionType.SET_DARK_MODE> & DarkModeConfig
|
62
frontend/src/redux/editor/methods.ts
Normal file
62
frontend/src/redux/editor/methods.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type {
|
||||
EditorConfig,
|
||||
SetEditorLigaturesAction,
|
||||
SetEditorSmartPasteAction,
|
||||
SetEditorSyncScrollAction
|
||||
} from './types'
|
||||
import { EditorConfigActionType } from './types'
|
||||
import { Logger } from '../../utils/logger'
|
||||
|
||||
const log = new Logger('Redux > Editor')
|
||||
|
||||
export const loadFromLocalStorage = (): EditorConfig | undefined => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('editorConfig')
|
||||
if (!stored) {
|
||||
return undefined
|
||||
}
|
||||
return JSON.parse(stored) as EditorConfig
|
||||
} catch (_) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const saveToLocalStorage = (editorConfig: EditorConfig): void => {
|
||||
try {
|
||||
const json = JSON.stringify(editorConfig)
|
||||
localStorage.setItem('editorConfig', json)
|
||||
} catch (error) {
|
||||
log.error('Error while saving editor config in local storage', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const setEditorSyncScroll = (syncScroll: boolean): void => {
|
||||
const action: SetEditorSyncScrollAction = {
|
||||
type: EditorConfigActionType.SET_SYNC_SCROLL,
|
||||
syncScroll
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
||||
|
||||
export const setEditorLigatures = (ligatures: boolean): void => {
|
||||
const action: SetEditorLigaturesAction = {
|
||||
type: EditorConfigActionType.SET_LIGATURES,
|
||||
ligatures
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
||||
|
||||
export const setEditorSmartPaste = (smartPaste: boolean): void => {
|
||||
const action: SetEditorSmartPasteAction = {
|
||||
type: EditorConfigActionType.SET_SMART_PASTE,
|
||||
smartPaste
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
60
frontend/src/redux/editor/reducers.ts
Normal file
60
frontend/src/redux/editor/reducers.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import { loadFromLocalStorage, saveToLocalStorage } from './methods'
|
||||
import type { EditorConfig, EditorConfigActions } from './types'
|
||||
import { EditorConfigActionType } from './types'
|
||||
|
||||
const initialState: EditorConfig = {
|
||||
ligatures: true,
|
||||
syncScroll: true,
|
||||
smartPaste: true,
|
||||
spellCheck: false
|
||||
}
|
||||
|
||||
const getInitialState = (): EditorConfig => {
|
||||
return { ...initialState, ...loadFromLocalStorage() }
|
||||
}
|
||||
|
||||
export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (
|
||||
state: EditorConfig = getInitialState(),
|
||||
action: EditorConfigActions
|
||||
) => {
|
||||
let newState: EditorConfig
|
||||
switch (action.type) {
|
||||
case EditorConfigActionType.SET_SYNC_SCROLL:
|
||||
newState = {
|
||||
...state,
|
||||
syncScroll: action.syncScroll
|
||||
}
|
||||
saveToLocalStorage(newState)
|
||||
return newState
|
||||
case EditorConfigActionType.SET_LIGATURES:
|
||||
newState = {
|
||||
...state,
|
||||
ligatures: action.ligatures
|
||||
}
|
||||
saveToLocalStorage(newState)
|
||||
return newState
|
||||
case EditorConfigActionType.SET_SMART_PASTE:
|
||||
newState = {
|
||||
...state,
|
||||
smartPaste: action.smartPaste
|
||||
}
|
||||
saveToLocalStorage(newState)
|
||||
return newState
|
||||
case EditorConfigActionType.SET_SPELL_CHECK:
|
||||
newState = {
|
||||
...state,
|
||||
spellCheck: action.spellCheck
|
||||
}
|
||||
saveToLocalStorage(newState)
|
||||
return newState
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
48
frontend/src/redux/editor/types.ts
Normal file
48
frontend/src/redux/editor/types.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
|
||||
export enum EditorConfigActionType {
|
||||
SET_EDITOR_VIEW_MODE = 'editor/view-mode/set',
|
||||
SET_SYNC_SCROLL = 'editor/syncScroll/set',
|
||||
SET_LIGATURES = 'editor/preferences/setLigatures',
|
||||
SET_SMART_PASTE = 'editor/preferences/setSmartPaste',
|
||||
SET_SPELL_CHECK = 'editor/preferences/setSpellCheck'
|
||||
}
|
||||
|
||||
export interface EditorConfig {
|
||||
syncScroll: boolean
|
||||
ligatures: boolean
|
||||
smartPaste: boolean
|
||||
spellCheck: boolean
|
||||
}
|
||||
|
||||
export type EditorConfigActions =
|
||||
| SetEditorSyncScrollAction
|
||||
| SetEditorLigaturesAction
|
||||
| SetEditorSmartPasteAction
|
||||
| SetSpellCheckAction
|
||||
|
||||
export interface SetEditorSyncScrollAction extends Action<EditorConfigActionType> {
|
||||
type: EditorConfigActionType.SET_SYNC_SCROLL
|
||||
syncScroll: boolean
|
||||
}
|
||||
|
||||
export interface SetEditorLigaturesAction extends Action<EditorConfigActionType> {
|
||||
type: EditorConfigActionType.SET_LIGATURES
|
||||
ligatures: boolean
|
||||
}
|
||||
|
||||
export interface SetEditorSmartPasteAction extends Action<EditorConfigActionType> {
|
||||
type: EditorConfigActionType.SET_SMART_PASTE
|
||||
smartPaste: boolean
|
||||
}
|
||||
|
||||
export interface SetSpellCheckAction extends Action<EditorConfigActionType> {
|
||||
type: EditorConfigActionType.SET_SPELL_CHECK
|
||||
spellCheck: boolean
|
||||
}
|
251
frontend/src/redux/history/methods.ts
Normal file
251
frontend/src/redux/history/methods.ts
Normal file
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { getGlobalState, store } from '../index'
|
||||
import type { HistoryExportJson, RemoveEntryAction, SetEntriesAction, UpdateEntryAction, V1HistoryEntry } from './types'
|
||||
import { HistoryActionType } from './types'
|
||||
import { download } from '../../components/common/download/download'
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
deleteRemoteHistory,
|
||||
deleteRemoteHistoryEntry,
|
||||
getRemoteHistory,
|
||||
setRemoteHistoryEntries,
|
||||
updateRemoteHistoryEntryPinStatus
|
||||
} from '../../api/history'
|
||||
import { addRemoteOriginToHistoryEntry, historyEntryToHistoryEntryPutDto } from '../../api/history/dto-methods'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../api/history/types'
|
||||
|
||||
const log = new Logger('Redux > History')
|
||||
|
||||
/**
|
||||
* Sets the given history entries into the current redux state and updates the local-storage.
|
||||
* @param entries The history entries to set into the redux state.
|
||||
*/
|
||||
export const setHistoryEntries = (entries: HistoryEntryWithOrigin[]): void => {
|
||||
store.dispatch({
|
||||
type: HistoryActionType.SET_ENTRIES,
|
||||
entries
|
||||
} as SetEntriesAction)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the given history entries into redux state and local-storage and remote based on their associated origin label.
|
||||
* @param entries The history entries to import.
|
||||
*/
|
||||
export const importHistoryEntries = (entries: HistoryEntryWithOrigin[]): Promise<unknown> => {
|
||||
setHistoryEntries(entries)
|
||||
return storeRemoteHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all history entries in the redux, local-storage and on the server.
|
||||
*/
|
||||
export const deleteAllHistoryEntries = (): Promise<unknown> => {
|
||||
store.dispatch({
|
||||
type: HistoryActionType.SET_ENTRIES,
|
||||
entries: []
|
||||
} as SetEntriesAction)
|
||||
storeLocalHistory()
|
||||
return deleteRemoteHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single history entry in the redux.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
* @param newEntry The modified history entry.
|
||||
*/
|
||||
export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): void => {
|
||||
store.dispatch({
|
||||
type: HistoryActionType.UPDATE_ENTRY,
|
||||
noteId,
|
||||
newEntry
|
||||
} as UpdateEntryAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single history entry in the local-storage.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
* @param newEntry The modified history entry.
|
||||
*/
|
||||
export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry): void => {
|
||||
updateHistoryEntryRedux(noteId, newEntry)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single history entry for a given note id.
|
||||
* @param noteId The note id of the history entry to delete.
|
||||
*/
|
||||
export const removeHistoryEntry = async (noteId: string): Promise<void> => {
|
||||
const entryToDelete = getGlobalState().history.find((entry) => entry.identifier === noteId)
|
||||
if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) {
|
||||
await deleteRemoteHistoryEntry(noteId)
|
||||
}
|
||||
store.dispatch({
|
||||
type: HistoryActionType.REMOVE_ENTRY,
|
||||
noteId
|
||||
} as RemoveEntryAction)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the pinning state of a single history entry.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
*/
|
||||
export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> => {
|
||||
const state = getGlobalState().history
|
||||
const entryToUpdate = state.find((entry) => entry.identifier === noteId)
|
||||
if (!entryToUpdate) {
|
||||
return Promise.reject(`History entry for note '${noteId}' not found`)
|
||||
}
|
||||
const updatedEntry = {
|
||||
...entryToUpdate,
|
||||
pinStatus: !entryToUpdate.pinStatus
|
||||
}
|
||||
if (entryToUpdate.origin === HistoryEntryOrigin.LOCAL) {
|
||||
updateLocalHistoryEntry(noteId, updatedEntry)
|
||||
} else {
|
||||
await updateRemoteHistoryEntryPinStatus(noteId, updatedEntry.pinStatus)
|
||||
updateHistoryEntryRedux(noteId, updatedEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current history redux state into a JSON file that will be downloaded by the client.
|
||||
*/
|
||||
export const downloadHistory = (): void => {
|
||||
const history = getGlobalState().history
|
||||
history.forEach((entry: Partial<HistoryEntryWithOrigin>) => {
|
||||
delete entry.origin
|
||||
})
|
||||
const json = JSON.stringify({
|
||||
version: 2,
|
||||
entries: history
|
||||
} as HistoryExportJson)
|
||||
download(json, `history_${Date.now()}.json`, 'application/json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two arrays of history entries while removing duplicates.
|
||||
* @param a The first input array of history entries.
|
||||
* @param b The second input array of history entries. This array takes precedence when duplicates were found.
|
||||
* @return The merged array of history entries without duplicates.
|
||||
*/
|
||||
export const mergeHistoryEntries = (
|
||||
a: HistoryEntryWithOrigin[],
|
||||
b: HistoryEntryWithOrigin[]
|
||||
): HistoryEntryWithOrigin[] => {
|
||||
const noDuplicates = a.filter((entryA) => !b.some((entryB) => entryA.identifier === entryB.identifier))
|
||||
return noDuplicates.concat(b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of local HedgeDoc v1 history entries to HedgeDoc v2 history entries.
|
||||
* @param oldHistory An array of HedgeDoc v1 history entries.
|
||||
* @return An array of HedgeDoc v2 history entries associated with the local origin label.
|
||||
*/
|
||||
export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntryWithOrigin[] => {
|
||||
return oldHistory.map((entry) => ({
|
||||
identifier: entry.id,
|
||||
title: entry.text,
|
||||
tags: entry.tags,
|
||||
lastVisitedAt: DateTime.fromMillis(entry.time).toISO(),
|
||||
pinStatus: entry.pinned,
|
||||
origin: HistoryEntryOrigin.LOCAL
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the history redux state by reloading the local history and fetching the remote history if the user is logged-in.
|
||||
*/
|
||||
export const refreshHistoryState = async (): Promise<void> => {
|
||||
const localEntries = loadLocalHistory()
|
||||
if (!getGlobalState().user) {
|
||||
setHistoryEntries(localEntries)
|
||||
return
|
||||
}
|
||||
const remoteEntries = await loadRemoteHistory()
|
||||
const allEntries = mergeHistoryEntries(localEntries, remoteEntries)
|
||||
setHistoryEntries(allEntries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the history entries marked as local from the redux to the user's local-storage.
|
||||
*/
|
||||
export const storeLocalHistory = (): void => {
|
||||
const history = getGlobalState().history
|
||||
const localEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL)
|
||||
const entriesWithoutOrigin = localEntries.map((entry) => ({
|
||||
...entry,
|
||||
origin: undefined
|
||||
}))
|
||||
window.localStorage.setItem('history', JSON.stringify(entriesWithoutOrigin))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the history entries marked as remote from the redux to the server.
|
||||
*/
|
||||
export const storeRemoteHistory = (): Promise<unknown> => {
|
||||
if (!getGlobalState().user) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const history = getGlobalState().history
|
||||
const remoteEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.REMOTE)
|
||||
const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto)
|
||||
return setRemoteHistoryEntries(remoteEntryDtos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the local history from local-storage, converts from V1 format if necessary and returns the history entries with a local origin label.
|
||||
* @return The local history entries with the origin set to local.
|
||||
*/
|
||||
const loadLocalHistory = (): HistoryEntryWithOrigin[] => {
|
||||
const localV1Json = window.localStorage.getItem('notehistory')
|
||||
if (localV1Json) {
|
||||
try {
|
||||
const localV1History = JSON.parse(JSON.parse(localV1Json) as string) as V1HistoryEntry[]
|
||||
window.localStorage.removeItem('notehistory')
|
||||
return convertV1History(localV1History)
|
||||
} catch (error) {
|
||||
log.error('Error while converting old history entries', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const localJson = window.localStorage.getItem('history')
|
||||
if (!localJson) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const localHistory = JSON.parse(localJson) as HistoryEntryWithOrigin[]
|
||||
localHistory.forEach((entry) => {
|
||||
entry.origin = HistoryEntryOrigin.LOCAL
|
||||
})
|
||||
return localHistory
|
||||
} catch (error) {
|
||||
log.error('Error while parsing locally stored history entries', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the remote history and maps each entry with a remote origin label.
|
||||
* @return The remote history entries with the origin set to remote.
|
||||
*/
|
||||
const loadRemoteHistory = async (): Promise<HistoryEntryWithOrigin[]> => {
|
||||
try {
|
||||
const remoteHistory = await getRemoteHistory()
|
||||
return remoteHistory.map(addRemoteOriginToHistoryEntry)
|
||||
} catch (error) {
|
||||
log.error('Error while fetching history entries from server', error)
|
||||
return []
|
||||
}
|
||||
}
|
30
frontend/src/redux/history/reducers.ts
Normal file
30
frontend/src/redux/history/reducers.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import type { HistoryActions } from './types'
|
||||
import { HistoryActionType } from './types'
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
|
||||
// Q: Why is the reducer initialized with an empty array instead of the actual history entries like in the config reducer?
|
||||
// A: The history reducer will be created without entries because of async entry retrieval.
|
||||
// Entries will be added after reducer initialization.
|
||||
|
||||
export const HistoryReducer: Reducer<HistoryEntryWithOrigin[], HistoryActions> = (
|
||||
state: HistoryEntryWithOrigin[] = [],
|
||||
action: HistoryActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case HistoryActionType.SET_ENTRIES:
|
||||
return action.entries
|
||||
case HistoryActionType.UPDATE_ENTRY:
|
||||
return [...state.filter((entry) => entry.identifier !== action.noteId), action.newEntry]
|
||||
case HistoryActionType.REMOVE_ENTRY:
|
||||
return state.filter((entry) => entry.identifier !== action.noteId)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
51
frontend/src/redux/history/types.ts
Normal file
51
frontend/src/redux/history/types.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
|
||||
export interface V1HistoryEntry {
|
||||
id: string
|
||||
text: string
|
||||
time: number
|
||||
tags: string[]
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export interface HistoryExportJson {
|
||||
version: number
|
||||
entries: HistoryEntryWithOrigin[]
|
||||
}
|
||||
|
||||
export enum HistoryActionType {
|
||||
SET_ENTRIES = 'SET_ENTRIES',
|
||||
ADD_ENTRY = 'ADD_ENTRY',
|
||||
UPDATE_ENTRY = 'UPDATE_ENTRY',
|
||||
REMOVE_ENTRY = 'REMOVE_ENTRY'
|
||||
}
|
||||
|
||||
export type HistoryActions = SetEntriesAction | AddEntryAction | UpdateEntryAction | RemoveEntryAction
|
||||
|
||||
export interface SetEntriesAction extends Action<HistoryActionType> {
|
||||
type: HistoryActionType.SET_ENTRIES
|
||||
entries: HistoryEntryWithOrigin[]
|
||||
}
|
||||
|
||||
export interface AddEntryAction extends Action<HistoryActionType> {
|
||||
type: HistoryActionType.ADD_ENTRY
|
||||
newEntry: HistoryEntryWithOrigin
|
||||
}
|
||||
|
||||
export interface UpdateEntryAction extends Action<HistoryActionType> {
|
||||
type: HistoryActionType.UPDATE_ENTRY
|
||||
noteId: string
|
||||
newEntry: HistoryEntryWithOrigin
|
||||
}
|
||||
|
||||
export interface RemoveEntryAction extends Action<HistoryActionType> {
|
||||
type: HistoryActionType.REMOVE_ENTRY
|
||||
noteId: string
|
||||
}
|
17
frontend/src/redux/index.ts
Normal file
17
frontend/src/redux/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { allReducers } from './reducers'
|
||||
import type { ApplicationState } from './application-state'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { isDevMode } from '../utils/test-modes'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: allReducers,
|
||||
devTools: isDevMode
|
||||
})
|
||||
|
||||
export const getGlobalState = (): ApplicationState => store.getState()
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from './types/note-details'
|
||||
import { extractFrontmatter } from './frontmatter-extractor/extractor'
|
||||
import { initialState } from './initial-state'
|
||||
import type { PresentFrontmatterExtractionResult } from './frontmatter-extractor/types'
|
||||
import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser'
|
||||
import { generateNoteTitle } from './generate-note-title'
|
||||
import { calculateLineStartIndexes } from './calculate-line-start-indexes'
|
||||
|
||||
/**
|
||||
* Copies a {@link NoteDetails} but with another markdown content.
|
||||
* @param state The previous state.
|
||||
* @param markdownContent The new note markdown content consisting of the frontmatter and markdown part.
|
||||
* @return An updated {@link NoteDetails} state.
|
||||
*/
|
||||
export const buildStateFromUpdatedMarkdownContent = (state: NoteDetails, markdownContent: string): NoteDetails => {
|
||||
return buildStateFromMarkdownContentAndLines(state, markdownContent, markdownContent.split('\n'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a {@link NoteDetails} but with another markdown content.
|
||||
* @param state The previous state.
|
||||
* @param markdownContentLines The new note markdown content as separate lines consisting of the frontmatter and markdown part.
|
||||
* @return An updated {@link NoteDetails} state.
|
||||
*/
|
||||
export const buildStateFromUpdatedMarkdownContentLines = (
|
||||
state: NoteDetails,
|
||||
markdownContentLines: string[]
|
||||
): NoteDetails => {
|
||||
return buildStateFromMarkdownContentAndLines(state, markdownContentLines.join('\n'), markdownContentLines)
|
||||
}
|
||||
|
||||
const buildStateFromMarkdownContentAndLines = (
|
||||
state: NoteDetails,
|
||||
markdownContent: string,
|
||||
markdownContentLines: string[]
|
||||
): NoteDetails => {
|
||||
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
|
||||
const lineStartIndexes = calculateLineStartIndexes(markdownContentLines)
|
||||
if (frontmatterExtraction.isPresent) {
|
||||
return buildStateFromFrontmatterUpdate(
|
||||
{
|
||||
...state,
|
||||
markdownContent: {
|
||||
plain: markdownContent,
|
||||
lines: markdownContentLines,
|
||||
lineStartIndexes
|
||||
}
|
||||
},
|
||||
frontmatterExtraction
|
||||
)
|
||||
} else {
|
||||
return {
|
||||
...state,
|
||||
markdownContent: {
|
||||
plain: markdownContent,
|
||||
lines: markdownContentLines,
|
||||
lineStartIndexes
|
||||
},
|
||||
rawFrontmatter: '',
|
||||
title: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: initialState.frontmatterRendererInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from extracted frontmatter data.
|
||||
* @param state The previous redux state.
|
||||
* @param frontmatterExtraction The result of the frontmatter extraction containing the raw data and the line offset.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromFrontmatterUpdate = (
|
||||
state: NoteDetails,
|
||||
frontmatterExtraction: PresentFrontmatterExtractionResult
|
||||
): NoteDetails => {
|
||||
if (frontmatterExtraction.rawText === state.rawFrontmatter) {
|
||||
return state
|
||||
}
|
||||
try {
|
||||
const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText)
|
||||
return {
|
||||
...state,
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: frontmatter,
|
||||
title: generateNoteTitle(frontmatter, state.firstHeading),
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
frontmatterInvalid: false,
|
||||
slideOptions: frontmatter.slideOptions
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
...state,
|
||||
title: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
frontmatterInvalid: true,
|
||||
slideOptions: initialState.frontmatterRendererInfo.slideOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { calculateLineStartIndexes } from './calculate-line-start-indexes'
|
||||
|
||||
describe('calculateLineStartIndexes', () => {
|
||||
it('works with an empty list', () => {
|
||||
expect(calculateLineStartIndexes([])).toEqual([])
|
||||
})
|
||||
it('works with an non empty list', () => {
|
||||
expect(calculateLineStartIndexes(['a', 'bc', 'def', 'ghij', 'klmno', 'pqrstu', 'vwxyz'])).toEqual([
|
||||
0, 2, 5, 9, 14, 20, 27
|
||||
])
|
||||
})
|
||||
it('works with an non empty list with empty lines', () => {
|
||||
expect(calculateLineStartIndexes(['', '', ''])).toEqual([0, 1, 2])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculates the absolute start position of every line.
|
||||
*
|
||||
* @param markdownContentLines The lines of the document
|
||||
* @returns the calculated line starts
|
||||
*/
|
||||
export const calculateLineStartIndexes = (markdownContentLines: string[]): number[] => {
|
||||
return markdownContentLines.reduce((state, line, lineIndex, lines) => {
|
||||
const lastIndex = lineIndex === 0 ? 0 : state[lineIndex - 1] + lines[lineIndex - 1].length + 1
|
||||
return [...state, lastIndex]
|
||||
}, [] as number[])
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { extractFrontmatter } from './extractor'
|
||||
import type { PresentFrontmatterExtractionResult } from './types'
|
||||
|
||||
describe('frontmatter extraction', () => {
|
||||
describe('isPresent property', () => {
|
||||
it('is false when note does not contain three dashes at all', () => {
|
||||
const testNote = ['abcdef', 'more text']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is false when note does not start with three dashes', () => {
|
||||
const testNote = ['', '---', 'this is not frontmatter']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is false when note start with less than three dashes', () => {
|
||||
const testNote = ['--', 'this is not frontmatter']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is false when note starts with three dashes but contains other characters in the same line', () => {
|
||||
const testNote = ['--- a', 'this is not frontmatter']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is false when note has no ending marker for frontmatter', () => {
|
||||
const testNote = ['---', 'this is not frontmatter', 'because', 'there is no', 'end marker']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is false when note end marker is present but with not the same amount of dashes as start marker', () => {
|
||||
const testNote = ['---', 'this is not frontmatter', '----', 'content']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(false)
|
||||
})
|
||||
it('is true when note end marker is present with the same amount of dashes as start marker', () => {
|
||||
const testNote = ['---', 'this is frontmatter', '---', 'content']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(true)
|
||||
})
|
||||
it('is true when note end marker is present with the same amount of dashes as start marker but without content', () => {
|
||||
const testNote = ['---', 'this is frontmatter', '---']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(true)
|
||||
})
|
||||
it('is true when note end marker is present with the same amount of dots as start marker', () => {
|
||||
const testNote = ['---', 'this is frontmatter', '...', 'content']
|
||||
const extraction = extractFrontmatter(testNote)
|
||||
expect(extraction.isPresent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lineOffset property', () => {
|
||||
it('is correct for single line frontmatter without content', () => {
|
||||
const testNote = ['---', 'single line frontmatter', '...']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.lineOffset).toEqual(3)
|
||||
})
|
||||
it('is correct for single line frontmatter with content', () => {
|
||||
const testNote = ['---', 'single line frontmatter', '...', 'content']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.lineOffset).toEqual(3)
|
||||
})
|
||||
it('is correct for multi-line frontmatter without content', () => {
|
||||
const testNote = ['---', 'abc', '123', 'def', '...']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.lineOffset).toEqual(5)
|
||||
})
|
||||
it('is correct for multi-line frontmatter with content', () => {
|
||||
const testNote = ['---', 'abc', '123', 'def', '...', 'content']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.lineOffset).toEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rawText property', () => {
|
||||
it('contains single-line frontmatter text', () => {
|
||||
const testNote = ['---', 'single-line', '...', 'content']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.rawText).toEqual('single-line')
|
||||
})
|
||||
it('contains multi-line frontmatter text', () => {
|
||||
const testNote = ['---', 'multi', 'line', '...', 'content']
|
||||
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
|
||||
expect(extraction.rawText).toEqual('multi\nline')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { FrontmatterExtractionResult } from './types'
|
||||
|
||||
const FRONTMATTER_BEGIN_REGEX = /^-{3,}$/
|
||||
const FRONTMATTER_END_REGEX = /^(?:-{3,}|\.{3,})$/
|
||||
|
||||
/**
|
||||
* Extracts a frontmatter block from a given multiline string.
|
||||
* A valid frontmatter block requires the content to start with a line containing at least three dashes.
|
||||
* The block is terminated by a line containing the same amount of dashes or dots as the first line.
|
||||
* @param lines The lines from which the frontmatter should be extracted.
|
||||
* @return { isPresent } false if no frontmatter block could be found, true if a block was found.
|
||||
* { rawFrontmatterText } if a block was found, this property contains the extracted text without the fencing.
|
||||
* { frontmatterLines } if a block was found, this property contains the number of lines to skip from the
|
||||
* given multiline string for retrieving the non-frontmatter content.
|
||||
*/
|
||||
export const extractFrontmatter = (lines: string[]): FrontmatterExtractionResult => {
|
||||
if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) {
|
||||
return {
|
||||
isPresent: false
|
||||
}
|
||||
}
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].length === lines[0].length && FRONTMATTER_END_REGEX.test(lines[i])) {
|
||||
return {
|
||||
isPresent: true,
|
||||
rawText: lines.slice(1, i).join('\n'),
|
||||
lineOffset: i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
isPresent: false
|
||||
}
|
||||
}
|
17
frontend/src/redux/note-details/frontmatter-extractor/types.d.ts
vendored
Normal file
17
frontend/src/redux/note-details/frontmatter-extractor/types.d.ts
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type FrontmatterExtractionResult = PresentFrontmatterExtractionResult | NonPresentFrontmatterExtractionResult
|
||||
|
||||
export interface PresentFrontmatterExtractionResult {
|
||||
isPresent: true
|
||||
rawText: string
|
||||
lineOffset: number
|
||||
}
|
||||
|
||||
interface NonPresentFrontmatterExtractionResult {
|
||||
isPresent: false
|
||||
}
|
31
frontend/src/redux/note-details/generate-note-title.test.ts
Normal file
31
frontend/src/redux/note-details/generate-note-title.test.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { generateNoteTitle } from './generate-note-title'
|
||||
import { initialState } from './initial-state'
|
||||
|
||||
describe('generate note title', () => {
|
||||
it('will choose the frontmatter title first', () => {
|
||||
const actual = generateNoteTitle(
|
||||
{ ...initialState.frontmatter, title: 'frontmatter', opengraph: { title: 'opengraph' } },
|
||||
'first-heading'
|
||||
)
|
||||
expect(actual).toEqual('frontmatter')
|
||||
})
|
||||
|
||||
it('will choose the opengraph title second', () => {
|
||||
const actual = generateNoteTitle(
|
||||
{ ...initialState.frontmatter, opengraph: { title: 'opengraph' } },
|
||||
'first-heading'
|
||||
)
|
||||
expect(actual).toEqual('opengraph')
|
||||
})
|
||||
|
||||
it('will choose the first heading third', () => {
|
||||
const actual = generateNoteTitle({ ...initialState.frontmatter }, 'first-heading')
|
||||
expect(actual).toEqual('first-heading')
|
||||
})
|
||||
})
|
28
frontend/src/redux/note-details/generate-note-title.ts
Normal file
28
frontend/src/redux/note-details/generate-note-title.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteFrontmatter } from './types/note-details'
|
||||
|
||||
/**
|
||||
* Generates the note title from the given frontmatter or the first heading in the markdown content.
|
||||
*
|
||||
* @param frontmatter The frontmatter of the note
|
||||
* @param firstHeading The first heading in the markdown content
|
||||
* @return The title from the frontmatter or, if no title is present in the frontmatter, the first heading.
|
||||
*/
|
||||
export const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string): string => {
|
||||
if (frontmatter?.title && frontmatter?.title !== '') {
|
||||
return frontmatter.title.trim()
|
||||
} else if (
|
||||
frontmatter?.opengraph &&
|
||||
frontmatter?.opengraph.title !== undefined &&
|
||||
frontmatter?.opengraph.title !== ''
|
||||
) {
|
||||
return (frontmatter?.opengraph.title ?? firstHeading ?? '').trim()
|
||||
} else {
|
||||
return (firstHeading ?? '').trim()
|
||||
}
|
||||
}
|
62
frontend/src/redux/note-details/initial-state.ts
Normal file
62
frontend/src/redux/note-details/initial-state.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from './types/note-details'
|
||||
import { NoteTextDirection, NoteType } from './types/note-details'
|
||||
import type { SlideOptions } from './types/slide-show-options'
|
||||
|
||||
export const initialSlideOptions: SlideOptions = {
|
||||
transition: 'zoom',
|
||||
autoSlide: 0,
|
||||
autoSlideStoppable: true,
|
||||
backgroundTransition: 'fade',
|
||||
slideNumber: false
|
||||
}
|
||||
|
||||
export const initialState: NoteDetails = {
|
||||
updateUsername: null,
|
||||
version: 0,
|
||||
markdownContent: {
|
||||
plain: '',
|
||||
lines: [],
|
||||
lineStartIndexes: []
|
||||
},
|
||||
selection: { from: 0 },
|
||||
rawFrontmatter: '',
|
||||
frontmatterRendererInfo: {
|
||||
frontmatterInvalid: false,
|
||||
lineOffset: 0,
|
||||
slideOptions: initialSlideOptions
|
||||
},
|
||||
id: '',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
aliases: [],
|
||||
primaryAddress: '',
|
||||
permissions: {
|
||||
owner: null,
|
||||
sharedToGroups: [],
|
||||
sharedToUsers: []
|
||||
},
|
||||
viewCount: 0,
|
||||
editedBy: [],
|
||||
title: '',
|
||||
firstHeading: '',
|
||||
frontmatter: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
robots: '',
|
||||
lang: 'en',
|
||||
dir: NoteTextDirection.LTR,
|
||||
newlinesAreBreaks: true,
|
||||
GA: '',
|
||||
disqus: '',
|
||||
type: NoteType.DOCUMENT,
|
||||
opengraph: {},
|
||||
slideOptions: initialSlideOptions
|
||||
}
|
||||
}
|
81
frontend/src/redux/note-details/methods.ts
Normal file
81
frontend/src/redux/note-details/methods.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { Note, NotePermissions } from '../../api/notes/types'
|
||||
import type {
|
||||
SetNoteDetailsFromServerAction,
|
||||
SetNoteDocumentContentAction,
|
||||
SetNotePermissionsFromServerAction,
|
||||
UpdateCursorPositionAction,
|
||||
UpdateMetadataAction,
|
||||
UpdateNoteTitleByFirstHeadingAction
|
||||
} from './types'
|
||||
import { NoteDetailsActionType } from './types'
|
||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
import { getNoteMetadata } from '../../api/notes'
|
||||
|
||||
/**
|
||||
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
||||
* @param content The note content as it is written inside the editor pane.
|
||||
*/
|
||||
export const setNoteContent = (content: string): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.SET_DOCUMENT_CONTENT,
|
||||
content: content
|
||||
} as SetNoteDocumentContentAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the note metadata for the current note from an API response DTO to the redux.
|
||||
* @param apiResponse The NoteDTO received from the API to store into redux.
|
||||
*/
|
||||
export const setNoteDataFromServer = (apiResponse: Note): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER,
|
||||
noteFromServer: apiResponse
|
||||
} as SetNoteDetailsFromServerAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the note permissions for the current note from an API response DTO to the redux.
|
||||
* @param apiResponse The NotePermissionsDTO received from the API to store into redux.
|
||||
*/
|
||||
export const setNotePermissionsFromServer = (apiResponse: NotePermissions): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER,
|
||||
notePermissionsFromServer: apiResponse
|
||||
} as SetNotePermissionsFromServerAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the note title in the redux by the first heading found in the markdown content.
|
||||
* @param firstHeading The content of the first heading found in the markdown content.
|
||||
*/
|
||||
export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING,
|
||||
firstHeading: firstHeading
|
||||
} as UpdateNoteTitleByFirstHeadingAction)
|
||||
}
|
||||
|
||||
export const updateCursorPositions = (selection: CursorSelection): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION,
|
||||
selection
|
||||
} as UpdateCursorPositionAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current note's metadata from the server.
|
||||
*/
|
||||
export const updateMetadata = async (): Promise<void> => {
|
||||
const updatedMetadata = await getNoteMetadata(store.getState().noteDetails.id)
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.UPDATE_METADATA,
|
||||
updatedMetadata
|
||||
} as UpdateMetadataAction)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createNoteFrontmatterFromYaml } from './parser'
|
||||
|
||||
describe('yaml frontmatter', () => {
|
||||
it('should parse "title"', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('title: test')
|
||||
expect(noteFrontmatter.title).toEqual('test')
|
||||
})
|
||||
|
||||
it('should parse "robots"', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('robots: index, follow')
|
||||
expect(noteFrontmatter.robots).toEqual('index, follow')
|
||||
})
|
||||
|
||||
it('should parse the deprecated tags syntax', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('tags: test123, abc')
|
||||
expect(noteFrontmatter.tags).toEqual(['test123', 'abc'])
|
||||
})
|
||||
|
||||
it('should parse the tags list syntax', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml(`tags:
|
||||
- test123
|
||||
- abc
|
||||
`)
|
||||
expect(noteFrontmatter.tags).toEqual(['test123', 'abc'])
|
||||
})
|
||||
|
||||
it('should parse the tag inline-list syntax', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml("tags: ['test123', 'abc']")
|
||||
expect(noteFrontmatter.tags).toEqual(['test123', 'abc'])
|
||||
})
|
||||
|
||||
it('should parse "breaks"', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('breaks: false')
|
||||
expect(noteFrontmatter.newlinesAreBreaks).toEqual(false)
|
||||
})
|
||||
|
||||
it('should parse an empty opengraph object', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:')
|
||||
expect(noteFrontmatter.opengraph).toEqual({})
|
||||
})
|
||||
|
||||
it('should parse an opengraph title', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph:
|
||||
title: Testtitle
|
||||
`)
|
||||
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
|
||||
})
|
||||
|
||||
it('should parse multiple opengraph values', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph:
|
||||
title: Testtitle
|
||||
image: https://dummyimage.com/48.png
|
||||
image:type: image/png
|
||||
`)
|
||||
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
|
||||
expect(noteFrontmatter.opengraph.image).toEqual('https://dummyimage.com/48.png')
|
||||
expect(noteFrontmatter.opengraph['image:type']).toEqual('image/png')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { load } from 'js-yaml'
|
||||
import type { SlideOptions } from '../types/slide-show-options'
|
||||
import type { Iso6391Language, NoteFrontmatter, OpenGraph } from '../types/note-details'
|
||||
import { NoteTextDirection, NoteType } from '../types/note-details'
|
||||
import { ISO6391 } from '../types/iso6391'
|
||||
import type { RawNoteFrontmatter } from './types'
|
||||
import { initialSlideOptions, initialState } from '../initial-state'
|
||||
|
||||
/**
|
||||
* Creates a new frontmatter metadata instance based on a raw yaml string.
|
||||
* @param rawYaml The frontmatter content in yaml format.
|
||||
* @throws Error when the content string is invalid yaml.
|
||||
* @return Frontmatter metadata instance containing the parsed properties from the yaml content.
|
||||
*/
|
||||
export const createNoteFrontmatterFromYaml = (rawYaml: string): NoteFrontmatter => {
|
||||
const rawNoteFrontmatter = load(rawYaml) as RawNoteFrontmatter
|
||||
return parseRawNoteFrontmatter(rawNoteFrontmatter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new frontmatter metadata instance based on the given raw metadata properties.
|
||||
* @param rawData A {@link RawNoteFrontmatter} object containing the properties of the parsed yaml frontmatter.
|
||||
*/
|
||||
const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontmatter => {
|
||||
let tags: string[]
|
||||
if (typeof rawData?.tags === 'string') {
|
||||
tags = rawData?.tags?.split(',').map((entry) => entry.trim()) ?? []
|
||||
} else if (typeof rawData?.tags === 'object') {
|
||||
tags = rawData?.tags?.filter((tag) => tag !== null) ?? []
|
||||
} else {
|
||||
tags = [...initialState.frontmatter.tags]
|
||||
}
|
||||
|
||||
return {
|
||||
title: rawData.title ?? initialState.frontmatter.title,
|
||||
description: rawData.description ?? initialState.frontmatter.description,
|
||||
robots: rawData.robots ?? initialState.frontmatter.robots,
|
||||
newlinesAreBreaks: rawData.breaks ?? initialState.frontmatter.newlinesAreBreaks,
|
||||
GA: rawData.GA ?? initialState.frontmatter.GA,
|
||||
disqus: rawData.disqus ?? initialState.frontmatter.disqus,
|
||||
lang: parseLanguage(rawData),
|
||||
type: parseNoteType(rawData),
|
||||
dir: parseTextDirection(rawData),
|
||||
opengraph: parseOpenGraph(rawData),
|
||||
slideOptions: parseSlideOptions(rawData),
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link OpenGraph open graph} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link OpenGraph open graph}
|
||||
*/
|
||||
const parseOpenGraph = (rawData: RawNoteFrontmatter): OpenGraph => {
|
||||
return { ...(rawData.opengraph ?? initialState.frontmatter.opengraph) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link Iso6391Language iso 6391 language code} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link Iso6391Language iso 6391 language code}
|
||||
*/
|
||||
const parseLanguage = (rawData: RawNoteFrontmatter): Iso6391Language => {
|
||||
return (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? initialState.frontmatter.lang
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link NoteType note type} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link NoteType note type}
|
||||
*/
|
||||
const parseNoteType = (rawData: RawNoteFrontmatter): NoteType => {
|
||||
return rawData.type !== undefined
|
||||
? rawData.type === NoteType.SLIDE
|
||||
? NoteType.SLIDE
|
||||
: NoteType.DOCUMENT
|
||||
: initialState.frontmatter.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link NoteTextDirection note text direction} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link NoteTextDirection note text direction}
|
||||
*/
|
||||
const parseTextDirection = (rawData: RawNoteFrontmatter): NoteTextDirection => {
|
||||
return rawData.dir !== undefined
|
||||
? rawData.dir === NoteTextDirection.LTR
|
||||
? NoteTextDirection.LTR
|
||||
: NoteTextDirection.RTL
|
||||
: initialState.frontmatter.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link SlideOptions} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed slide options
|
||||
*/
|
||||
const parseSlideOptions = (rawData: RawNoteFrontmatter): SlideOptions => {
|
||||
const rawSlideOptions = rawData?.slideOptions
|
||||
return {
|
||||
autoSlide: parseNumber(rawSlideOptions?.autoSlide) ?? initialSlideOptions.autoSlide,
|
||||
transition: rawSlideOptions?.transition ?? initialSlideOptions.transition,
|
||||
backgroundTransition: rawSlideOptions?.backgroundTransition ?? initialSlideOptions.backgroundTransition,
|
||||
autoSlideStoppable: parseBoolean(rawSlideOptions?.autoSlideStoppable) ?? initialSlideOptions.autoSlideStoppable,
|
||||
slideNumber: parseBoolean(rawSlideOptions?.slideNumber) ?? initialSlideOptions.slideNumber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an unknown variable into a boolean.
|
||||
*
|
||||
* @param rawData The raw data
|
||||
* @return The parsed boolean or undefined if it's not possible to parse the data.
|
||||
*/
|
||||
const parseBoolean = (rawData: unknown | undefined): boolean | undefined => {
|
||||
return rawData === undefined ? undefined : rawData === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an unknown variable into a number.
|
||||
*
|
||||
* @param rawData The raw data
|
||||
* @return The parsed number or undefined if it's not possible to parse the data.
|
||||
*/
|
||||
const parseNumber = (rawData: unknown | undefined): number | undefined => {
|
||||
if (rawData === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const numValue = Number(rawData)
|
||||
return isNaN(numValue) ? undefined : numValue
|
||||
}
|
20
frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts
vendored
Normal file
20
frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface RawNoteFrontmatter {
|
||||
title: string | undefined
|
||||
description: string | undefined
|
||||
tags: string | number | string[] | undefined
|
||||
robots: string | undefined
|
||||
lang: string | undefined
|
||||
dir: string | undefined
|
||||
breaks: boolean | undefined
|
||||
GA: string | undefined
|
||||
disqus: string | undefined
|
||||
type: string | undefined
|
||||
slideOptions: { [key: string]: string } | null
|
||||
opengraph: { [key: string]: string } | null
|
||||
}
|
39
frontend/src/redux/note-details/reducer.ts
Normal file
39
frontend/src/redux/note-details/reducer.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import type { NoteDetailsActions } from './types'
|
||||
import { NoteDetailsActionType } from './types'
|
||||
import { initialState } from './initial-state'
|
||||
import type { NoteDetails } from './types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated-markdown-content'
|
||||
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
|
||||
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
|
||||
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
|
||||
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
|
||||
import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update'
|
||||
|
||||
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||
state: NoteDetails = initialState,
|
||||
action: NoteDetailsActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case NoteDetailsActionType.UPDATE_CURSOR_POSITION:
|
||||
return buildStateFromUpdateCursorPosition(state, action.selection)
|
||||
case NoteDetailsActionType.SET_DOCUMENT_CONTENT:
|
||||
return buildStateFromUpdatedMarkdownContent(state, action.content)
|
||||
case NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER:
|
||||
return buildStateFromServerPermissions(state, action.notePermissionsFromServer)
|
||||
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
|
||||
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
|
||||
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
|
||||
return buildStateFromServerDto(action.noteFromServer)
|
||||
case NoteDetailsActionType.UPDATE_METADATA:
|
||||
return buildStateFromMetadataUpdate(state, action.updatedMetadata)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { buildStateFromFirstHeadingUpdate } from './build-state-from-first-heading-update'
|
||||
import { initialState } from '../initial-state'
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
jest.mock('../generate-note-title', () => ({
|
||||
generateNoteTitle: () => 'generated title'
|
||||
}))
|
||||
|
||||
describe('build state from first heading update', () => {
|
||||
it('generates a new state with the given first heading', () => {
|
||||
const startState = { ...initialState, firstHeading: 'heading', title: 'noteTitle' }
|
||||
const actual = buildStateFromFirstHeadingUpdate(startState, 'new first heading')
|
||||
expect(actual).toStrictEqual({ ...initialState, firstHeading: 'new first heading', title: 'generated title' })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { generateNoteTitle } from '../generate-note-title'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with an updated note title from frontmatter data and the first heading.
|
||||
* @param state The previous redux state.
|
||||
* @param firstHeading The first heading of the document. Should be {@link undefined} if there is no such heading.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
firstHeading: firstHeading,
|
||||
title: generateNoteTitle(state.frontmatter, firstHeading)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { initialState } from '../initial-state'
|
||||
import type { NoteMetadata } from '../../../api/notes/types'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromMetadataUpdate } from './build-state-from-metadata-update'
|
||||
|
||||
describe('build state from server permissions', () => {
|
||||
it('creates a new state with the given permissions', () => {
|
||||
const state: NoteDetails = { ...initialState }
|
||||
const metadata: NoteMetadata = {
|
||||
updateUsername: 'test',
|
||||
permissions: {
|
||||
owner: null,
|
||||
sharedToGroups: [],
|
||||
sharedToUsers: []
|
||||
},
|
||||
editedBy: [],
|
||||
primaryAddress: 'test-id',
|
||||
tags: ['test'],
|
||||
description: 'test',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: '2022-09-18T18:51:00.000+02:00',
|
||||
updatedAt: '2022-09-18T18:52:00.000+02:00'
|
||||
}
|
||||
expect(buildStateFromMetadataUpdate(state, metadata)).toStrictEqual({
|
||||
...state,
|
||||
updateUsername: 'test',
|
||||
permissions: {
|
||||
owner: null,
|
||||
sharedToGroups: [],
|
||||
sharedToUsers: []
|
||||
},
|
||||
editedBy: [],
|
||||
primaryAddress: 'test-id',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: 1663519860,
|
||||
updatedAt: 1663519920
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteMetadata } from '../../../api/notes/types'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a note metadata DTO received from the HTTP API.
|
||||
* @param state The previous state to update.
|
||||
* @param noteMetadata The updated metadata from the API.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromMetadataUpdate = (state: NoteDetails, noteMetadata: NoteMetadata): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
updateUsername: noteMetadata.updateUsername,
|
||||
permissions: noteMetadata.permissions,
|
||||
editedBy: noteMetadata.editedBy,
|
||||
primaryAddress: noteMetadata.primaryAddress,
|
||||
id: noteMetadata.id,
|
||||
aliases: noteMetadata.aliases,
|
||||
title: noteMetadata.title,
|
||||
version: noteMetadata.version,
|
||||
viewCount: noteMetadata.viewCount,
|
||||
createdAt: DateTime.fromISO(noteMetadata.createdAt).toSeconds(),
|
||||
updatedAt: DateTime.fromISO(noteMetadata.updatedAt).toSeconds()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { initialState } from '../initial-state'
|
||||
import type { NotePermissions } from '../../../api/notes/types'
|
||||
import { buildStateFromServerPermissions } from './build-state-from-server-permissions'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
|
||||
describe('build state from server permissions', () => {
|
||||
it('creates a new state with the given permissions', () => {
|
||||
const state: NoteDetails = { ...initialState }
|
||||
const permissions: NotePermissions = {
|
||||
owner: 'test-owner',
|
||||
sharedToUsers: [
|
||||
{
|
||||
username: 'test-user',
|
||||
canEdit: true
|
||||
}
|
||||
],
|
||||
sharedToGroups: [
|
||||
{
|
||||
groupName: 'test-group',
|
||||
canEdit: false
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(buildStateFromServerPermissions(state, permissions)).toStrictEqual({ ...state, permissions: permissions })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import type { NotePermissions } from '../../../api/notes/types'
|
||||
|
||||
/**
|
||||
* Builds the updated state from a given previous state and updated NotePermissions data.
|
||||
* @param state The previous note details state.
|
||||
* @param serverPermissions The updated NotePermissions data.
|
||||
*/
|
||||
export const buildStateFromServerPermissions = (
|
||||
state: NoteDetails,
|
||||
serverPermissions: NotePermissions
|
||||
): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
permissions: serverPermissions
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { buildStateFromServerDto } from './build-state-from-set-note-data-from-server'
|
||||
import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { NoteTextDirection, NoteType } from '../types/note-details'
|
||||
import { DateTime } from 'luxon'
|
||||
import { initialSlideOptions } from '../initial-state'
|
||||
import type { Note } from '../../../api/notes/types'
|
||||
|
||||
jest.mock('../build-state-from-updated-markdown-content')
|
||||
|
||||
describe('build state from set note data from server', () => {
|
||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentModule,
|
||||
'buildStateFromUpdatedMarkdownContent'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
||||
})
|
||||
|
||||
it('builds a new state from the given note dto', () => {
|
||||
const noteDto: Note = {
|
||||
content: 'line1\nline2',
|
||||
metadata: {
|
||||
primaryAddress: 'alias',
|
||||
version: 5678,
|
||||
aliases: [
|
||||
{
|
||||
noteId: 'id',
|
||||
primaryAlias: true,
|
||||
name: 'alias'
|
||||
}
|
||||
],
|
||||
id: 'id',
|
||||
createdAt: '2012-05-25T09:08:34.123',
|
||||
description: 'description',
|
||||
editedBy: ['editedBy'],
|
||||
permissions: {
|
||||
owner: 'username',
|
||||
sharedToGroups: [
|
||||
{
|
||||
canEdit: true,
|
||||
groupName: 'groupName'
|
||||
}
|
||||
],
|
||||
sharedToUsers: [
|
||||
{
|
||||
canEdit: true,
|
||||
username: 'shareusername'
|
||||
}
|
||||
]
|
||||
},
|
||||
viewCount: 987,
|
||||
tags: ['tag'],
|
||||
title: 'title',
|
||||
updatedAt: '2020-05-25T09:08:34.123',
|
||||
updateUsername: 'updateusername'
|
||||
},
|
||||
editedByAtPosition: [
|
||||
{
|
||||
endPos: 5,
|
||||
createdAt: 'createdAt',
|
||||
startPos: 9,
|
||||
updatedAt: 'updatedAt',
|
||||
username: 'userName'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const convertedNoteDetails: NoteDetails = {
|
||||
frontmatter: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
robots: '',
|
||||
lang: 'en',
|
||||
dir: NoteTextDirection.LTR,
|
||||
newlinesAreBreaks: true,
|
||||
GA: '',
|
||||
disqus: '',
|
||||
type: NoteType.DOCUMENT,
|
||||
opengraph: {},
|
||||
slideOptions: {
|
||||
transition: 'zoom',
|
||||
autoSlide: 0,
|
||||
autoSlideStoppable: true,
|
||||
backgroundTransition: 'fade',
|
||||
slideNumber: false
|
||||
}
|
||||
},
|
||||
frontmatterRendererInfo: {
|
||||
frontmatterInvalid: false,
|
||||
lineOffset: 0,
|
||||
slideOptions: initialSlideOptions
|
||||
},
|
||||
title: 'title',
|
||||
selection: { from: 0 },
|
||||
markdownContent: {
|
||||
plain: 'line1\nline2',
|
||||
lines: ['line1', 'line2'],
|
||||
lineStartIndexes: [0, 6]
|
||||
},
|
||||
firstHeading: '',
|
||||
rawFrontmatter: '',
|
||||
id: 'id',
|
||||
createdAt: DateTime.fromISO('2012-05-25T09:08:34.123').toSeconds(),
|
||||
updatedAt: DateTime.fromISO('2020-05-25T09:08:34.123').toSeconds(),
|
||||
updateUsername: 'updateusername',
|
||||
viewCount: 987,
|
||||
aliases: [
|
||||
{
|
||||
name: 'alias',
|
||||
noteId: 'id',
|
||||
primaryAlias: true
|
||||
}
|
||||
],
|
||||
primaryAddress: 'alias',
|
||||
version: 5678,
|
||||
editedBy: ['editedBy'],
|
||||
permissions: {
|
||||
owner: 'username',
|
||||
sharedToGroups: [
|
||||
{
|
||||
canEdit: true,
|
||||
groupName: 'groupName'
|
||||
}
|
||||
],
|
||||
sharedToUsers: [
|
||||
{
|
||||
canEdit: true,
|
||||
username: 'shareusername'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const result = buildStateFromServerDto(noteDto)
|
||||
expect(result).toEqual(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(convertedNoteDetails, 'line1\nline2')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
||||
import { initialState } from '../initial-state'
|
||||
import { calculateLineStartIndexes } from '../calculate-line-start-indexes'
|
||||
import type { Note } from '../../../api/notes/types'
|
||||
import { buildStateFromMetadataUpdate } from './build-state-from-metadata-update'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||
* @param dto The first DTO received from the API containing the relevant information about the note.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromServerDto = (dto: Note): NoteDetails => {
|
||||
const newState = convertNoteDtoToNoteDetails(dto)
|
||||
return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent.plain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a note DTO from the HTTP API to a {@link NoteDetails} object.
|
||||
* Note that the documentContent will be set but the markdownContent and rawFrontmatterContent are yet to be processed.
|
||||
* @param note The NoteDTO as defined in the backend.
|
||||
* @return The NoteDetails object corresponding to the DTO.
|
||||
*/
|
||||
const convertNoteDtoToNoteDetails = (note: Note): NoteDetails => {
|
||||
const stateWithMetadata = buildStateFromMetadataUpdate(initialState, note.metadata)
|
||||
const newLines = note.content.split('\n')
|
||||
return {
|
||||
...stateWithMetadata,
|
||||
markdownContent: {
|
||||
plain: note.content,
|
||||
lines: newLines,
|
||||
lineStartIndexes: calculateLineStartIndexes(newLines)
|
||||
},
|
||||
rawFrontmatter: ''
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { initialState } from '../initial-state'
|
||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromTaskListUpdate } from './build-state-from-task-list-update'
|
||||
|
||||
jest.mock('../build-state-from-updated-markdown-content')
|
||||
|
||||
describe('build state from task list update', () => {
|
||||
const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
||||
'buildStateFromUpdatedMarkdownContentLines'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
|
||||
})
|
||||
|
||||
const markdownContentLines = ['no task', '- [ ] not checked', '- [x] checked']
|
||||
|
||||
it(`doesn't change the state if the line doesn't contain a task`, () => {
|
||||
const startState: NoteDetails = {
|
||||
...initialState,
|
||||
markdownContent: { ...initialState.markdownContent, lines: markdownContentLines }
|
||||
}
|
||||
const result = buildStateFromTaskListUpdate(startState, 0, true)
|
||||
expect(result).toBe(startState)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it(`can change the state of a task to checked`, () => {
|
||||
const startState: NoteDetails = {
|
||||
...initialState,
|
||||
markdownContent: { ...initialState.markdownContent, lines: markdownContentLines }
|
||||
}
|
||||
const result = buildStateFromTaskListUpdate(startState, 1, true)
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
|
||||
'no task',
|
||||
'- [x] not checked',
|
||||
'- [x] checked'
|
||||
])
|
||||
})
|
||||
|
||||
it(`can change the state of a task to unchecked`, () => {
|
||||
const startState: NoteDetails = {
|
||||
...initialState,
|
||||
markdownContent: { ...initialState.markdownContent, lines: markdownContentLines }
|
||||
}
|
||||
const result = buildStateFromTaskListUpdate(startState, 2, false)
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
|
||||
'no task',
|
||||
'- [ ] not checked',
|
||||
'- [ ] checked'
|
||||
])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
|
||||
|
||||
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )\[[ xX]?]( .*)/
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state where a checkbox in the markdown content either gets checked or unchecked.
|
||||
* @param state The previous redux state.
|
||||
* @param changedLineIndex The number of the line in which the checkbox should be updated.
|
||||
* @param checkboxChecked true if the checkbox should be checked, false otherwise.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromTaskListUpdate = (
|
||||
state: NoteDetails,
|
||||
changedLineIndex: number,
|
||||
checkboxChecked: boolean
|
||||
): NoteDetails => {
|
||||
const lines = [...state.markdownContent.lines]
|
||||
return Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex]))
|
||||
.map((results) => {
|
||||
const [, beforeCheckbox, afterCheckbox] = results
|
||||
lines[changedLineIndex] = `${beforeCheckbox}[${checkboxChecked ? 'x' : ' '}]${afterCheckbox}`
|
||||
return buildStateFromUpdatedMarkdownContentLines(state, lines)
|
||||
})
|
||||
.orElse(state)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { initialState } from '../initial-state'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import { buildStateFromUpdateCursorPosition } from './build-state-from-update-cursor-position'
|
||||
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
|
||||
describe('build state from update cursor position', () => {
|
||||
it('creates a new state with the given cursor', () => {
|
||||
const state = { ...initialState }
|
||||
const selection: CursorSelection = Mock.of<CursorSelection>()
|
||||
expect(buildStateFromUpdateCursorPosition(state, selection)).toStrictEqual({ ...state, selection })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
|
||||
export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => {
|
||||
const correctedSelection = isFromAfterTo(selection)
|
||||
? {
|
||||
to: selection.from,
|
||||
from: selection.to as number
|
||||
}
|
||||
: selection
|
||||
|
||||
return {
|
||||
...state,
|
||||
selection: correctedSelection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the from-cursor position in the given selection is after the to -cursor position.
|
||||
*
|
||||
* @param selection The cursor selection to check
|
||||
* @return {@link true} if the from-cursor position is after the to position
|
||||
*/
|
||||
const isFromAfterTo = (selection: CursorSelection): boolean => {
|
||||
if (selection.to === undefined) {
|
||||
return false
|
||||
}
|
||||
return selection.from > selection.to
|
||||
}
|
71
frontend/src/redux/note-details/types.ts
Normal file
71
frontend/src/redux/note-details/types.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
import type { Note, NoteMetadata, NotePermissions } from '../../api/notes/types'
|
||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
|
||||
export enum NoteDetailsActionType {
|
||||
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
||||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set',
|
||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition',
|
||||
UPDATE_METADATA = 'note-details/update-metadata'
|
||||
}
|
||||
|
||||
export type NoteDetailsActions =
|
||||
| SetNoteDocumentContentAction
|
||||
| SetNoteDetailsFromServerAction
|
||||
| SetNotePermissionsFromServerAction
|
||||
| UpdateNoteTitleByFirstHeadingAction
|
||||
| UpdateCursorPositionAction
|
||||
| UpdateMetadataAction
|
||||
|
||||
/**
|
||||
* Action for updating the document content of the currently loaded note.
|
||||
*/
|
||||
export interface SetNoteDocumentContentAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.SET_DOCUMENT_CONTENT
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for overwriting the current state with the data received from the API.
|
||||
*/
|
||||
export interface SetNoteDetailsFromServerAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER
|
||||
noteFromServer: Note
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for overwriting the current permission state with the data received from the API.
|
||||
*/
|
||||
export interface SetNotePermissionsFromServerAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER
|
||||
notePermissionsFromServer: NotePermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for updating the note title of the currently loaded note by using frontmatter data or the first heading.
|
||||
*/
|
||||
export interface UpdateNoteTitleByFirstHeadingAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING
|
||||
firstHeading?: string
|
||||
}
|
||||
|
||||
export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
|
||||
selection: CursorSelection
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for updating the metadata of the current note.
|
||||
*/
|
||||
export interface UpdateMetadataAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.UPDATE_METADATA
|
||||
updatedMetadata: NoteMetadata
|
||||
}
|
210
frontend/src/redux/note-details/types/iso6391.ts
Normal file
210
frontend/src/redux/note-details/types/iso6391.ts
Normal file
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const ISO6391 = [
|
||||
'aa',
|
||||
'ab',
|
||||
'af',
|
||||
'am',
|
||||
'ar',
|
||||
'ar-ae',
|
||||
'ar-bh',
|
||||
'ar-dz',
|
||||
'ar-eg',
|
||||
'ar-iq',
|
||||
'ar-jo',
|
||||
'ar-kw',
|
||||
'ar-lb',
|
||||
'ar-ly',
|
||||
'ar-ma',
|
||||
'ar-om',
|
||||
'ar-qa',
|
||||
'ar-sa',
|
||||
'ar-sy',
|
||||
'ar-tn',
|
||||
'ar-ye',
|
||||
'as',
|
||||
'ay',
|
||||
'de-at',
|
||||
'de-ch',
|
||||
'de-li',
|
||||
'de-lu',
|
||||
'div',
|
||||
'dz',
|
||||
'el',
|
||||
'en',
|
||||
'en-au',
|
||||
'en-bz',
|
||||
'en-ca',
|
||||
'en-gb',
|
||||
'en-ie',
|
||||
'en-jm',
|
||||
'en-nz',
|
||||
'en-ph',
|
||||
'en-tt',
|
||||
'en-us',
|
||||
'en-za',
|
||||
'en-zw',
|
||||
'eo',
|
||||
'es',
|
||||
'es-ar',
|
||||
'es-bo',
|
||||
'es-cl',
|
||||
'es-co',
|
||||
'es-cr',
|
||||
'es-do',
|
||||
'es-ec',
|
||||
'es-es',
|
||||
'es-gt',
|
||||
'es-hn',
|
||||
'es-mx',
|
||||
'es-ni',
|
||||
'es-pa',
|
||||
'es-pe',
|
||||
'es-pr',
|
||||
'es-py',
|
||||
'es-sv',
|
||||
'es-us',
|
||||
'es-uy',
|
||||
'es-ve',
|
||||
'et',
|
||||
'eu',
|
||||
'fa',
|
||||
'fi',
|
||||
'fj',
|
||||
'fo',
|
||||
'fr',
|
||||
'fr-be',
|
||||
'fr-ca',
|
||||
'fr-ch',
|
||||
'fr-lu',
|
||||
'fr-mc',
|
||||
'fy',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'gn',
|
||||
'gu',
|
||||
'ha',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'hy',
|
||||
'ia',
|
||||
'id',
|
||||
'ie',
|
||||
'ik',
|
||||
'in',
|
||||
'is',
|
||||
'it',
|
||||
'it-ch',
|
||||
'iw',
|
||||
'ja',
|
||||
'ji',
|
||||
'jw',
|
||||
'ka',
|
||||
'kk',
|
||||
'kl',
|
||||
'km',
|
||||
'kn',
|
||||
'ko',
|
||||
'kok',
|
||||
'ks',
|
||||
'ku',
|
||||
'ky',
|
||||
'kz',
|
||||
'la',
|
||||
'ln',
|
||||
'lo',
|
||||
'ls',
|
||||
'lt',
|
||||
'lv',
|
||||
'mg',
|
||||
'mi',
|
||||
'mk',
|
||||
'ml',
|
||||
'mn',
|
||||
'mo',
|
||||
'mr',
|
||||
'ms',
|
||||
'mt',
|
||||
'my',
|
||||
'na',
|
||||
'nb-no',
|
||||
'ne',
|
||||
'nl',
|
||||
'nl-be',
|
||||
'nn-no',
|
||||
'no',
|
||||
'oc',
|
||||
'om',
|
||||
'or',
|
||||
'pa',
|
||||
'pl',
|
||||
'ps',
|
||||
'pt',
|
||||
'pt-br',
|
||||
'qu',
|
||||
'rm',
|
||||
'rn',
|
||||
'ro',
|
||||
'ro-md',
|
||||
'ru',
|
||||
'ru-md',
|
||||
'rw',
|
||||
'sa',
|
||||
'sb',
|
||||
'sd',
|
||||
'sg',
|
||||
'sh',
|
||||
'si',
|
||||
'sk',
|
||||
'sl',
|
||||
'sm',
|
||||
'sn',
|
||||
'so',
|
||||
'sq',
|
||||
'sr',
|
||||
'ss',
|
||||
'st',
|
||||
'su',
|
||||
'sv',
|
||||
'sv-fi',
|
||||
'sw',
|
||||
'sx',
|
||||
'syr',
|
||||
'ta',
|
||||
'te',
|
||||
'tg',
|
||||
'th',
|
||||
'ti',
|
||||
'tk',
|
||||
'tl',
|
||||
'tn',
|
||||
'to',
|
||||
'tr',
|
||||
'ts',
|
||||
'tt',
|
||||
'tw',
|
||||
'uk',
|
||||
'ur',
|
||||
'us',
|
||||
'uz',
|
||||
'vi',
|
||||
'vo',
|
||||
'wo',
|
||||
'xh',
|
||||
'yi',
|
||||
'yo',
|
||||
'zh',
|
||||
'zh-cn',
|
||||
'zh-hk',
|
||||
'zh-mo',
|
||||
'zh-sg',
|
||||
'zh-tw',
|
||||
'zu'
|
||||
] as const
|
65
frontend/src/redux/note-details/types/note-details.ts
Normal file
65
frontend/src/redux/note-details/types/note-details.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { SlideOptions } from './slide-show-options'
|
||||
import type { ISO6391 } from './iso6391'
|
||||
import type { NoteMetadata } from '../../../api/notes/types'
|
||||
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
|
||||
type UnnecessaryNoteAttributes = 'updatedAt' | 'createdAt' | 'tags' | 'description'
|
||||
|
||||
/**
|
||||
* Redux state containing the currently loaded note with its content and metadata.
|
||||
*/
|
||||
export interface NoteDetails extends Omit<NoteMetadata, UnnecessaryNoteAttributes> {
|
||||
updatedAt: number
|
||||
createdAt: number
|
||||
markdownContent: {
|
||||
plain: string
|
||||
lines: string[]
|
||||
lineStartIndexes: number[]
|
||||
}
|
||||
selection: CursorSelection
|
||||
firstHeading?: string
|
||||
rawFrontmatter: string
|
||||
frontmatter: NoteFrontmatter
|
||||
frontmatterRendererInfo: RendererFrontmatterInfo
|
||||
}
|
||||
|
||||
export type Iso6391Language = typeof ISO6391[number]
|
||||
|
||||
export type OpenGraph = Record<string, string>
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
title: string
|
||||
description: string
|
||||
tags: string[]
|
||||
robots: string
|
||||
lang: Iso6391Language
|
||||
dir: NoteTextDirection
|
||||
newlinesAreBreaks: boolean
|
||||
GA: string
|
||||
disqus: string
|
||||
type: NoteType
|
||||
opengraph: OpenGraph
|
||||
slideOptions: SlideOptions
|
||||
}
|
||||
|
||||
export enum NoteTextDirection {
|
||||
LTR = 'ltr',
|
||||
RTL = 'rtl'
|
||||
}
|
||||
|
||||
export enum NoteType {
|
||||
DOCUMENT = '',
|
||||
SLIDE = 'slide'
|
||||
}
|
||||
|
||||
export interface RendererFrontmatterInfo {
|
||||
lineOffset: number
|
||||
frontmatterInvalid: boolean
|
||||
slideOptions: SlideOptions
|
||||
}
|
11
frontend/src/redux/note-details/types/slide-show-options.d.ts
vendored
Normal file
11
frontend/src/redux/note-details/types/slide-show-options.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { RevealOptions } from 'reveal.js'
|
||||
|
||||
type WantedRevealOptions = 'autoSlide' | 'autoSlideStoppable' | 'transition' | 'backgroundTransition' | 'slideNumber'
|
||||
|
||||
export type SlideOptions = Required<Pick<RevealOptions, WantedRevealOptions>>
|
37
frontend/src/redux/realtime/methods.ts
Normal file
37
frontend/src/redux/realtime/methods.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { AddOnlineUserAction, OnlineUser, RemoveOnlineUserAction } from './types'
|
||||
import { RealtimeActionType } from './types'
|
||||
|
||||
/**
|
||||
* Dispatches an event to add a user
|
||||
*
|
||||
* @param clientId The clientId of the user to add
|
||||
* @param user The user to add.
|
||||
*/
|
||||
export const addOnlineUser = (clientId: number, user: OnlineUser): void => {
|
||||
const action: AddOnlineUserAction = {
|
||||
type: RealtimeActionType.ADD_ONLINE_USER,
|
||||
clientId,
|
||||
user
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an event to remove a user from the online users list.
|
||||
*
|
||||
* @param clientId The yjs client id of the user to remove from the online users list.
|
||||
*/
|
||||
export const removeOnlineUser = (clientId: number): void => {
|
||||
const action: RemoveOnlineUserAction = {
|
||||
type: RealtimeActionType.REMOVE_ONLINE_USER,
|
||||
clientId
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
36
frontend/src/redux/realtime/reducers.ts
Normal file
36
frontend/src/redux/realtime/reducers.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { RealtimeActions, RealtimeState } from './types'
|
||||
import { RealtimeActionType } from './types'
|
||||
import type { Reducer } from 'redux'
|
||||
import { buildStateFromRemoveUser } from './reducers/build-state-from-remove-user'
|
||||
import { buildStateFromAddUser } from './reducers/build-state-from-add-user'
|
||||
|
||||
const initialState: RealtimeState = {
|
||||
users: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies {@link RealtimeReducer realtime actions} to the global application state.
|
||||
*
|
||||
* @param state the current state
|
||||
* @param action the action that should get applied
|
||||
* @return The new changed state
|
||||
*/
|
||||
export const RealtimeReducer: Reducer<RealtimeState, RealtimeActions> = (
|
||||
state = initialState,
|
||||
action: RealtimeActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case RealtimeActionType.ADD_ONLINE_USER:
|
||||
return buildStateFromAddUser(state, action.clientId, action.user)
|
||||
case RealtimeActionType.REMOVE_ONLINE_USER:
|
||||
return buildStateFromRemoveUser(state, action.clientId)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { OnlineUser, RealtimeState } from '../types'
|
||||
|
||||
/**
|
||||
* Builds a new {@link RealtimeState} with a new client id that is shown as online.
|
||||
*
|
||||
* @param oldState The old state that will be copied
|
||||
* @param clientId The identifier of the new client
|
||||
* @param user The information about the new user
|
||||
* @return the generated state
|
||||
*/
|
||||
export const buildStateFromAddUser = (oldState: RealtimeState, clientId: number, user: OnlineUser): RealtimeState => {
|
||||
return {
|
||||
users: {
|
||||
...oldState.users,
|
||||
[clientId]: user
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { RealtimeState } from '../types'
|
||||
|
||||
/**
|
||||
* Builds a new {@link RealtimeState} but removes the information about a client.
|
||||
*
|
||||
* @param oldState The old state that will be copied
|
||||
* @param clientIdToRemove The identifier of the client that should be removed
|
||||
* @return the generated state
|
||||
*/
|
||||
export const buildStateFromRemoveUser = (oldState: RealtimeState, clientIdToRemove: number): RealtimeState => {
|
||||
const newUsers = { ...oldState.users }
|
||||
delete newUsers[clientIdToRemove]
|
||||
return {
|
||||
users: newUsers
|
||||
}
|
||||
}
|
41
frontend/src/redux/realtime/types.ts
Normal file
41
frontend/src/redux/realtime/types.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
|
||||
export enum RealtimeActionType {
|
||||
ADD_ONLINE_USER = 'realtime/add-user',
|
||||
REMOVE_ONLINE_USER = 'realtime/remove-user',
|
||||
UPDATE_ONLINE_USER = 'realtime/update-user'
|
||||
}
|
||||
|
||||
export interface RealtimeState {
|
||||
users: Record<number, OnlineUser>
|
||||
}
|
||||
|
||||
export enum ActiveIndicatorStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive'
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
username: string
|
||||
color: string
|
||||
active: ActiveIndicatorStatus
|
||||
}
|
||||
|
||||
export interface AddOnlineUserAction extends Action<RealtimeActionType> {
|
||||
type: RealtimeActionType.ADD_ONLINE_USER
|
||||
clientId: number
|
||||
user: OnlineUser
|
||||
}
|
||||
|
||||
export interface RemoveOnlineUserAction extends Action<RealtimeActionType> {
|
||||
type: RealtimeActionType.REMOVE_ONLINE_USER
|
||||
clientId: number
|
||||
}
|
||||
|
||||
export type RealtimeActions = AddOnlineUserAction | RemoveOnlineUserAction
|
28
frontend/src/redux/reducers.ts
Normal file
28
frontend/src/redux/reducers.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import { combineReducers } from 'redux'
|
||||
import { UserReducer } from './user/reducers'
|
||||
import { ConfigReducer } from './config/reducers'
|
||||
import { HistoryReducer } from './history/reducers'
|
||||
import { EditorConfigReducer } from './editor/reducers'
|
||||
import { DarkModeConfigReducer } from './dark-mode/reducers'
|
||||
import { NoteDetailsReducer } from './note-details/reducer'
|
||||
import { RendererStatusReducer } from './renderer-status/reducers'
|
||||
import type { ApplicationState } from './application-state'
|
||||
import { RealtimeReducer } from './realtime/reducers'
|
||||
|
||||
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
||||
user: UserReducer,
|
||||
config: ConfigReducer,
|
||||
history: HistoryReducer,
|
||||
editorConfig: EditorConfigReducer,
|
||||
darkMode: DarkModeConfigReducer,
|
||||
noteDetails: NoteDetailsReducer,
|
||||
rendererStatus: RendererStatusReducer,
|
||||
realtime: RealtimeReducer
|
||||
})
|
22
frontend/src/redux/renderer-status/methods.ts
Normal file
22
frontend/src/redux/renderer-status/methods.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { SetRendererStatusAction } from './types'
|
||||
import { RendererStatusActionType } from './types'
|
||||
|
||||
/**
|
||||
* Dispatches a global application state change for the "renderer ready" state.
|
||||
*
|
||||
* @param rendererReady The new renderer ready state.
|
||||
*/
|
||||
export const setRendererStatus = (rendererReady: boolean): void => {
|
||||
const action: SetRendererStatusAction = {
|
||||
type: RendererStatusActionType.SET_RENDERER_STATUS,
|
||||
rendererReady
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
35
frontend/src/redux/renderer-status/reducers.ts
Normal file
35
frontend/src/redux/renderer-status/reducers.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { RendererStatus, RendererStatusActions } from './types'
|
||||
import { RendererStatusActionType } from './types'
|
||||
import type { Reducer } from 'redux'
|
||||
|
||||
const initialState: RendererStatus = {
|
||||
rendererReady: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies {@link RendererStatusActions renderer status actions} to the global application state.
|
||||
*
|
||||
* @param state the current state
|
||||
* @param action the action that should get applied
|
||||
* @return The new changed state
|
||||
*/
|
||||
export const RendererStatusReducer: Reducer<RendererStatus, RendererStatusActions> = (
|
||||
state: RendererStatus = initialState,
|
||||
action: RendererStatusActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case RendererStatusActionType.SET_RENDERER_STATUS:
|
||||
return {
|
||||
...state,
|
||||
rendererReady: action.rendererReady
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
22
frontend/src/redux/renderer-status/types.ts
Normal file
22
frontend/src/redux/renderer-status/types.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
|
||||
export enum RendererStatusActionType {
|
||||
SET_RENDERER_STATUS = 'renderer-status/set-ready'
|
||||
}
|
||||
|
||||
export interface RendererStatus {
|
||||
rendererReady: boolean
|
||||
}
|
||||
|
||||
export interface SetRendererStatusAction extends Action<RendererStatusActionType> {
|
||||
type: RendererStatusActionType.SET_RENDERER_STATUS
|
||||
rendererReady: boolean
|
||||
}
|
||||
|
||||
export type RendererStatusActions = SetRendererStatusAction
|
19
frontend/src/redux/store-provider.tsx
Normal file
19
frontend/src/redux/store-provider.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from './index'
|
||||
|
||||
/**
|
||||
* Sets the redux store for the children components.
|
||||
*
|
||||
* @param children The child components that should access the redux store
|
||||
*/
|
||||
export const StoreProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
32
frontend/src/redux/user/methods.ts
Normal file
32
frontend/src/redux/user/methods.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '..'
|
||||
import type { ClearUserAction, SetUserAction } from './types'
|
||||
import { UserActionType } from './types'
|
||||
import type { LoginUserInfo } from '../../api/me/types'
|
||||
|
||||
/**
|
||||
* Sets the given user state into the redux.
|
||||
* @param state The user state to set into the redux.
|
||||
*/
|
||||
export const setUser = (state: LoginUserInfo): void => {
|
||||
const action: SetUserAction = {
|
||||
type: UserActionType.SET_USER,
|
||||
state
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the user state from the redux.
|
||||
*/
|
||||
export const clearUser: () => void = () => {
|
||||
const action: ClearUserAction = {
|
||||
type: UserActionType.CLEAR_USER
|
||||
}
|
||||
store.dispatch(action)
|
||||
}
|
23
frontend/src/redux/user/reducers.ts
Normal file
23
frontend/src/redux/user/reducers.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import type { OptionalUserState, UserActions } from './types'
|
||||
import { UserActionType } from './types'
|
||||
|
||||
export const UserReducer: Reducer<OptionalUserState, UserActions> = (
|
||||
state: OptionalUserState = null,
|
||||
action: UserActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case UserActionType.SET_USER:
|
||||
return action.state
|
||||
case UserActionType.CLEAR_USER:
|
||||
return null
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
26
frontend/src/redux/user/types.ts
Normal file
26
frontend/src/redux/user/types.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
import type { LoginUserInfo } from '../../api/me/types'
|
||||
|
||||
export enum UserActionType {
|
||||
SET_USER = 'user/set',
|
||||
CLEAR_USER = 'user/clear'
|
||||
}
|
||||
|
||||
export type UserActions = SetUserAction | ClearUserAction
|
||||
|
||||
export interface SetUserAction extends Action<UserActionType> {
|
||||
type: UserActionType.SET_USER
|
||||
state: LoginUserInfo
|
||||
}
|
||||
|
||||
export interface ClearUserAction extends Action<UserActionType> {
|
||||
type: UserActionType.CLEAR_USER
|
||||
}
|
||||
|
||||
export type OptionalUserState = LoginUserInfo | null
|
Loading…
Add table
Add a link
Reference in a new issue