• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

EcrituresNumeriques / stylo / 21433867219

28 Jan 2026 10:06AM UTC coverage: 69.336% (-0.5%) from 69.865%
21433867219

Pull #1870

github

web-flow
Merge 2ecb40001 into 3e2a2d1b3
Pull Request #1870: Redesign de l'écran articles

731 of 1151 branches covered (63.51%)

Branch coverage included in aggregate %.

30 of 107 new or added lines in 4 files covered. (28.04%)

36 existing lines in 2 files now uncovered.

4013 of 5691 relevant lines covered (70.51%)

5.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.96
/graphql/resolvers/articleResolver.js
1
const { NotFoundError } = require('../helpers/errors.js')
1✔
2
const YAML = require('js-yaml')
1✔
3
const { WSSharedDoc } = require('@y/websocket-server/utils')
1✔
4

1✔
5
const Article = require('../models/article.js')
1✔
6
const User = require('../models/user.js')
1✔
7
const Corpus = require('../models/corpus.js')
1✔
8
const Workspace = require('../models/workspace.js')
1✔
9
const Version = require('../models/version.js')
1✔
10

1✔
11
const isUser = require('../policies/isUser.js')
1✔
12
const { reformat } = require('../helpers/metadata.js')
1✔
13
const {
1✔
14
  computeMajorVersion,
1✔
15
  computeMinorVersion,
1✔
16
} = require('../helpers/versions.js')
1✔
17
const { previewEntries } = require('../helpers/bibliography.js')
1✔
18
const { logger } = require('../logger.js')
1✔
19
const { toLegacyFormat } = require('../helpers/metadata.js')
1✔
20
const Y = require('yjs')
1✔
21
const { mongo } = require('mongoose')
1✔
22
const Sentry = require('@sentry/node')
1✔
23
const {
1✔
24
  NotAuthenticatedError,
1✔
25
  BadRequestError,
1✔
26
} = require('../helpers/errors.js')
1✔
27

1✔
28
function getTextFromYjsDoc(yjsdocBase64) {
×
29
  const wsDoc = new WSSharedDoc(`ws/${new mongo.ObjectID().toString()}`)
×
30
  try {
×
31
    Y.applyUpdate(wsDoc, Buffer.from(yjsdocBase64, 'base64'))
×
32
    return wsDoc.getText('main').toString()
×
33
  } finally {
×
34
    wsDoc.destroy()
×
35
  }
×
36
}
×
37

1✔
38
async function getUser(userId) {
2✔
39
  const user = await User.findById(userId)
2✔
40
  if (!user) {
2!
41
    throw new NotFoundError('User', userId)
×
42
  }
×
43
  return user
2✔
44
}
2✔
45

1✔
46
async function getArticleByContext(articleId, context) {
2✔
47
  if (context.token.admin === true) {
2!
48
    return await getArticle(articleId)
×
49
  }
×
50

2✔
51
  const userId = context.userId
2✔
52
  if (!userId) {
2!
53
    throw new NotAuthenticatedError()
×
54
  }
×
55

2✔
56
  return await getArticleByUser(articleId, userId)
2✔
57
}
2✔
58

1✔
59
async function getArticle(articleId) {
×
60
  const article = await Article.findById(articleId)
×
61
    .populate('owner tags')
×
62
    .populate({ path: 'contributors', populate: { path: 'user' } })
×
63

×
64
  if (!article) {
×
65
    throw new NotFoundError('Article', articleId)
×
66
  }
×
67

×
68
  return article
×
69
}
×
70

1✔
71
async function getArticleByUser(articleId, userId) {
2✔
72
  const userWorkspace = await Workspace.findOne({
2✔
73
    'members.user': userId,
2✔
74
    articles: articleId,
2✔
75
  })
2✔
76

2✔
77
  if (userWorkspace) {
2✔
78
    // article found in one of user's workspaces
1✔
79
    const article = await Article.findById(articleId)
1✔
80
      .populate('owner tags')
1✔
81
      .populate({ path: 'contributors', populate: { path: 'user' } })
1✔
82

1✔
83
    if (!article) {
1!
84
      throw new NotFoundError('Article', articleId)
×
85
    }
×
86
    return article
1✔
87
  }
1✔
88

1✔
89
  // find article by owner or contributors
1✔
90
  const article = await Article.findOne({
1✔
91
    _id: articleId,
1✔
92
    $or: [
1✔
93
      { owner: userId },
1✔
94
      { contributors: { $elemMatch: { user: userId } } },
1✔
95
    ],
1✔
96
  })
1✔
97
    .populate('owner tags')
1✔
98
    .populate({ path: 'contributors', populate: { path: 'user' } })
1✔
99

1✔
100
  if (!article) {
2!
101
    throw new NotFoundError('Article', articleId)
×
102
  }
×
103
  return article
1✔
104
}
2✔
105

