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:
Tilman Vatteroth 2022-11-11 11:16:18 +01:00
parent 4e18ce38f3
commit 762a0a850e
No known key found for this signature in database
GPG key ID: B97799103358209B
1051 changed files with 0 additions and 35 deletions

View 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
}

View 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)
}

View 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
}
}

View 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
}

View 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)
}

View 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
}
}

View 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

View 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)
}

View 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
}
}

View 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
}

View 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 []
}
}

View 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
}
}

View 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
}

View 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()

View file

@ -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
}
}
}
}

View file

@ -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])
})
})

View file

@ -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[])
}

View file

@ -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')
})
})
})

View file

@ -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
}
}

View 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
}

View 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')
})
})

View 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()
}
}

View 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
}
}

View 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)
}

View file

@ -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')
})
})

View file

@ -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
}

View 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
}

View 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
}
}

View file

@ -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' })
})
})

View file

@ -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)
}
}

View file

@ -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
})
})
})

View file

@ -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()
}
}

View file

@ -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 })
})
})

View file

@ -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
}
}

View file

@ -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')
})
})

View file

@ -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: ''
}
}

View file

@ -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'
])
})
})

View file

@ -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)
}

View file

@ -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 })
})
})

View file

@ -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
}

View 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
}

View 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

View 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
}

View 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>>

View 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)
}

View 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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View 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

View 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
})

View 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)
}

View 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
}
}

View 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

View 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>
}

View 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)
}

View 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
}
}

View 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