Move frontmatter extraction from renderer to redux (#1413)

This commit is contained in:
Erik Michelson 2021-09-02 11:15:31 +02:00 committed by GitHub
parent 7fb7c55877
commit 04e16d8880
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 680 additions and 589 deletions

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteDetails } from './types'
import { DateTime } from 'luxon'
import { NoteTextDirection, NoteType } from '../../components/common/note-frontmatter/types'
export const initialState: NoteDetails = {
documentContent: '',
markdownContent: '',
rawFrontmatter: '',
frontmatterRendererInfo: {
frontmatterInvalid: false,
deprecatedSyntax: false,
offsetLines: 0
},
id: '',
createTime: DateTime.fromSeconds(0),
lastChange: {
timestamp: DateTime.fromSeconds(0),
userName: ''
},
alias: '',
viewCount: 0,
authorship: [],
noteTitle: '',
firstHeading: '',
frontmatter: {
title: '',
description: '',
tags: [],
deprecatedTagsSyntax: false,
robots: '',
lang: 'en',
dir: NoteTextDirection.LTR,
breaks: true,
GA: '',
disqus: '',
type: NoteType.DOCUMENT,
opengraph: new Map<string, string>()
}
}

View file

@ -6,31 +6,40 @@
import { store } from '..'
import { NoteDto } from '../../api/notes/types'
import { NoteFrontmatter } from '../../components/editor-page/note-frontmatter/note-frontmatter'
import { initialState } from './reducers'
import {
NoteDetailsActionType,
SetCheckboxInMarkdownContentAction,
SetNoteDetailsAction,
SetNoteDetailsFromServerAction,
SetNoteFrontmatterFromRenderingAction,
UpdateNoteTitleByFirstHeadingAction
SetNoteDocumentContentAction,
UpdateNoteTitleByFirstHeadingAction,
UpdateTaskListCheckboxAction
} from './types'
export const setNoteMarkdownContent = (content: string): void => {
/**
* 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
} as SetNoteDetailsAction)
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: NoteDto): void => {
store.dispatch({
type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER,
note: apiResponse
dto: apiResponse
} as SetNoteDetailsFromServerAction)
}
/**
* 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,
@ -38,20 +47,15 @@ export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => {
} as UpdateNoteTitleByFirstHeadingAction)
}
export const setNoteFrontmatter = (frontmatter: NoteFrontmatter | undefined): void => {
if (!frontmatter) {
frontmatter = initialState.frontmatter
}
/**
* Changes a checkbox state in the note document content. Triggered when a checkbox in the rendering is clicked.
* @param lineInDocumentContent The line in the document content to change.
* @param checked true if the checkbox is checked, false otherwise.
*/
export const setCheckboxInMarkdownContent = (lineInDocumentContent: number, checked: boolean): void => {
store.dispatch({
type: NoteDetailsActionType.SET_NOTE_FRONTMATTER,
frontmatter: frontmatter
} as SetNoteFrontmatterFromRenderingAction)
}
export const setCheckboxInMarkdownContent = (lineInMarkdown: number, checked: boolean): void => {
store.dispatch({
type: NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT,
checked: checked,
lineInMarkdown: lineInMarkdown
} as SetCheckboxInMarkdownContentAction)
type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX,
checkboxChecked: checked,
changedLine: lineInDocumentContent
} as UpdateTaskListCheckboxAction)
}

View file