1✔
106
async function createVersion(article, { major, message, userId, type }) {
×
107
  const { bib, metadata, metadataFormType, ydoc } = article.workingVersion
×
108

×
109
  const md = getTextFromYjsDoc(ydoc)
×
110

×
111
  /** @type {Query<Array<Article>>|Array<Article>} */
×
112
  const latestVersions = await Version.find({
×
113
    _id: { $in: article.versions.map((a) => a._id) },
×
114
  }).sort({ createdAt: -1 })
×
115

×
116
  if (latestVersions?.length > 0) {
×
117
    const latestVersion = latestVersions[0]
×
118
    if (
×
119
      bib === latestVersion.bib &&
×
120
      metadata === latestVersion.metadata &&
×
121
      md === latestVersion.md
×
122
    ) {
×
123
      logger.info("Won't create a new version since there's no change", {
×
124
        action: 'createVersion',
×
125
        articleId: article._id,
×
126
      })
×
127
      return false
×
128
    }
×
129
  }
×
130

×
131
  let mostRecentVersion = { version: 0, revision: 0 }
×
132
  const latestUserVersions = latestVersions?.filter(
×
133
    (v) => !v.type || v.type === 'userAction'
×
134
  )
×
135
  if (latestUserVersions?.length > 0) {
×
136
    const latestUserVersion = latestUserVersions[0]
×
137
    mostRecentVersion = {
×
138
      version: latestUserVersion.version,
×
139
      revision: latestUserVersion.revision,
×
140
    }
×
141
  }
×
142
  const { revision, version } = major
×
143
    ? computeMajorVersion(mostRecentVersion)
×
144
    : computeMinorVersion(mostRecentVersion)
×
145

×
146
  const createdVersion = await Version.create({
×
147
    md,
×
148
    metadata,
×
149
    metadataFormType,
×
150
    bib,
×
151
    version,
×
152
    revision,
×
153
    message: message,
×
154
    owner: userId,
×
155
    type: type || 'userAction',
×
156
  })
×
157
  await createdVersion.populate('owner')
×
158
  article.versions.unshift(createdVersion._id)
×
159
  return true
×
160
}
×
161

1✔
NEW
162
function eqSet(a, b) {
×
NEW
163
  return a.size === b.size && a.intersection(b).size === a.size
×
NEW
164
}
×
165

