diff --git a/front/src/components/Article.graphql b/front/src/components/Article.graphql
index 712ee1632..64c774424 100644
--- a/front/src/components/Article.graphql
+++ b/front/src/components/Article.graphql
@@ -17,6 +17,8 @@ query getArticleVersions($articleId: ID!) {
message
revision
version
+ type
+ createdAt
}
}
}
diff --git a/front/src/components/ArticleVersionLinks.jsx b/front/src/components/ArticleVersionLinks.jsx
index cabe2b8ee..751675e62 100644
--- a/front/src/components/ArticleVersionLinks.jsx
+++ b/front/src/components/ArticleVersionLinks.jsx
@@ -1,4 +1,5 @@
import { Loading } from '@geist-ui/core'
+import clsx from 'clsx'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
@@ -6,6 +7,7 @@ import useGraphQL from '../hooks/graphql.js'
import styles from './articles.module.scss'
import { getArticleVersions } from './Article.graphql'
+import TimeAgo from './TimeAgo.jsx'
export default function ArticleVersionLinks ({ articleId, article }) {
const { t } = useTranslation()
@@ -19,7 +21,22 @@ export default function ArticleVersionLinks ({ articleId, article }) {
revalidateOnFocus: false,
revalidateOnReconnect: false
})
- const versions = useMemo(() => data?.article?.versions || [], [data])
+ const getVersions = () => (data?.article?.versions || []).map(v => {
+ let title = ''
+ if (v.type === 'editingSessionEnded') {
+ title = t('version.editingSessionEnded.text')
+ } else if (v.type === 'collaborativeSessionEnded') {
+ title = t('version.collaborativeSessionEnded.text')
+ } else {
+ title = `v${v.version}.${v.revision} ${v.message}`
+ }
+ return {
+ ...v,
+ type: v.type || 'userAction',
+ title,
+ }
+ })
+ const versions = useMemo(getVersions, [data])
if (isLoading) {
return
@@ -32,15 +49,16 @@ export default function ArticleVersionLinks ({ articleId, article }) {
{t('article.versions.title')}
{versions.map((v) => (
- -
- {`${
- v.message ? v.message : 'no label'
- } (v${v.version}.${v.revision})`}
+
-
+
+ {v.title}{' '}
+
+
- ))}
+ ))}
>
}
>
- )
+)
}
diff --git a/front/src/components/Articles.graphql b/front/src/components/Articles.graphql
index b9fa46dad..07121047b 100644
--- a/front/src/components/Articles.graphql
+++ b/front/src/components/Articles.graphql
@@ -66,6 +66,7 @@ query getUserArticles($user: ID!) {
creator {
_id
}
+ creatorUsername
createdAt
}
}
@@ -128,6 +129,7 @@ query getWorkspaceArticles ($workspaceId: ID!) {
creator {
_id
}
+ creatorUsername
createdAt
}
diff --git a/front/src/components/Articles.jsx b/front/src/components/Articles.jsx
index d6c8acdbb..89d645964 100644
--- a/front/src/components/Articles.jsx
+++ b/front/src/components/Articles.jsx
@@ -123,7 +123,7 @@ export default function Articles () {
useEffect(() => {
let events
if (!isLoading) {
- events = new EventSource(`${backendEndpoint}/events`)
+ events = new EventSource(`${backendEndpoint}/events?userId=${activeUserId}`)
events.onmessage = (event) => {
handleStateUpdated(event)
}
diff --git a/front/src/components/Write/Versions.jsx b/front/src/components/Write/Versions.jsx
index a63e3b8b7..f321c315a 100644
--- a/front/src/components/Write/Versions.jsx
+++ b/front/src/components/Write/Versions.jsx
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'
import PropTypes from 'prop-types'
+import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
import { ArrowLeft, Check, ChevronDown, ChevronRight, Edit3 } from 'react-feather'
@@ -19,7 +20,8 @@ import Field from '../Field'
import CreateVersion from './CreateVersion'
import clsx from 'clsx'
-function Version ({ articleId, articleName: name, compareTo, onExport, readOnly, selectedVersion, v }) {
+function Version ({ articleId, compareTo, readOnly, selectedVersion, v }) {
+ const { t } = useTranslation()
const className = clsx({
[styles.selected]: v._id === selectedVersion,
[styles.compareTo]: v._id === compareTo
@@ -27,12 +29,13 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly,
const articleVersionId = v._id
const versionPart = selectedVersion ? `version/${selectedVersion}/` : ''
- const compareLink = `/article/${articleId}/${versionPart}compare/${v._id}`
+ const compareLink = `/article/${articleId}/${versionPart}compare/${v._id}`
const runQuery = useGraphQL()
const [renaming, setRenaming] = useState(false)
const [title, setTitle] = useState(v.message)
- const setExportParams = useCallback(() => onExport({ articleId, articleVersionId, bib: v.bibPreview, name }), [])
+ const versionType = v.type || 'userAction'
+ const manualVersion = versionType === 'userAction'
const startRenaming = useCallback((event) => event.preventDefault() || setRenaming(true), [])
const cancelRenaming = useCallback(() => setTitle(v.message) || setRenaming(false), [])
@@ -44,24 +47,15 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly,
setRenaming(false)
}, [title])
- return
- {!renaming &&
-
- {title || 'no label'}
- v{v.version}.{v.revision}
-
-
- {!readOnly && }
- }
-
+ return
{renaming && (
)}
-
- {!renaming &&
- {v.owner && (
-
+
+ {!renaming && versionType === 'editingSessionEnded' &&
+ {t('version.editingSessionEnded.text')}
+ }
+
+ {!renaming && versionType === 'collaborativeSessionEnded' &&
+ {t('version.collaborativeSessionEnded.text')}
+ }
+
+ {!renaming && versionType === 'userAction' &&
+
+ v{v.version}.{v.revision}{' '}{title || ''}
+
+ {!readOnly && }
+ }
+
+ {!renaming &&
+ {v.owner && (
+
by {v.owner.displayName || v.owner.username}
- )}
-
-
+ )}
+
+
-
}
-
- {!renaming &&
}
+
+ {!renaming && selectedVersion &&
{![compareTo, selectedVersion].includes(v._id) && (
-
Compare
@@ -95,40 +106,23 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly,
{v._id === compareTo && (
-
Stop
)}
- -
-
- Preview
-
-
- -
-
-
}
}
Version.propTypes = {
articleId: PropTypes.string.isRequired,
- articleName: PropTypes.string.isRequired,
v: PropTypes.object.isRequired,
selectedVersion: PropTypes.string,
compareTo: PropTypes.string,
readOnly: PropTypes.bool,
- onExport: PropTypes.func.isRequired
}
export default function Versions ({ article, selectedVersion, compareTo, readOnly }) {
@@ -145,7 +139,6 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl
dispatch({ type: 'ARTICLE_PREFERENCES_TOGGLE', key: 'expandVersions', value: false })
setExpandCreateForm(true)
}, [])
- const handleVersionExport = useCallback(({ articleId, articleVersionId, bib, name }) => setExportParams({ articleId, articleVersionId, bib, name }), [])
const cancelExport = useCallback(() => setExportParams({}), [])
return (
@@ -154,9 +147,10 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl
{expand ? : }
Versions
- }
+ {readOnly && Edit Mode}
{exportParams.articleId && (
@@ -165,25 +159,21 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl
)}
{expand && (
<>
- {expandCreateForm && }
+ {expandCreateForm && }
{articleVersions.length === 0 && (
All changes are automatically saved.
Create a new version to keep track of particular changes.
)}
- {readOnly && Edit Mode}
-
{articleVersions.map((v) => (
+ articleId={article._id}
+ selectedVersion={selectedVersion}
+ compareTo={compareTo}
+ readOnly={readOnly}
+ v={v}/>
))}
>
diff --git a/front/src/components/Write/WorkingVersion.jsx b/front/src/components/Write/WorkingVersion.jsx
index b8fb2b4b7..d2d8912fa 100644
--- a/front/src/components/Write/WorkingVersion.jsx
+++ b/front/src/components/Write/WorkingVersion.jsx
@@ -77,6 +77,10 @@ export default function WorkingVersion ({ articleInfos, live, selectedVersion, m
const cancelExport = useCallback(() => setExporting(false), [])
const openExport = useCallback(() => setExporting(true), [])
+
+ const previewUrl = selectedVersion
+ ? `/article/${articleInfos._id}/version/${selectedVersion}/preview`
+ : `/article/${articleInfos._id}/preview`
const articleOwnerAndContributors = [
articleInfos.owner.displayName,
...articleInfos.contributors.map(contributor => contributor.user.displayName)
@@ -114,7 +118,7 @@ export default function WorkingVersion ({ articleInfos, live, selectedVersion, m
-
diff --git a/front/src/components/Write/Write.graphql b/front/src/components/Write/Write.graphql
index fb1414cec..685c3c1d6 100644
--- a/front/src/components/Write/Write.graphql
+++ b/front/src/components/Write/Write.graphql
@@ -48,6 +48,7 @@ query getEditableArticle ($article: ID!, $hasVersion: Boolean!, $version: ID!) {
displayName
username
}
+ type
}
workingVersion @skip(if: $hasVersion) {
diff --git a/front/src/components/Write/Write.jsx b/front/src/components/Write/Write.jsx
index 4e48357df..5a524c9bb 100644
--- a/front/src/components/Write/Write.jsx
+++ b/front/src/components/Write/Write.jsx
@@ -43,6 +43,7 @@ export function deriveModeFrom ({ path, currentVersion }) {
export default function Write() {
const { setToast } = useToasts()
+ const backendEndpoint = useSelector(state => state.applicationConfig.backendEndpoint)
const { t } = useTranslation()
const { version: currentVersion, id: articleId, compareTo } = useParams()
const workingArticle = useSelector(state => state.workingArticle, shallowEqual)
@@ -61,6 +62,7 @@ export default function Write() {
const [graphQLError, setGraphQLError] = useState()
const [isLoading, setIsLoading] = useState(true)
const [live, setLive] = useState({})
+ const [soloSessionTakenOverBy, setSoloSessionTakenOverBy] = useState('')
const [articleInfos, setArticleInfos] = useState({
title: '',
owner: '',
@@ -83,6 +85,12 @@ export default function Write() {
bindings: soloSessionActiveBinding
} = useModal()
+ const {
+ visible: soloSessionTakeOverModalVisible,
+ setVisible: setSoloSessionTakeOverModalVisible,
+ bindings: soloSessionTakeOverModalBinding
+ } = useModal()
+
const PreviewComponent = useMemo(
() => articleInfos.preview.stylesheet ? PreviewPaged : PreviewHtml,
[articleInfos.preview.stylesheet, currentVersion]
@@ -147,6 +155,25 @@ export default function Write() {
return setLive({ ...live, yaml: metadata })
}
+ const handleStateUpdated = useCallback((event) => {
+ const parsedData = JSON.parse(event.data)
+ if (parsedData.articleStateUpdated) {
+ const articleStateUpdated = parsedData.articleStateUpdated
+ if (articleId === articleStateUpdated._id) {
+ if (articleStateUpdated.soloSession && articleStateUpdated.soloSession.id) {
+ if (userId !== articleStateUpdated.soloSession.creator._id) {
+ setSoloSessionTakenOverBy(articleStateUpdated.soloSession.creatorUsername)
+ setSoloSessionActive(true)
+ setSoloSessionTakeOverModalVisible(true)
+ }
+ } else if (articleStateUpdated.collaborativeSession) {
+ setCollaborativeSessionActiveVisible(true)
+ setCollaborativeSessionActive(true)
+ }
+ }
+ }
+ }, [articleId])
+
useEffect(() => {
// FIXME: should retrieve extensions.type 'COLLABORATIVE_SESSION_CONFLICT'
if (workingArticle && workingArticle.state === 'saveFailure' && workingArticle.stateMessage === 'Active collaborative session, cannot update the working copy.') {
@@ -245,6 +272,21 @@ export default function Write() {
}
}, [currentVersion, articleId])
+ useEffect(() => {
+ let events
+ if (!isLoading) {
+ events = new EventSource(`${backendEndpoint}/events?userId=${userId}`)
+ events.onmessage = (event) => {
+ handleStateUpdated(event)
+ }
+ }
+ return () => {
+ if (events) {
+ events.close()
+ }
+ }
+ }, [isLoading, handleStateUpdated])
+
if (graphQLError) {
return (
@@ -277,6 +319,14 @@ export default function Write() {
setSoloSessionActiveVisible(false)}>{t('modal.confirmButton.text')}
+
+ {t('article.soloSessionTakeOver.title')}
+
+ {t('article.soloSessionTakeOver.message', { username: soloSessionTakenOverBy })}
+
+ setSoloSessionTakeOverModalVisible(false)}>{t('modal.confirmButton.text')}
+
+
li:not(:first-child) {
- margin-left: 0.5em;
+ margin-left: 0.25em;
}
}
diff --git a/front/src/components/Write/menu.module.scss b/front/src/components/Write/menu.module.scss
index c29e7fbcd..ad56564fe 100644
--- a/front/src/components/Write/menu.module.scss
+++ b/front/src/components/Write/menu.module.scss
@@ -5,7 +5,6 @@
> h1 {
font-size: 1.2rem;
@extend .clickable;
- border-top: 1px solid $main-border-color;
padding: 0.5rem;
display: flex;
diff --git a/front/src/components/Write/versions.module.scss b/front/src/components/Write/versions.module.scss
index 82d97daa8..d103a067b 100644
--- a/front/src/components/Write/versions.module.scss
+++ b/front/src/components/Write/versions.module.scss
@@ -4,12 +4,10 @@
.versionsList {
font-size: 0.9em;
+ padding-bottom: 1em;
> li {
padding: .5rem;
}
- > li:nth-child(2n + 1) {
- background-color: $main-background-color;
- }
> li.selected {
background-color: $selected-background;
}
@@ -24,30 +22,78 @@
.editTitleButton {
@extend article, .editTitleButton;
height: auto;
- padding: .4em .25em;
+ padding: 0 .25em;
}
.momentAgo {
@extend article, .momentsAgo;
}
-.versionNumber {
- margin-left: .25em;
- &::after {
- content: ")";
- }
- &::before {
- content: "(";
+.version {
+ border-left: 4px solid #cecece;
+ margin-bottom: 0.25em;
+ margin-left: 0.5rem;
+ margin-right: 0.25em;
+ cursor: pointer;
+ display: flex;
+}
+
+.versionLink {
+ color: inherit;
+ text-decoration: none;
+ flex-grow: 1;
+
+ > header {
+ max-width: 265px;
+ white-space: nowrap;
+ display: flex;
}
}
+.versionLinkCompare {
+ max-width: 200px;
+}
+
+.manualVersion {
+ border-left: 4px solid #202020;
+}
+
+.automaticVersion {
+ border-left: 4px solid #bebebe;
+ color: #858585;
+}
+
+.versionLabel {
+ text-overflow: ellipsis;
+ display: block;
+ overflow: hidden;
+ padding-bottom: 0.15em;
+}
+
+.versionNumber {
+}
+
+.automaticVersion > header {
+ font-style: italic;
+}
+
.actions {
display: flex;
gap: 0.5em;
- margin-top: 0.5rem;
+ margin-top: 0.15rem;
margin-bottom: 0.25rem;
justify-content: flex-end;
- font-size: 0.9em;
+ font-size: 0.75em;
+}
+
+.action {
+ background-color: transparent;
+ border: none;
+ text-decoration: underline;
+}
+
+.action:hover {
+ text-decoration: underline;
}
.headingAction {
@@ -56,6 +102,5 @@
.editMode {
font-size: 0.8rem;
- margin-top: 0.75em;
- margin-left: 0.5em;
+ margin-left: auto;
}
diff --git a/front/src/components/articles.module.scss b/front/src/components/articles.module.scss
index 8d25b6b46..66123ce59 100644
--- a/front/src/components/articles.module.scss
+++ b/front/src/components/articles.module.scss
@@ -468,3 +468,10 @@ hr.horizontalSeparator {
display: flex;
gap: 0.35em;
}
+
+.userVersion {
+ font-weight: 500;
+}
+.automaticVersion {
+ font-weight: 300;
+}
diff --git a/front/src/components/solo/SoloSession.graphql b/front/src/components/solo/SoloSession.graphql
index 2aeb769d8..de92a3b8c 100644
--- a/front/src/components/solo/SoloSession.graphql
+++ b/front/src/components/solo/SoloSession.graphql
@@ -5,6 +5,7 @@ query getSoloSession($articleId: ID!) {
creator {
_id
}
+ creatorUsername
createdAt
}
}
@@ -17,3 +18,11 @@ mutation startSoloSession($articleId: ID!) {
}
}
}
+
+mutation takeOverSoloSession($articleId: ID!) {
+ article (articleId: $articleId) {
+ takeOverSoloSession {
+ id
+ }
+ }
+}
diff --git a/front/src/components/solo/SoloSessionAction.jsx b/front/src/components/solo/SoloSessionAction.jsx
index 9c562fe18..b37372872 100644
--- a/front/src/components/solo/SoloSessionAction.jsx
+++ b/front/src/components/solo/SoloSessionAction.jsx
@@ -1,23 +1,30 @@
-import { Dot, useToasts } from '@geist-ui/core'
+import { Dot, Modal as GeistModal, useModal, useToasts } from '@geist-ui/core'
import PropTypes from 'prop-types'
-import React, { useCallback } from 'react'
+import React, { useCallback, useState } from 'react'
import { Edit3 } from 'react-feather'
+import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
import { useMutation } from '../../hooks/graphql.js'
import Button from '../Button.jsx'
-import { startSoloSession } from './SoloSession.graphql'
+import { startSoloSession, takeOverSoloSession } from './SoloSession.graphql'
export default function SoloSessionAction ({ collaborativeSession, soloSession, articleId }) {
+ const { t } = useTranslation()
const { setToast } = useToasts()
const history = useHistory()
+ const [activeSoloSessionCreator, setActiveSoloSessionCreator] = useState('')
const mutation = useMutation()
-
+ const {
+ visible: takeOverModalVisible,
+ setVisible: setTakeOverModalVisible,
+ bindings: takeOverModalBinding
+ } = useModal()
const handleStartSoloEditing = useCallback(async () => {
- // try to start a collaborative editing
+ console.log({soloSession})
if (soloSession && soloSession.id) {
- // join existing solo session
- history.push(`/article/${articleId}`)
+ setActiveSoloSessionCreator(soloSession.creatorUsername)
+ setTakeOverModalVisible(true)
} else {
// start a new solo session
try {
@@ -36,7 +43,20 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession,
}
}
}
- }, [soloSession])
+ }, [soloSession, setTakeOverModalVisible])
+ const handleTakeOver = useCallback(async () => {
+ try {
+ await mutation({ query: takeOverSoloSession, variables: { articleId } })
+ setTakeOverModalVisible(false)
+ history.push(`/article/${articleId}`)
+ } catch (err) {
+ setToast({
+ type: 'error',
+ text: `Unable to take over this solo session: ${err.toString()}`
+ }
+ )
+ }
+ }, [setTakeOverModalVisible])
if (collaborativeSession) {
return <>>
@@ -44,6 +64,14 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession,
return (
<>
+
+ Take over
+
+ Would you like to take over the editing session from {activeSoloSessionCreator}?
+
+ setTakeOverModalVisible(false)}>{t('modal.cancelButton.text')}
+ handleTakeOver()}>{t('modal.confirmButton.text')}
+
{soloSession && }
@@ -55,7 +83,8 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession,
SoloSessionAction.propTypes = {
articleId: PropTypes.string.isRequired,
soloSession: PropTypes.shape({
- id: PropTypes.string
+ id: PropTypes.string,
+ creatorUsername: PropTypes.string
}),
collaborativeSession: PropTypes.shape({
id: PropTypes.string
diff --git a/front/src/components/tag/TagsList.jsx b/front/src/components/tag/TagsList.jsx
index 833509611..8b7f96eac 100644
--- a/front/src/components/tag/TagsList.jsx
+++ b/front/src/components/tag/TagsList.jsx
@@ -13,7 +13,7 @@ import useGraphQL from '../../hooks/graphql'
export default function TagsList () {
const { t } = useTranslation()
const dispatch = useDispatch()
- const selectedTagIds = useSelector(state => state.activeUser.selectedTagIds)
+ const selectedTagIds = useSelector(state => state.activeUser.selectedTagIds || [])
const { visible: createTagVisible, setVisible: setCreateTagVisible, bindings: createTagModalBinding } = useModal()
const { data, isLoading } = useGraphQL({ query: getTags, variables: {} }, {
revalidateOnFocus: false,
diff --git a/front/src/locales/en/translation.json b/front/src/locales/en/translation.json
index d987063be..9c19d844b 100644
--- a/front/src/locales/en/translation.json
+++ b/front/src/locales/en/translation.json
@@ -49,8 +49,12 @@
"article.collaborativeSessionEnd.confirmMessage": "Are you sure you want to end this collaborative session?",
"article.collaborativeSessionActive.title": "Active collaborative session",
"article.collaborativeSessionActive.message": "This article is read-only since there's an active collaborative session. If you want to edit this article, please join the collaborative session.",
- "article.soloSessionActive.title": "Active solo session",
- "article.soloSessionActive.message": "This article is read-only because someone is already editing. If you want to edit this article, please start a collaborative session.",
+ "article.soloSessionActive.title": "Active editing session",
+ "article.soloSessionActive.message": "This article is read-only because someone is already editing. If you want to edit this article, please take over the editing session or start a collaborative session.",
+ "article.soloSessionTakeOver.title": "Editing session taken over",
+ "article.soloSessionTakeOver.message": "This article is read-only because {{username}} has took over your editing session. If you'd like to continue editing this article, you can take back this session or start a collaborative session.",
+ "version.editingSessionEnded.text": "Editing session ended",
+ "version.collaborativeSessionEnded.text": "Collaborative session ended",
"modal.cancelButton.text": "Cancel",
"modal.confirmButton.text": "Confirm",
"modal.close.text": "Close",
diff --git a/front/src/locales/fr/translation.json b/front/src/locales/fr/translation.json
index 786f17b70..aa5b5bef7 100644
--- a/front/src/locales/fr/translation.json
+++ b/front/src/locales/fr/translation.json
@@ -49,8 +49,12 @@
"article.collaborativeSessionEnd.confirmMessage": "Souhaitez-vous terminer la session collaborative ?",
"article.collaborativeSessionActive.title": "Session collaborative en cours",
"article.collaborativeSessionActive.message": "Cet article est en lecture seule car une session collaborative est en cours. Si vous souhaitez modifier cet article, merci de rejoindre la session collaborative.",
- "article.soloSessionActive.title": "Session solo en cours",
- "article.soloSessionActive.message": "Cet article est en lecture seule car il est en cours d'édition. Si vous souhaitez modifier cet article, merci de créer une session collaborative.",
+ "article.soloSessionActive.title": "Session d'édition en cours",
+ "article.soloSessionActive.message": "Cet article est en lecture seule car il est en cours d'édition. Si vous souhaitez éditer cet article, merci de prendre la main sur la session d'édition ou de créer une session collaborative.",
+ "article.soloSessionTakeOver.title": "Session d'édition terminée",
+ "article.soloSessionTakeOver.message": "Cet article est en lecture seule car l'utilisateur {{username}} a pris la main sur la session d'édition. Si vous souhaitez continuer d'éditer cet article, merci de récupérer la session d'édition ou de créer une session collaborative",
+ "version.editingSessionEnded.text": "Session d'édition terminée",
+ "version.collaborativeSessionEnded.text": "Session collaborative terminée",
"modal.cancelButton.text": "Annuler",
"modal.confirmButton.text": "Confirmer",
"modal.close.text": "Fermer",
diff --git a/front/src/services/ArticleService.graphql b/front/src/services/ArticleService.graphql
index 095072f69..75d8d6185 100644
--- a/front/src/services/ArticleService.graphql
+++ b/front/src/services/ArticleService.graphql
@@ -20,6 +20,7 @@ mutation createVersion ($articleId: ID!, $userId: ID!, $major: Boolean!, $messag
displayName
username
}
+ type
}
}
}
diff --git a/graphql/data/defaultsData.js b/graphql/data/defaultsData.js
index 2b35831e0..7ba7bf193 100644
--- a/graphql/data/defaultsData.js
+++ b/graphql/data/defaultsData.js
@@ -65,7 +65,6 @@ issueid: ''
ordseq: ''
---`,
title:'New article',
- sommaire:'',
bib: '',
md: `## Section Title
diff --git a/graphql/events.js b/graphql/events.js
index b16a728a4..b47d872f7 100644
--- a/graphql/events.js
+++ b/graphql/events.js
@@ -1,3 +1,4 @@
+const crypto = require('node:crypto')
let clients = []
const handleEvents = (request, response) => {
@@ -7,7 +8,7 @@ const handleEvents = (request, response) => {
'Cache-Control': 'no-cache'
}
response.writeHead(200, headers)
- const clientId = Date.now()
+ const clientId = crypto.randomUUID()
const newClient = {
id: clientId,
response
diff --git a/graphql/models/article.js b/graphql/models/article.js
index c5d2272a7..7ff684144 100644
--- a/graphql/models/article.js
+++ b/graphql/models/article.js
@@ -85,6 +85,10 @@ const articleSchema = new Schema({
type: Schema.Types.ObjectId,
ref: 'User'
},
+ creatorUsername: {
+ type: String,
+ default: ''
+ },
createdAt: {
type: Schema.Types.Date
}
diff --git a/graphql/models/version.js b/graphql/models/version.js
index 2db4c5069..ec58bbcc3 100644
--- a/graphql/models/version.js
+++ b/graphql/models/version.js
@@ -1,28 +1,28 @@
-const mongoose = require('mongoose');
-const Schema = mongoose.Schema;
+const mongoose = require('mongoose')
+const Schema = mongoose.Schema
const { deriveToc } = require('../helpers/markdown.js')
const versionSchema = new Schema({
- owner:{
+ owner: {
type: Schema.Types.ObjectId,
ref: 'User'
},
- title:{
- type:String,
- default:''
+ title: {
+ type: String,
+ default: ''
},
- version:{
- type:Number,
- default:0
+ version: {
+ type: Number,
+ default: 0
},
- revision:{
- type:Number,
- default:0
+ revision: {
+ type: Number,
+ default: 0
},
- message:{
- type:String,
- default:''
+ message: {
+ type: String,
+ default: ''
},
md: {
type: String,
@@ -36,11 +36,15 @@ const versionSchema = new Schema({
default: ''
},
bib: String,
- sommaire:{
+ sommaire: {
type: String,
default: ''
},
-}, {timestamps:true});
+ type: {
+ type: String,
+ default: ''
+ }
+}, { timestamps: true })
-module.exports = mongoose.model('Version', versionSchema);
+module.exports = mongoose.model('Version', versionSchema)
module.exports.schema = versionSchema
diff --git a/graphql/package-lock.json b/graphql/package-lock.json
index 7933c931e..c43ba76cc 100644
--- a/graphql/package-lock.json
+++ b/graphql/package-lock.json
@@ -34,7 +34,6 @@
"pino": "^7.11.0",
"pino-http": "^7.0.0",
"remove-markdown": "^0.5.0",
- "uid-generator": "^2.0.0",
"ws": "^8.13.0",
"y-websocket": "^1.5.0"
},
@@ -8736,14 +8735,6 @@
"node": ">=4.2.0"
}
},
- "node_modules/uid-generator": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/uid-generator/-/uid-generator-2.0.0.tgz",
- "integrity": "sha512-XLRw2UyViQueSbd3dOHkswrg4gA4YuhibKzkFiPkilo6cdKEQqOX3K/Yu6Z2WXVMK+npfMNlSSufVSUifbXoOQ==",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -16102,11 +16093,6 @@
"dev": true,
"peer": true
},
- "uid-generator": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/uid-generator/-/uid-generator-2.0.0.tgz",
- "integrity": "sha512-XLRw2UyViQueSbd3dOHkswrg4gA4YuhibKzkFiPkilo6cdKEQqOX3K/Yu6Z2WXVMK+npfMNlSSufVSUifbXoOQ=="
- },
"uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
diff --git a/graphql/package.json b/graphql/package.json
index 4296d1feb..710436d44 100644
--- a/graphql/package.json
+++ b/graphql/package.json
@@ -43,7 +43,6 @@
"pino": "^7.11.0",
"pino-http": "^7.0.0",
"remove-markdown": "^0.5.0",
- "uid-generator": "^2.0.0",
"ws": "^8.13.0",
"y-websocket": "^1.5.0"
},
diff --git a/graphql/resolvers/articleResolver.js b/graphql/resolvers/articleResolver.js
index 78152f6ed..2bd5d3dd6 100644
--- a/graphql/resolvers/articleResolver.js
+++ b/graphql/resolvers/articleResolver.js
@@ -14,6 +14,7 @@ const { reformat } = require('../helpers/metadata.js')
const { computeMajorVersion, computeMinorVersion } = require('../helpers/versions')
const { previewEntries } = require('../helpers/bibliography')
const { notifyArticleStatusChange } = require('../events')
+const { logger } = require('../logger')
async function getUser (userId) {
@@ -86,6 +87,77 @@ async function getArticleByUser (articleId, userId) {
return article
}
+async function createSoloSession (article, user, force = false) {
+ if (article.soloSession && article.soloSession.id) {
+ if (article.soloSession.creator._id.equals(user._id)) {
+ return article.soloSession
+ }
+ if (!force) {
+ throw new ApiError('UNAUTHORIZED_SOLO_SESSION_ACTIVE', `A solo session is already active!`)
+ }
+ }
+ const soloSessionId = new ObjectId()
+ const soloSession = {
+ id: soloSessionId,
+ creator: user._id,
+ creatorUsername: user.username,
+ createdAt: new Date()
+ }
+ article.soloSession = soloSession
+ await article.save()
+ notifyArticleStatusChange(article)
+ return soloSession
+}
+
+async function createVersion (article, { major, message, userId, type }) {
+ const { bib, yaml, md } = article.workingVersion
+
+ /** @type {Query>|Array} */
+ const latestVersions = await Version.find({ _id: { $in: article.versions.map((a) => a._id) } })
+ .sort({ createdAt: -1 })
+ .limit(1)
+
+ if (type !== 'userAction') {
+ if (latestVersions?.length > 0) {
+ const latestVersion = latestVersions[0]
+ if (bib === latestVersion.bib && yaml === latestVersion.yaml && md === latestVersion.md) {
+ logger.info("Won't create a new version since there's no change", {
+ action: "createVersion",
+ articleId: article._id
+ })
+ return false
+ }
+ }
+ }
+
+ let mostRecentVersion = { version: 0, revision: 0 }
+ const latestUserVersions = latestVersions?.filter(v => v.type === undefined || v.type === 'userAction')
+ if (latestUserVersions?.length > 0) {
+ const latestVersion = latestVersions[0]
+ mostRecentVersion = {
+ version: latestVersion.version,
+ revision: latestVersion.revision,
+ }
+ }
+ const { revision, version } = major
+ ? computeMajorVersion(mostRecentVersion)
+ : computeMinorVersion(mostRecentVersion)
+
+ const createdVersion = await Version.create({
+ md,
+ yaml,
+ bib,
+ version,
+ revision,
+ message: message,
+ owner: userId,
+ type: type || 'userAction'
+ })
+ await createdVersion.populate('owner').execPopulate()
+ article.versions.unshift(createdVersion)
+ return true
+}
+
module.exports = {
Mutation: {
/**
@@ -319,35 +391,7 @@ module.exports = {
},
async createVersion (article, { articleVersionInput }) {
- const { bib, yaml, md } = article.workingVersion
-
- /** @type {Query>|Array} */
- const latestVersions = await Version.find({ _id: { $in: article.versions.map((a) => a._id) } })
- .sort({ createdAt: -1 })
- .limit(1)
-
- let mostRecentVersion = { version: 0, revision: 0 }
- if (latestVersions?.length > 0 ) {
- mostRecentVersion = {
- version: latestVersions[0].version,
- revision: latestVersions[0].revision,
- }
- }
- const { revision, version } = articleVersionInput.major
- ? computeMajorVersion(mostRecentVersion)
- : computeMinorVersion(mostRecentVersion)
-
- const createdVersion = await Version.create({
- md,
- yaml,
- bib,
- version,
- revision,
- message: articleVersionInput.message,
- owner: articleVersionInput.userId,
- }).then((v) => v.populate('owner').execPopulate())
-
- article.versions.unshift(createdVersion)
+ await createVersion(article, articleVersionInput)
await article.save()
return article
},
@@ -372,10 +416,11 @@ module.exports = {
yState.insert(0, 'started')
await article.save()
+ notifyArticleStatusChange(article)
return collaborativeSession
},
- async stopCollaborativeSession(article) {
+ async stopCollaborativeSession(article, _, { user }) {
if (article.collaborativeSession && article.collaborativeSession.id) {
const yDoc = getYDoc(`ws/${article.collaborativeSession.id.toString()}`)
const yState = yDoc.getText('state')
@@ -385,29 +430,27 @@ module.exports = {
const yText = yDoc.getText('main')
article.workingVersion.md = yText.toString()
article.collaborativeSession = null
+ await createVersion(article, {
+ major: false,
+ message: '',
+ userId: user._id,
+ type: 'collaborativeSessionEnded'
+ })
await article.save()
+ notifyArticleStatusChange(article)
return article
}
return article
},
async startSoloSession(article, _, { user }) {
- if (article.soloSession && article.soloSession.id) {
- if (article.soloSession.creator._id.equals(user._id)) {
- return article.soloSession
- }
- throw new ApiError('UNAUTHORIZED_SOLO_SESSION_ACTIVE', `A solo session is already active!`)
- }
- const soloSessionId = new ObjectId()
- const soloSession = {
- id: soloSessionId,
- creator: user._id,
- createdAt: new Date()
- }
- article.soloSession = soloSession
- await article.save()
- notifyArticleStatusChange(article)
- return soloSession
+ return createSoloSession(article, user, false)
+ },
+
+ async takeOverSoloSession(article, _, { user }) {
+ // force!
+ // TODO: take over should save a new version of the article!
+ return createSoloSession(article, user, true)
},
async stopSoloSession(article, _, { user }) {
@@ -416,6 +459,12 @@ module.exports = {
throw new ApiError('UNAUTHORIZED', `Solo session ${article.soloSession.id} can only be ended by its creator ${article.soloSession.creator}.`)
}
article.soloSession = null
+ await createVersion(article, {
+ major: false,
+ message: '',
+ userId: user._id,
+ type: 'editingSessionEnded'
+ })
await article.save()
notifyArticleStatusChange(article)
}
diff --git a/graphql/schema.js b/graphql/schema.js
index fa35d10cb..a7c426554 100644
--- a/graphql/schema.js
+++ b/graphql/schema.js
@@ -86,6 +86,7 @@ type Version {
revision: Int
md: String
sommaire: String
+ type: String
yaml (options: YamlFormattingInput): String
bib: String
bibPreview: String
@@ -113,6 +114,7 @@ type CollaborativeSession {
type SoloSession {
id: ID
creator: User
+ creatorUsername: String
createdAt: DateTime
}
@@ -145,6 +147,7 @@ type Article {
createVersion(articleVersionInput: ArticleVersionInput!): Article
startCollaborativeSession: CollaborativeSession!
startSoloSession: SoloSession!
+ takeOverSoloSession: SoloSession!
stopCollaborativeSession: Article
stopSoloSession: Article
}
diff --git a/schema.graphql b/schema.graphql
index e9ccdb750..a86816d45 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -32,6 +32,7 @@ type Article {
stopCollaborativeSession: Article
stopSoloSession: Article
tags(limit: Int, page: Int): [Tag!]!
+ takeOverSoloSession: SoloSession!
title: String
updateWorkingVersion(content: WorkingVersionInput!): Article
updatedAt: DateTime
@@ -183,6 +184,7 @@ type Query {
type SoloSession {
createdAt: DateTime
creator: User
+ creatorUsername: String
id: ID
}
@@ -254,6 +256,7 @@ type Version {
rename(name: String): Boolean
revision: Int
sommaire: String
+ type: String
updatedAt: DateTime
version: Int
yaml(options: YamlFormattingInput): String