mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-12-22 20:21:42 +00:00
feat(sidebar): add gitlab snippet and github gist export
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
5fd8c02637
commit
0db5a0856b
13 changed files with 624 additions and 33 deletions
|
@ -333,6 +333,37 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"markdown-file": "Markdown file"
|
||||
},
|
||||
|
@ -520,6 +551,8 @@
|
|||
"avatarOf": "Avatar of '{{name}}'",
|
||||
"why": "Why?",
|
||||
"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.",
|
||||
"errorOccurred": "An error occurred",
|
||||
"readForMoreInfo": "Read here for more information",
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
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 { 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 { 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'
|
||||
|
@ -62,7 +62,7 @@ export const Sidebar: React.FC = () => {
|
|||
selectedMenuId={selectedMenu}
|
||||
onClick={toggleValue}
|
||||
/>
|
||||
<ExportMenuSidebarMenu
|
||||
<ExportSidebarMenu
|
||||
menuId={DocumentSidebarMenuSelection.EXPORT}
|
||||
selectedMenuId={selectedMenu}
|
||||
onClick={toggleValue}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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) === '_')
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content'
|
||||
import { getGlobalState } from '../../../../redux'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { download } from '../../../common/download/download'
|
||||
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
||||
import { useNoteMarkdownContent } from '../../../../../../hooks/common/use-note-markdown-content'
|
||||
import { getGlobalState } from '../../../../../../redux'
|
||||
import { cypressId } from '../../../../../../utils/cypress-attribute'
|
||||
import { download } from '../../../../../common/download/download'
|
||||
import { SidebarButton } from '../../../sidebar-button/sidebar-button'
|
||||
import React, { useCallback } from 'react'
|
||||
import { FileText as IconFileText } from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import sanitize from 'sanitize-filename'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { useNoteFilename } from '../../../../../../hooks/common/use-note-filename'
|
||||
|
||||
/**
|
||||
* Editor sidebar entry for exporting the markdown content into a local file.
|
||||
*/
|
||||
export const ExportMarkdownSidebarEntry: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const markdownContent = useNoteMarkdownContent()
|
||||
const fileName = useNoteFilename()
|
||||
const onClick = useCallback(() => {
|
||||
const title = getGlobalState().noteDetails?.title
|
||||
if (title === undefined) {
|
||||
return
|
||||
}
|
||||
const sanitized = sanitize(title)
|
||||
download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown')
|
||||
}, [markdownContent, t])
|
||||
download(markdownContent, fileName, 'text/markdown')
|
||||
}, [markdownContent, fileName])
|
||||
|
||||
return (
|
||||
<SidebarButton {...cypressId('menu-export-markdown')} onClick={onClick} icon={IconFileText}>
|
|
@ -3,23 +3,23 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { IconGitlab } from '../../../common/icons/additional/icon-gitlab'
|
||||
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
||||
import { SidebarMenu } from '../sidebar-menu/sidebar-menu'
|
||||
import type { SpecificSidebarMenuProps } from '../types'
|
||||
import { DocumentSidebarMenuSelection } from '../types'
|
||||
import { ExportMarkdownSidebarEntry } from './export-markdown-sidebar-entry'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import { SidebarMenu } from '../../sidebar-menu/sidebar-menu'
|
||||
import type { SpecificSidebarMenuProps } from '../../types'
|
||||
import { DocumentSidebarMenuSelection } from '../../types'
|
||||
import { ExportMarkdownSidebarEntry } from './entries/export-markdown-sidebar-entry'
|
||||
import React, { Fragment, useCallback } from 'react'
|
||||
import {
|
||||
ArrowLeft as IconArrowLeft,
|
||||
CloudDownload as IconCloudDownload,
|
||||
FileCode as IconFileCode,
|
||||
Github as IconGithub
|
||||
FileCode as IconFileCode
|
||||
} from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { concatCssClasses } from '../../../../utils/concat-css-classes'
|
||||
import styles from '../sidebar-button/sidebar-button.module.scss'
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
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.
|
||||
|
@ -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 selectedMenuId The currently selected menu id
|
||||
*/
|
||||
export const ExportMenuSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
||||
export const ExportSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
||||
className,
|
||||
menuId,
|
||||
onClick,
|
||||
|
@ -53,13 +53,8 @@ export const ExportMenuSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
<Trans i18nKey={'editor.documentBar.export'} />
|
||||
</SidebarButton>
|
||||
<SidebarMenu expand={expand}>
|
||||
<SidebarButton icon={IconGithub} disabled={true}>
|
||||
Gist
|
||||
</SidebarButton>
|
||||
<SidebarButton icon={IconGitlab} disabled={true}>
|
||||
Gitlab Snippet
|
||||
</SidebarButton>
|
||||
|
||||
<ExportGistSidebarEntry />
|
||||
<ExportGitlabSnippetSidebarEntry />
|
||||
<ExportMarkdownSidebarEntry />
|
||||
|
||||
<SidebarButton icon={IconFileCode} disabled={true}>
|
19
frontend/src/hooks/common/use-note-filename.tsx
Normal file
19
frontend/src/hooks/common/use-note-filename.tsx
Normal 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])
|
||||
}
|
Loading…
Reference in a new issue