1✔
166
module.exports = {
1✔
167
  Mutation: {
1✔
168
    /**
1✔
169
     * Update an article as the current user
1✔
170
     * @param {null} _root
1✔
171
     * @param {*} args
1✔
172
     * @param {{ userId, token }} context
1✔
173
     */
1✔
174
    async updateArticle(_root, args, context) {
1✔
NEW
175
      const { id, title, tags, workspaces } = args.updateArticleInput
×
NEW
176
      const article = await getArticleByContext(id, context)
×
NEW
177
      if (title) {
×
NEW
178
        article.title = title
×
NEW
179
      }
×
NEW
180
      if (Array.isArray(tags)) {
×
NEW
181
        article.tags = tags
×
NEW
182
      }
×
NEW
183
      if (Array.isArray(workspaces)) {
×
NEW
184
        const currentWorkspaces = await Workspace.findByArticleId(id)
×
NEW
185
        const currentWorkspacesSet = new Set(
×
NEW
186
          currentWorkspaces.map((w) => w._id.toString())
×
NEW
187
        )
×
NEW
188
        const workspacesSet = new Set(workspaces)
×
NEW
189
        if (!eqSet(workspacesSet, currentWorkspacesSet)) {
×
NEW
190
          const additions = workspacesSet.difference(currentWorkspacesSet)
×
NEW
191
          for (const additionWorkspaceId of additions) {
×
NEW
192
            await Workspace.addArticle(additionWorkspaceId, article._id)
×
NEW
193
          }
×
NEW
194
          const deletions = currentWorkspacesSet.difference(workspacesSet)
×
NEW
195
          for (const deletionWorkspaceId of deletions) {
×
NEW
196
            await Workspace.deleteArticle(deletionWorkspaceId, article._id)
×
NEW
197
          }
×
NEW
198
        }
×
NEW
199
      }
×
NEW
200
      await article.save()
×
NEW
201
      return article.populate('tags')
×
202
    },
1✔
203

1✔
204
    /**
1✔
205
     * Create an article as the current user
1✔
206
     * @param {null} _root
1✔
207
     * @param {*} args
1✔
208
     * @param {{ userId, token }} context
1✔
209
     */
1✔
210
    async createArticle(_root, args, context) {
1✔
211
      const user = await getUser(context.userId)
×
212
      const { title, tags, workspaces } = args.createArticleInput
×
213

×
214
      //Add default article + default version
×
215
      const yDoc = new Y.Doc({ gc: false })
×
216
      const yText = yDoc.getText('main')
×
217
      yText.insert(0, '')
×
218
      const documentState = Y.encodeStateAsUpdate(yDoc) // is a Uint8Array
×
219
      const newArticle = await Article.create({
×
220
        title,
×
221
        owner: user,
×
222
        workingVersion: {
×
223
          md: '',
×
224
          bib: '',
×
225
          metadata: {},
×
226
          ydoc: Buffer.from(documentState).toString('base64'),
×
227
        },
×
228
      })
×
229

×
230
      if (Array.isArray(tags) && tags.length) {
×
231
        await newArticle.addTags(...tags)
×
232
      }
×
233

×
234
      if (Array.isArray(workspaces) && workspaces.length) {
×
235
        for await (const id of workspaces) {
×
236
          const workspace = await Workspace.getWorkspaceById(id, user)
×
237

×
238
          if (!workspace) {
×
239
            throw new NotFoundError('Workspace', id)
×
240
          }
×
241

×
242
          workspace.articles.push(newArticle)
×
243
          await workspace.save()
×
244
        }
×
245
      }
×
246

×
247
      return newArticle
×
248
    },
1✔
249

1✔
250
    async article(_root, { articleId }, context) {
1✔
251
      return getArticleByContext(articleId, context)
×
252
    },
1✔
253

1✔
254
    /**
1✔
255
     * Share an article as the current user
1✔
256
     *
1✔
257
     * @param {*} _root
1✔
258
     * @param {*} args
1✔
259
     * @param {{ userId, token }} context
1✔
260
     * @returns
1✔
261
     */
1✔
262
    async shareArticle(_root, args, context) {
1✔
263
      const withUser = await getUser(args.to)
×
264
      const article = await getArticleByContext(args.article, context)
×
265
      await article.shareWith(withUser)
×
266
      return article
×
267
    },
1✔
268

1✔
269
    /**
1✔
270
     * Unshare an article as the current user
1✔
271
     *
1✔
272
     * @param {null} _root
1✔
273
     * @param {*} args
1✔
274
     * @param {{ userId, token }} context
1✔
275
     * @returns
1✔
276
     */
1✔
277
    async unshareArticle(_root, args, context) {
1✔
278
      const withUser = await getUser(args.to)
×
279
      const article = await getArticleByContext(args.article, context)
×
280
      await article.unshareWith(withUser)
×
281
      return article
×
282
    },
1✔
283

1✔
284
    /**
1✔
285
     * Duplicate an article as the current user
1✔
286
     *
1✔
287
     * @param {null} _root
1✔
288
     * @param {*} args
1✔
289
     * @param {{ userId, token }} context
1✔
290
     * @returns {Promise<import('./article')>}
1✔
291
     */
1✔
292
    async duplicateArticle(_root, args, context) {
1✔
293
      const withUser = await getUser(args.to)
2✔
294
      const article = await getArticleByContext(args.article, context)
2✔
295
      const userId = context.userId
2✔
296
      const prefix = userId === args.to ? '[Copy] ' : '[Sent] '
2!
297

2✔
298
      const newArticle = new Article({
2✔
299
        ...article.toObject(),
2✔
300
        _id: undefined,
2✔
301
        owner: withUser.id,
2✔
302
        contributors: [],
2✔
303
        versions: [],
2✔
304
        createdAt: null,
2✔
305
        updatedAt: null,
2✔
306
        title: prefix + article.title,
2✔
307
      })
2✔
308

2✔
309
      newArticle.isNew = true
2✔
310
      await newArticle.save()
2✔
311

2✔
312
      // Maintain links
2✔
313
      await Corpus.updateMany(
2✔
314
        { 'articles.article': article._id },
2✔
315
        { $push: { articles: { article: newArticle._id } } }
2✔
316
      )
2✔
317

2✔
318
      await Workspace.updateMany(
2✔
319
        { articles: article },
2✔
320
        { $push: { articles: [newArticle._id] } }
2✔
321
      )
2✔
322

2✔
323
      return newArticle
2✔
324
    },
1✔
325
  },
1✔
326

1✔
327
  Query: {
1✔
328
    /**
1✔
329
     * Fetch an article as the current user
1✔
330
     *
1✔
331
     * @param {null} _root
1✔
332
     * @param {{ article: string }} args
1✔
333
     * @param {{ loaders: { article }, userId, token }} context
1✔
334
     * @returns
1✔
335
     */
1✔
336
    async article(_root, args, context) {
1✔
337
      return await getArticleByContext(args.article, context)
×
338
    },
1✔
339

1✔
340
    async sharedArticle(_root, args) {
1✔
341
      return getArticle(args.article)
×
342
    },
1✔
343

1✔
344
    /**
1✔
345
     * Fetch all the articles related to a user:
1✔
346
     * - one stated by the JWT token (context.user), a User object
1✔
347
     * - one we are supposedly able ot impersonate (args.user), an ID
1✔
348
     *
1✔
349
     * We list:
1✔
350
     * - their articles
1✔
351
     * - their directly shared articles
1✔
352
     * - BUT not the granted account shared articles — we switch into their view for this
1✔
353
     *
1✔
354
     * @param {null} _root
1✔
355
     * @param {{ user?: String, filter?: { workspaceId?: string, corpusId?: string } }} args
1✔
356
     * @param {{ user: User, token: Object, userId: String, loaders: { tags, users } }} context
1✔
357
     * @returns {Promise<import('./article')[]>}
1✔
358
     */
1✔
359
    async articles(_root, args, context) {
1✔
360
      const { userId } = isUser(args, context)
3✔
361
      let filterIds = null
3✔
362

3✔
363
      // prefilter by articles contained in a workspace or corpus
3✔
364
      if (args.filter?.workspaceId) {
3✔
365
        const workspace = await Workspace.findById(
1✔
366
          args.filter.workspaceId,
1✔
367
          'articles'
1✔
368
        ).lean()
1✔
369
        filterIds = [...workspace.articles]
1✔
370
      }
1✔
371

2✔
372
      return Article.getArticles({
2✔
373
        filter: !Array.isArray(filterIds)
2✔
374
          ? {
3✔
375
              $or: [
1✔
376
                { owner: userId },
1✔
377
                { contributors: { $elemMatch: { user: userId } } },
1✔
378
              ],
1✔
379
            }
1✔
380
          : {
3✔
381
              _id: { $in: filterIds },
1✔
382
            },
3✔
383
        loaders: context.loaders,
3✔
384
      })
3✔
385
    },
1✔
386
  },
1✔
387

1✔
388
  Article: {
1✔
389
    async workspaces(article, _, { user, token }) {
1✔
390
      if (token.admin) {
2✔
391
        return Workspace.find({ articles: article._id })
1✔
392
      }
1✔
393
      return Workspace.find({
1✔
394
        $and: [{ articles: article._id }, { 'members.user': user._id }],
1✔
395
      })
1✔
396
    },
1✔
397

1✔
398
    async removeContributor(article, { userId }) {
1✔
399
      const contributorUser = await getUser(userId)
×
400
      await article.unshareWith(contributorUser)
×
401
      return article
×
402
    },
1✔
403

1✔
404
    async addContributor(article, { userId }) {
1✔
405
      const contributorUser = await User.findById(userId)
×
406
      if (!contributorUser) {
×
407
        throw new Error(`Unable to find user with id: ${userId}`)
×
408
      }
×
409
      await article.shareWith(contributorUser)
×
410
      return article
×
411
    },
1✔
412

1✔
413
    async versions(article, _args, context) {
1✔
414
      const versions = (
×
415
        await Promise.all(
×
416
          article.versions.map(
×
417
            async (versionId) => await context.loaders.versions.load(versionId)
×
418
          )
×
419
        )
×
420
      ).filter((v) => v) // ignore unresolved versions
×
421
      versions.sort((a, b) => b.createdAt - a.createdAt)
×
422
      return versions
×
423
    },
1✔
424

1✔
425
    /**
1✔
426
     * Delete an article.
1✔
427
     *
1✔
428
     * @param {import('mongoose').Document<ObjectId, any, Article>} article
1✔
429
     * @returns {boolean} true if the article was deleted, false otherwise
1✔
430
     */
1✔
431
    async delete(article) {
1✔
432
      await article.remove()
1✔
433
      return article.$isDeleted()
1✔
434
    },
1✔
435

1✔
436
    async rename(article, { title }) {
1✔
437
      article.set('title', title)
×
438
      const result = await article.save({ timestamps: false })
×
439
      return result === article
×
440
    },
1✔
441

1✔
442
    async setZoteroLink(article, { zotero }) {
1✔
443
      article.set('zoteroLink', zotero)
×
444
      const result = await article.save({ timestamps: false })
×
445
      return result === article
×
446
    },
1✔
447

1✔
448
    async addTags(article, { tags }) {
1✔
449
      await article.addTags(...tags)
×
450
      return article.tags
×
451
    },
1✔
452

1✔
453
    async removeTags(article, { tags }) {
1✔
454
      await article.removeTags(...tags)
×
455
      return article.tags
×
456
    },
1✔
457

1✔
458
    async setPreviewSettings(article, { settings }) {
1✔
459
      await article.set('preview', settings, { merge: true }).save()
×
460
      return article
×
461
    },
1✔
462

1✔
463
    async updateWorkingVersion(article, { content }) {
1✔
464
      Object.entries(content).forEach(([key, value]) =>
×
465
        article.set(`workingVersion.${key}`, value)
×
466
      )
×
467

×
468
      return article.save()
×
469
    },
1✔
470

1✔
471
    /**
1✔
472
     * @param article
1✔
473
     * @param articleVersionInput
1✔
474
     * @param context
1✔
475
     * @return {Promise<Article>}
1✔
476
     */
1✔
477
    async createVersion(article, { articleVersionInput }) {
1✔
478
      const result = await createVersion(article, {
×
479
        ...articleVersionInput,
×
480
        type: 'userAction',
×
481
      })
×
482
      if (result === false) {
×
483
        throw new BadRequestError(
×
484
          'NO_CHANGE',
×
485
          'Unable to create a new version since there is no change'
×
486
        )
×
487
      }
×
488
      await article.save()
×
489
      return article
×
490
    },
1✔
491
  },
1✔
492

1✔
493
  WorkingVersion: {
1✔
494
    md({ ydoc = '' }) {
1✔
495
      try {
×
496
        return getTextFromYjsDoc(ydoc)
×
497
      } catch (err) {
×
498
        Sentry.captureException(err)
×
499
        console.error(
×
500
          'Unable to load text content (Markdown) from the Y.js document on article',
×
501
          err
×
502
        )
×
503
        return ''
×
504
      }
×
505
    },
1✔
506
    bibPreview({ bib }) {
1✔
507
      return previewEntries(bib)
×
508
    },
1✔
509
    yaml({ metadata = {} }, { options }) {
1✔
510
      const legacyMetadata = toLegacyFormat(metadata)
×
511
      const yaml = YAML.dump(legacyMetadata)
×
512
      return options?.strip_markdown
×
513
        ? reformat(yaml, { replaceBibliography: false })
×
514
        : yaml
×
515
    },
1✔
516
  },
1✔
517
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc