mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05: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": {
|
"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",
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
* 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}>
|
|
@ -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}>
|
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