feat(sidebar): add gitlab snippet and github gist export

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-03-23 00:35:00 +01:00
parent 5fd8c02637
commit 0db5a0856b
13 changed files with 624 additions and 33 deletions

View file

@ -333,6 +333,37 @@
} }
}, },
"export": { "export": {
"common": {
"title": "Export to {{service}}",
"createButton": "Create {{shortName}}",
"headingAuthentication": "Authentication",
"headingSettings": "Export settings",
"fieldToken": "Access token",
"fieldDescription": "Description",
"notificationSuccessTitle": "{{shortName}} successfully created",
"notificationSuccessMessage": "The note has been exported to {{service}}.",
"notificationSuccessButton": "Open {{shortName}}",
"notificationErrorTitle": "Error while creating {{shortName}}: {{errorMessage}}"
},
"gist": {
"service": "GitHub Gist",
"shortName": "Gist",
"infoToken": "You need a personal access token with the Gists account permission. You can create one in your GitHub settings here:",
"fieldPublic": "Make Gist public?"
},
"gitlab": {
"service": "GitLab snippet",
"shortName": "Snippet",
"infoToken": "You need a personal access token with the api permission. You can create one in your GitLab user settings here:",
"infoTokenLink": "Preferences → Access tokens",
"fieldUrl": "GitLab URL",
"fieldVisibility": "Set snippet visibility",
"visibility": {
"private": "Private",
"internal": "Internal",
"public": "Public"
}
},
"rawHtml": "Raw HTML", "rawHtml": "Raw HTML",
"markdown-file": "Markdown file" "markdown-file": "Markdown file"
}, },
@ -520,6 +551,8 @@
"avatarOf": "Avatar of '{{name}}'", "avatarOf": "Avatar of '{{name}}'",
"why": "Why?", "why": "Why?",
"loading": "Loading ...", "loading": "Loading ...",
"continue": "Continue",
"back": "Back",
"errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", "errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
"errorOccurred": "An error occurred", "errorOccurred": "An error occurred",
"readForMoreInfo": "Read here for more information", "readForMoreInfo": "Read here for more information",

View file

@ -5,7 +5,7 @@
*/ */
import { AliasesSidebarEntry } from './specific-sidebar-entries/aliases-sidebar-entry/aliases-sidebar-entry' import { AliasesSidebarEntry } from './specific-sidebar-entries/aliases-sidebar-entry/aliases-sidebar-entry'
import { DeleteNoteSidebarEntry } from './specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry' import { DeleteNoteSidebarEntry } from './specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry'
import { ExportMenuSidebarMenu } from './specific-sidebar-entries/export-menu-sidebar-menu' import { ExportSidebarMenu } from './specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu'
import { ImportMenuSidebarMenu } from './specific-sidebar-entries/import-menu-sidebar-menu' import { ImportMenuSidebarMenu } from './specific-sidebar-entries/import-menu-sidebar-menu'
import { NoteInfoSidebarMenu } from './specific-sidebar-entries/note-info-sidebar-menu/note-info-sidebar-menu' import { NoteInfoSidebarMenu } from './specific-sidebar-entries/note-info-sidebar-menu/note-info-sidebar-menu'
import { PermissionsSidebarEntry } from './specific-sidebar-entries/permissions-sidebar-entry/permissions-sidebar-entry' import { PermissionsSidebarEntry } from './specific-sidebar-entries/permissions-sidebar-entry/permissions-sidebar-entry'
@ -62,7 +62,7 @@ export const Sidebar: React.FC = () => {
selectedMenuId={selectedMenu} selectedMenuId={selectedMenu}
onClick={toggleValue} onClick={toggleValue}
/> />
<ExportMenuSidebarMenu <ExportSidebarMenu
menuId={DocumentSidebarMenuSelection.EXPORT} menuId={DocumentSidebarMenuSelection.EXPORT}
selectedMenuId={selectedMenu} selectedMenuId={selectedMenu}
onClick={toggleValue} onClick={toggleValue}

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Creates a gist on GitHub with the given content.
*
* @param token The GitHub personal access token to use
* @param content The content of the gist
* @param description The description of the gist
* @param fileName The (generated) file name of the note
* @param isPublic Whether the gist should be public
* @return The URL of the created gist
*/
export const createGist = async (
token: string,
content: string,
description: string,
fileName: string,
isPublic: boolean
): Promise<string> => {
const response = await fetch('https://api.github.com/gists', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json'
},
body: JSON.stringify({
description: description,
public: isPublic,
files: {
[fileName]: { content }
}
})
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid GitHub token provided')
}
throw new Error('Request to GitHub API failed')
}
const json = (await response.json()) as { html_url: string }
if (!json.html_url) {
throw new Error('Invalid response from GitHub API')
}
return json.html_url
}