@ -0,0 +1,194 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Reducer } from 'redux'
import { PresentFrontmatterExtractionResult } from '../../components/common/note-frontmatter/types'
import { NoteFrontmatter } from '../../components/common/note-frontmatter/note-frontmatter'
import { NoteDetails, NoteDetailsActions, NoteDetailsActionType } from './types'
import { extractFrontmatter } from '../../components/common/note-frontmatter/extract-frontmatter'
import { NoteDto } from '../../api/notes/types'
import { initialState } from './initial-state'
import { DateTime } from 'luxon'
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState,
action: NoteDetailsActions
) => {
switch (action.type) {
case NoteDetailsActionType.SET_DOCUMENT_CONTENT:
return buildStateFromDocumentContentUpdate(state, action.content)
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
return buildStateFromServerDto(action.dto)
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
default:
return state
}
}
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/
/**
* 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.
*/
const buildStateFromServerDto = (dto: NoteDto): NoteDetails => {
const newState = convertNoteDtoToNoteDetails(dto)
return buildStateFromDocumentContentUpdate(newState, newState.documentContent)
}
/**
* 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 changedLine 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.
*/
const buildStateFromTaskListUpdate = (
state: NoteDetails,
changedLine: number,
checkboxChecked: boolean
): NoteDetails => {
const lines = state.documentContent.split('\n')
const results = TASK_REGEX.exec(lines[changedLine])
if (results) {
const before = results[1]
const after = results[3]
lines[changedLine] = `${before}[${checkboxChecked ? 'x' : ' '}]${after}`
return buildStateFromDocumentContentUpdate(state, lines.join('\n'))
}
return state
}
/**
* Builds a {@link NoteDetails} redux state from a fresh document content.
* @param state The previous redux state.
* @param documentContent The fresh document content consisting of the frontmatter and markdown part.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromDocumentContentUpdate = (state: NoteDetails, documentContent: string): NoteDetails => {
const frontmatterExtraction = extractFrontmatter(documentContent)
if (!frontmatterExtraction.frontmatterPresent) {
return {
...state,
documentContent: documentContent,
markdownContent: documentContent,
rawFrontmatter: '',
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: initialState.frontmatterRendererInfo
}
}
return buildStateFromFrontmatterUpdate(
{
...state,
documentContent: documentContent,
markdownContent: documentContent.split('\n').slice(frontmatterExtraction.frontmatterLines).join('\n')
},
frontmatterExtraction
)
}
/**
* 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.rawFrontmatterText === state.rawFrontmatter) {
return state
}
try {
const frontmatter = NoteFrontmatter.createFromYaml(frontmatterExtraction.rawFrontmatterText)
return {
...state,
rawFrontmatter: frontmatterExtraction.rawFrontmatterText,
frontmatter: frontmatter,
noteTitle: generateNoteTitle(frontmatter),
frontmatterRendererInfo: {
offsetLines: frontmatterExtraction.frontmatterLines,
deprecatedSyntax: frontmatter.deprecatedTagsSyntax,
frontmatterInvalid: false
}
}
} catch (e) {
return {
...state,
rawFrontmatter: frontmatterExtraction.rawFrontmatterText,
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: {
offsetLines: frontmatterExtraction.frontmatterLines,
deprecatedSyntax: false,
frontmatterInvalid: true
}
}
}
}
/**
* 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 {@code undefined} if there is no such heading.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
return {
...state,
firstHeading: firstHeading,
noteTitle: generateNoteTitle(state.frontmatter, firstHeading)
}
}
const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string) => {
if (frontmatter?.title && frontmatter?.title !== '') {
return frontmatter.title.trim()
} else if (
frontmatter?.opengraph &&
frontmatter?.opengraph.get('title') &&
frontmatter?.opengraph.get('title') !== ''
) {
return (frontmatter?.opengraph.get('title') ?? firstHeading ?? '').trim()
} else {
return (firstHeading ?? firstHeading ?? '').trim()
}
}
/**
* 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: NoteDto): NoteDetails => {
return {
documentContent: note.content,
markdownContent: '',
rawFrontmatter: '',
frontmatterRendererInfo: {
frontmatterInvalid: false,
deprecatedSyntax: false,
offsetLines: 0
},
frontmatter: initialState.frontmatter,
id: note.metadata.id,
noteTitle: initialState.noteTitle,
createTime: DateTime.fromISO(note.metadata.createTime),
lastChange: {
userName: note.metadata.updateUser.userName,
timestamp: DateTime.fromISO(note.metadata.updateTime)
},
firstHeading: initialState.firstHeading,
viewCount: note.metadata.viewCount,
alias: note.metadata.alias,
authorship: note.metadata.editedBy
}
}

View file

@ -1,105 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DateTime } from 'luxon'
import { Reducer } from 'redux'
import {
NoteFrontmatter,
NoteTextDirection,
NoteType
} from '../../components/editor-page/note-frontmatter/note-frontmatter'
import { NoteDetails, NoteDetailsActions, NoteDetailsActionType } from './types'
import { noteDtoToNoteDetails } from '../../api/notes/dto-methods'
export const initialState: NoteDetails = {
markdownContent: '',
id: '',
createTime: DateTime.fromSeconds(0),
lastChange: {
timestamp: DateTime.fromSeconds(0),
userName: ''
},
alias: '',
viewCount: 0,
authorship: [],
noteTitle: '',
firstHeading: '',
frontmatter: {
title: '',
description: '',
tags: [],
deprecatedTagsSyntax: false,
robots: '',
lang: 'en',
dir: NoteTextDirection.LTR,
breaks: true,
GA: '',
disqus: '',
type: NoteType.DOCUMENT,
opengraph: new Map<string, string>()
}
}
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState,
action: NoteDetailsActions
) => {
switch (action.type) {
case NoteDetailsActionType.SET_DOCUMENT_CONTENT:
return {
...state,
markdownContent: action.content
}
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
return {
...state,
firstHeading: action.firstHeading,
noteTitle: generateNoteTitle(state.frontmatter, action.firstHeading)
}
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
return noteDtoToNoteDetails(action.note)
case NoteDetailsActionType.SET_NOTE_FRONTMATTER:
return {
...state,
frontmatter: action.frontmatter,
noteTitle: generateNoteTitle(action.frontmatter, state.firstHeading)
}
case NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT:
return {
...state,
markdownContent: setCheckboxInMarkdownContent(state.markdownContent, action.lineInMarkdown, action.checked)
}
default:
return state
}
}
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/
const setCheckboxInMarkdownContent = (markdownContent: string, lineInMarkdown: number, checked: boolean): string => {
const lines = markdownContent.split('\n')
const results = TASK_REGEX.exec(lines[lineInMarkdown])
if (results) {
const before = results[1]
const after = results[3]
lines[lineInMarkdown] = `${before}[${checked ? 'x' : ' '}]${after}`
return lines.join('\n')
}
return markdownContent
}
const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string) => {
if (frontmatter?.title && frontmatter?.title !== '') {
return frontmatter.title.trim()
} else if (
frontmatter?.opengraph &&
frontmatter?.opengraph.get('title') &&
frontmatter?.opengraph.get('title') !== ''
) {
return (frontmatter?.opengraph.get('title') ?? firstHeading ?? '').trim()
} else {
return (firstHeading ?? firstHeading ?? '').trim()
}
}

View file

@ -6,24 +6,30 @@
import { DateTime } from 'luxon'
import { Action } from 'redux'
import { NoteFrontmatter } from '../../components/editor-page/note-frontmatter/note-frontmatter'
import { NoteFrontmatter } from '../../components/common/note-frontmatter/note-frontmatter'
import { NoteDto } from '../../api/notes/types'
import { RendererFrontmatterInfo } from '../../components/common/note-frontmatter/types'
export enum NoteDetailsActionType {
SET_DOCUMENT_CONTENT = 'note-details/set',
SET_DOCUMENT_CONTENT = 'note-details/content/set',
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
SET_NOTE_FRONTMATTER = 'note-details/frontmatter/set',
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
SET_CHECKBOX_IN_MARKDOWN_CONTENT = 'note-details/toggle-checkbox-in-markdown-content'
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox'
}
interface LastChange {
userName: string
timestamp: DateTime
}
/**
* Redux state containing the currently loaded note with its content and metadata.
*/
export interface NoteDetails {
documentContent: string
markdownContent: string
rawFrontmatter: string
frontmatter: NoteFrontmatter
frontmatterRendererInfo: RendererFrontmatterInfo
id: string
createTime: DateTime
lastChange: LastChange
@ -32,38 +38,43 @@ export interface NoteDetails {
authorship: string[]
noteTitle: string
firstHeading?: string
frontmatter: NoteFrontmatter
}
export type NoteDetailsActions =
| SetNoteDetailsAction
| SetNoteDocumentContentAction
| SetNoteDetailsFromServerAction
| UpdateNoteTitleByFirstHeadingAction
| SetNoteFrontmatterFromRenderingAction
| SetCheckboxInMarkdownContentAction
| UpdateTaskListCheckboxAction
export interface SetNoteDetailsAction extends Action<NoteDetailsActionType> {
/**
* 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
note: NoteDto
dto: NoteDto
}
/**
* 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 SetNoteFrontmatterFromRenderingAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.SET_NOTE_FRONTMATTER
frontmatter: NoteFrontmatter
}
export interface SetCheckboxInMarkdownContentAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT
lineInMarkdown: number
checked: boolean
/**
* Action for manipulating the document content of the currently loaded note by changing the checked state of a task list checkbox.
*/
export interface UpdateTaskListCheckboxAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX
changedLine: number
checkboxChecked: boolean
}