View file

@ -0,0 +1,138 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo, useState } from 'react'
import type { ModalVisibilityProps } from '../../../../../../common/modals/common-modal'
import { CommonModal } from '../../../../../../common/modals/common-modal'
import { Button, FormCheck, FormControl, FormGroup, FormLabel, FormText, Modal } from 'react-bootstrap'
import { useNoteMarkdownContent } from '../../../../../../../hooks/common/use-note-markdown-content'
import { useNoteFilename } from '../../../../../../../hooks/common/use-note-filename'
import { useOnInputChange } from '../../../../../../../hooks/common/use-on-input-change'
import { Github } from 'react-bootstrap-icons'
import { useUiNotifications } from '../../../../../../notifications/ui-notification-boundary'
import { useTranslatedText } from '../../../../../../../hooks/common/use-translated-text'
import { Trans, useTranslation } from 'react-i18next'
import { ExternalLink } from '../../../../../../common/links/external-link'
import { createGist } from './create-gist'
import { validateToken } from './validate-token'
/**
* Renders the modal for exporting the note content to a GitHub Gist.
*
* @param show true to show the modal, false otherwise.
* @param onHide Callback that is fired when the modal is about to be closed.
*/
export const ExportGistModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const noteContent = useNoteMarkdownContent()
const noteFilename = useNoteFilename()
const { dispatchUiNotification, showErrorNotification } = useUiNotifications()
const textService = useTranslatedText('editor.export.gist.service')
const textShortName = useTranslatedText('editor.export.gist.shortName')
const textModalTitle = useTranslatedText('editor.export.common.title', { replace: { service: textService } })
const textCreateButton = useTranslatedText('editor.export.common.createButton', {
replace: { shortName: textShortName }
})
const textNotificationButton = useTranslatedText('editor.export.common.notificationSuccessButton', {
replace: {
shortName: textShortName
}
})
const textFieldPublic = useTranslatedText('editor.export.gist.fieldPublic')
const [ghToken, setGhToken] = useState('')
const [gistDescription, setGistDescription] = useState('')
const [gistPublic, setGistPublic] = useState(false)
const onGistDescriptionChange = useOnInputChange(setGistDescription)
const onGhTokenChange = useOnInputChange(setGhToken)
const onGistPublicChange = useCallback(() => setGistPublic((prev) => !prev), [])
const ghTokenFormatValid = useMemo(() => validateToken(ghToken), [ghToken])
const onCreateGist = useCallback(() => {
createGist(ghToken, noteContent, gistDescription, noteFilename, gistPublic)
.then((gistUrl) => {
dispatchUiNotification(
'editor.export.common.notificationSuccessTitle',
'editor.export.common.notificationSuccessMessage',
{
durationInSecond: 30,
icon: Github,
buttons: [{ label: textNotificationButton, onClick: () => window.open(gistUrl, '_blank') }],
titleI18nOptions: {
replace: { shortName: textShortName }
},
contentI18nOptions: {
replace: { service: textService }
}
}
)
onHide?.()
})
.catch(
showErrorNotification(
'editor.export.common.notificationErrorTitle',
{ replace: { shortName: textShortName } },
true
)
)
}, [
ghToken,
noteContent,
noteFilename,
gistDescription,
gistPublic,
showErrorNotification,
textShortName,
dispatchUiNotification,
textNotificationButton,
textService,
onHide
])
return (
<CommonModal show={show} onHide={onHide} title={textModalTitle} showCloseButton={true} titleIcon={Github}>
<Modal.Body>
<h5 className={'mb-2'}>
<Trans i18nKey={'editor.export.common.headingAuthentication'} />
</h5>
<FormGroup className={'my-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.common.fieldToken'} />
</FormLabel>
<FormControl value={ghToken} onChange={onGhTokenChange} type={'password'} isInvalid={!ghTokenFormatValid} />
<FormText muted={true}>
<Trans i18nKey={'editor.export.gist.infoToken'} />{' '}
<ExternalLink
text={'https://github.com/settings/personal-access-tokens/new'}
href={'https://github.com/settings/personal-access-tokens/new'}
/>
</FormText>
</FormGroup>
<h5 className={'mb-2 mt-4'}>
<Trans i18nKey={'editor.export.common.headingSettings'} />
</h5>
<FormGroup className={'my-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.common.fieldDescription'} />
</FormLabel>
<FormControl value={gistDescription} onChange={onGistDescriptionChange} type={'text'} />
</FormGroup>
<FormGroup className={'mt-2'}>
<FormCheck checked={gistPublic} onChange={onGistPublicChange} type={'checkbox'} label={textFieldPublic} />
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button variant={'success'} onClick={onCreateGist} disabled={!ghTokenFormatValid}>
{textCreateButton}
</Button>
</Modal.Footer>
</CommonModal>
)
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import { Github as IconGithub } from 'react-bootstrap-icons'
import { SidebarButton } from '../../../../sidebar-button/sidebar-button'
import { ExportGistModal } from './export-gist-modal'
import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state'
import { Trans, useTranslation } from 'react-i18next'
/**
* Renders the sidebar entry for exporting the note content to a GitHub Gist.
*/
export const ExportGistSidebarEntry: React.FC = () => {
useTranslation()
const [showModal, setShowModal, setHideModal] = useBooleanState(false)
return (
<Fragment>
<SidebarButton icon={IconGithub} onClick={setShowModal}>
<Trans i18nKey={'editor.export.gist.service'} />
</SidebarButton>
<ExportGistModal show={showModal} onHide={setHideModal} />
</Fragment>
)
}

View file

@ -0,0 +1,39 @@
import { validateToken } from './validate-token'
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('GitHub validateToken', () => {
it('should return true for a valid classic token', () => {
// token is randomly generated and not real
const token = 'ghp_yQbWDPxQgaZAeTSuVFJrmVGloUfENbFqzLHS'
expect(validateToken(token)).toBe(true)
})
it('should return true for a valid scoped token', () => {
// token is randomly generated and not real
const token = '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'
expect(validateToken(token)).toBe(true)
})
it('should return false for an empty token', () => {
const token = ''
expect(validateToken(token)).toBe(false)
})
it('should return false for an invalid classic token', () => {
const token = 'ghp_wrong_length'
expect(validateToken(token)).toBe(false)
})
it('should return false for an invalid scoped token (1)', () => {
const token = 'github_pat_wrong_length'
expect(validateToken(token)).toBe(false)
})
it('should return false for an invalid scoped token (2)', () => {
const token = 'github_pat_wrongwronmgwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongw'
expect(validateToken(token)).toBe(false)
})
it('should return false for a token with an invalid prefix', () => {
const token = 'wrong_abc'
expect(validateToken(token)).toBe(false)
})
})

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const GITHUB_CLASSIC_TOKEN_LENGTH = 40
const GITHUB_CLASSIC_TOKEN_PREFIX = 'ghp_'
const GITHUB_SCOPED_TOKEN_LENGTH = 93
const GITHUB_SCOPED_TOKEN_PREFIX = 'github_pat_'
/**
* Validates a given input to the format of a GitHub personal access token.
*
* @param token The input to validate
* @return true if the input conforms to the format of a GitHub personal access token, false otherwise
*/
export const validateToken = (token: string): boolean => {
return (
(token.startsWith(GITHUB_CLASSIC_TOKEN_PREFIX) && token.length === GITHUB_CLASSIC_TOKEN_LENGTH) ||
(token.startsWith(GITHUB_SCOPED_TOKEN_PREFIX) &&
token.length === GITHUB_SCOPED_TOKEN_LENGTH &&
token.charAt(33) === '_')
)
}

View file

@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum GitlabSnippetVisibility {
PRIVATE = 'private',
INTERNAL = 'internal',
PUBLIC = 'public'
}
/**
* Creates a snippet on a GitLab instance with the given content.
*
* @param instanceUrl The URL of the GitLab instance
* @param token The GitLab access token to use
* @param content The content of the snippet
* @param title The title of the snippet
* @param description The description of the gist
* @param fileName The (generated) file name of the note
* @param visibility The visibility level of the snippet
* @return The URL of the created snippet
*/
export const createSnippet = async (
instanceUrl: string,
token: string,
content: string,
title: string,
description: string,
fileName: string,
visibility: GitlabSnippetVisibility
): Promise<string> => {
const cleanedInstanceUrl = instanceUrl.endsWith('/') ? instanceUrl.slice(0, -1) : instanceUrl
const response = await fetch(`${cleanedInstanceUrl}/api/v4/snippets`, {
method: 'POST',
headers: {
'PRIVATE-TOKEN': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title,
description: description,
visibility: visibility,
files: [
{
content: content,
file_path: fileName
}
]
})
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid GitLab access token provided')
}
throw new Error('Request to GitLab API failed')
}
const json = (await response.json()) as { web_url: string }
if (!json.web_url) {
throw new Error('Invalid response from GitLab API')
}
return json.web_url
}

View file

@ -0,0 +1,172 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo, useState } from 'react'
import type { ModalVisibilityProps } from '../../../../../../common/modals/common-modal'
import { CommonModal } from '../../../../../../common/modals/common-modal'
import { Trans, useTranslation } from 'react-i18next'
import { useTranslatedText } from '../../../../../../../hooks/common/use-translated-text'
import { IconGitlab } from '../../../../../../common/icons/additional/icon-gitlab'
import { useOnInputChange } from '../../../../../../../hooks/common/use-on-input-change'
import { Button, FormControl, FormGroup, FormLabel, FormText, Modal } from 'react-bootstrap'
import { ExternalLink } from '../../../../../../common/links/external-link'
import { useNoteMarkdownContent } from '../../../../../../../hooks/common/use-note-markdown-content'
import { useNoteFilename } from '../../../../../../../hooks/common/use-note-filename'
import { useUiNotifications } from '../../../../../../notifications/ui-notification-boundary'
import { createSnippet, GitlabSnippetVisibility } from './create-snippet'
import { Github } from 'react-bootstrap-icons'
import { useNoteTitle } from '../../../../../../../hooks/common/use-note-title'
/**
* Renders the modal for exporting the note content to a GitLab snippet.
*
* @param show true to show the modal, false otherwise.
* @param onHide Callback that is fired when the modal is about to be closed.
*/
export const ExportGitlabSnippetModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const noteContent = useNoteMarkdownContent()
const noteFilename = useNoteFilename()
const noteTitle = useNoteTitle()
const { dispatchUiNotification, showErrorNotification } = useUiNotifications()
const [gitlabUrl, setGitlabUrl] = useState<string>('')
const [gitlabToken, setGitlabToken] = useState<string>('')
const [snippetVisibility, setSnippetVisibility] = useState<GitlabSnippetVisibility>(GitlabSnippetVisibility.PRIVATE)
const [snippetDescription, setSnippetDescription] = useState<string>('')
const textService = useTranslatedText('editor.export.gitlab.service')
const textShortName = useTranslatedText('editor.export.gitlab.shortName')
const textModalTitle = useTranslatedText('editor.export.common.title', { replace: { service: textService } })
const textTokenLink = useTranslatedText('editor.export.gitlab.infoTokenLink')
const textCreateButton = useTranslatedText('editor.export.common.createButton', {
replace: { shortName: textShortName }
})
const textNotificationButton = useTranslatedText('editor.export.common.notificationSuccessButton', {
replace: {
shortName: textShortName
}
})
const changeGitlabUrl = useOnInputChange(setGitlabUrl)
const changeGitlabToken = useOnInputChange(setGitlabToken)
const changeSnippetVisibility = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSnippetVisibility(event.target.value as GitlabSnippetVisibility)
}, [])
const changeSnippetDescription = useOnInputChange(setSnippetDescription)
const creationPossible = useMemo(() => {
return gitlabUrl !== '' && gitlabToken !== ''
}, [gitlabUrl, gitlabToken])
const onCreateSnippet = useCallback(() => {
createSnippet(gitlabUrl, gitlabToken, noteContent, noteTitle, snippetDescription, noteFilename, snippetVisibility)
.then((snippetUrl) => {
dispatchUiNotification(
'editor.export.common.notificationSuccessTitle',
'editor.export.common.notificationSuccessMessage',
{
durationInSecond: 30,
icon: Github,
buttons: [{ label: textNotificationButton, onClick: () => window.open(snippetUrl, '_blank') }],
titleI18nOptions: {
replace: { shortName: textShortName }
},
contentI18nOptions: {
replace: { service: textService }
}
}
)
onHide?.()
})
.catch(
showErrorNotification(
'editor.export.common.notificationErrorTitle',
{ replace: { shortName: textShortName } },
true
)
)
}, [
gitlabUrl,
gitlabToken,
noteContent,
noteTitle,
snippetDescription,
noteFilename,
snippetVisibility,
showErrorNotification,
textShortName,
dispatchUiNotification,
textNotificationButton,
textService,
onHide
])
return (
<CommonModal show={show} onHide={onHide} title={textModalTitle} showCloseButton={true} titleIcon={IconGitlab}>
<Modal.Body>
<h5 className={'mb-2'}>
<Trans i18nKey={'editor.export.common.headingAuthentication'} />
</h5>
<FormGroup className={'my-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.gitlab.fieldUrl'} />
</FormLabel>
<FormControl value={gitlabUrl} onChange={changeGitlabUrl} type={'url'} placeholder={'https://gitlab.com'} />
</FormGroup>
<FormGroup className={'my-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.common.fieldToken'} />
</FormLabel>
<FormControl
value={gitlabToken}
onChange={changeGitlabToken}
type={'password'}
isInvalid={gitlabToken === ''}
/>
<FormText muted={true}>
<Trans i18nKey={'editor.export.gitlab.infoToken'} />{' '}
<ExternalLink
text={textTokenLink}
href={`${gitlabUrl ?? 'https://gitlab.com'}/-/user_settings/personal_access_tokens?name=HedgeDoc+snippet+export&scopes=api`}
/>
</FormText>
</FormGroup>
<h5 className={'mb-2 mt-4'}>
<Trans i18nKey={'editor.export.common.headingSettings'} />
</h5>
<FormGroup className={'my-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.common.fieldDescription'} />
</FormLabel>
<FormControl value={snippetDescription} onChange={changeSnippetDescription} type={'text'} />
</FormGroup>
<FormGroup className={'mt-2'}>
<FormLabel>
<Trans i18nKey={'editor.export.gitlab.fieldVisibility'} />
</FormLabel>
<FormControl as={'select'} value={snippetVisibility} onChange={changeSnippetVisibility}>
<option value={GitlabSnippetVisibility.PRIVATE}>
<Trans i18nKey={'editor.export.gitlab.visibility.private'} />
</option>
<option value={GitlabSnippetVisibility.INTERNAL}>
<Trans i18nKey={'editor.export.gitlab.visibility.internal'} />
</option>
<option value={GitlabSnippetVisibility.PUBLIC}>
<Trans i18nKey={'editor.export.gitlab.visibility.public'} />
</option>
</FormControl>
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button variant={'success'} onClick={onCreateSnippet} disabled={!creationPossible}>
{textCreateButton}
</Button>
</Modal.Footer>
</CommonModal>
)
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import { SidebarButton } from '../../../../sidebar-button/sidebar-button'
import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state'
import { Trans, useTranslation } from 'react-i18next'
import { IconGitlab } from '../../../../../../common/icons/additional/icon-gitlab'
import { ExportGitlabSnippetModal } from './export-gitlab-snippet-modal'
/**
* Renders the sidebar entry for exporting the note content to a GitLab snippet.
*/
export const ExportGitlabSnippetSidebarEntry: React.FC = () => {
useTranslation()
const [showModal, setShowModal, setHideModal] = useBooleanState(false)
return (
<Fragment>
<SidebarButton icon={IconGitlab} onClick={setShowModal}>
<Trans i18nKey={'editor.export.gitlab.service'} />
</SidebarButton>
<ExportGitlabSnippetModal show={showModal} onHide={setHideModal} />
</Fragment>
)
}

View file

@ -1,32 +1,31 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content' import { useNoteMarkdownContent } from '../../../../../../hooks/common/use-note-markdown-content'
import { getGlobalState } from '../../../../redux' import { getGlobalState } from '../../../../../../redux'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../../../utils/cypress-attribute'
import { download } from '../../../common/download/download' import { download } from '../../../../../common/download/download'
import { SidebarButton } from '../sidebar-button/sidebar-button' import { SidebarButton } from '../../../sidebar-button/sidebar-button'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { FileText as IconFileText } from 'react-bootstrap-icons' import { FileText as IconFileText } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
import sanitize from 'sanitize-filename' import { useNoteFilename } from '../../../../../../hooks/common/use-note-filename'
/** /**
* Editor sidebar entry for exporting the markdown content into a local file. * Editor sidebar entry for exporting the markdown content into a local file.
*/ */
export const ExportMarkdownSidebarEntry: React.FC = () => { export const ExportMarkdownSidebarEntry: React.FC = () => {
const { t } = useTranslation()
const markdownContent = useNoteMarkdownContent() const markdownContent = useNoteMarkdownContent()
const fileName = useNoteFilename()
const onClick = useCallback(() => { const onClick = useCallback(() => {
const title = getGlobalState().noteDetails?.title const title = getGlobalState().noteDetails?.title
if (title === undefined) { if (title === undefined) {
return return
} }
const sanitized = sanitize(title) download(markdownContent, fileName, 'text/markdown')
download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown') }, [markdownContent, fileName])
}, [markdownContent, t])
return ( return (
<SidebarButton {...cypressId('menu-export-markdown')} onClick={onClick} icon={IconFileText}> <SidebarButton {...cypressId('menu-export-markdown')} onClick={onClick} icon={IconFileText}>

View file

@ -3,23 +3,23 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../../utils/cypress-attribute'
import { IconGitlab } from '../../../common/icons/additional/icon-gitlab' import { SidebarButton } from '../../sidebar-button/sidebar-button'
import { SidebarButton } from '../sidebar-button/sidebar-button' import { SidebarMenu } from '../../sidebar-menu/sidebar-menu'
import { SidebarMenu } from '../sidebar-menu/sidebar-menu' import type { SpecificSidebarMenuProps } from '../../types'
import type { SpecificSidebarMenuProps } from '../types' import { DocumentSidebarMenuSelection } from '../../types'
import { DocumentSidebarMenuSelection } from '../types' import { ExportMarkdownSidebarEntry } from './entries/export-markdown-sidebar-entry'
import { ExportMarkdownSidebarEntry } from './export-markdown-sidebar-entry'
import React, { Fragment, useCallback } from 'react' import React, { Fragment, useCallback } from 'react'
import { import {
ArrowLeft as IconArrowLeft, ArrowLeft as IconArrowLeft,
CloudDownload as IconCloudDownload, CloudDownload as IconCloudDownload,
FileCode as IconFileCode, FileCode as IconFileCode
Github as IconGithub
} from 'react-bootstrap-icons' } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { concatCssClasses } from '../../../../utils/concat-css-classes' import { concatCssClasses } from '../../../../../utils/concat-css-classes'
import styles from '../sidebar-button/sidebar-button.module.scss' import styles from '../../sidebar-button/sidebar-button.module.scss'
import { ExportGistSidebarEntry } from './entries/export-gist-sidebar-entry/export-gist-sidebar-entry'
import { ExportGitlabSnippetSidebarEntry } from './entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-sidebar-entry'
/** /**
* Renders the export menu for the sidebar. * Renders the export menu for the sidebar.
@ -29,7 +29,7 @@ import styles from '../sidebar-button/sidebar-button.module.scss'
* @param onClick The callback, that should be called when the menu button is pressed * @param onClick The callback, that should be called when the menu button is pressed
* @param selectedMenuId The currently selected menu id * @param selectedMenuId The currently selected menu id
*/ */
export const ExportMenuSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({ export const ExportSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
className, className,
menuId, menuId,
onClick, onClick,
@ -53,13 +53,8 @@ export const ExportMenuSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
<Trans i18nKey={'editor.documentBar.export'} /> <Trans i18nKey={'editor.documentBar.export'} />
</SidebarButton> </SidebarButton>
<SidebarMenu expand={expand}> <SidebarMenu expand={expand}>
<SidebarButton icon={IconGithub} disabled={true}> <ExportGistSidebarEntry />
Gist <ExportGitlabSnippetSidebarEntry />
</SidebarButton>
<SidebarButton icon={IconGitlab} disabled={true}>
Gitlab Snippet
</SidebarButton>
<ExportMarkdownSidebarEntry /> <ExportMarkdownSidebarEntry />
<SidebarButton icon={IconFileCode} disabled={true}> <SidebarButton icon={IconFileCode} disabled={true}>

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { useNoteTitle } from './use-note-title'
import sanitize from 'sanitize-filename'
/**
* Returns a sanitized filename for the current note based on the title.
* When no title is provided, the filename will be "untitled".
*
* @return The sanitized filename
*/
export const useNoteFilename = (): string => {
const title = useNoteTitle()
return useMemo(() => `${sanitize(title)}.md`, [title])
}