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

EcrituresNumeriques / stylo / 15922807606

27 Jun 2025 09:18AM UTC coverage: 39.241% (+0.04%) from 39.201%
15922807606

push

github

web-flow
Crée des requêtes de prévisualisation d'articles/corpus (#1592)

Co-authored-by: Thomas Parisot <thom4parisot@users.noreply.github.com>
Co-authored-by: Guillaume Grossetie <ggrossetie@yuzutech.fr>

570 of 800 branches covered (71.25%)

Branch coverage included in aggregate %.

90 of 250 new or added lines in 17 files covered. (36.0%)

137 existing lines in 8 files now uncovered.

5638 of 15020 relevant lines covered (37.54%)

2.66 hits per line

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

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

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

1✔
10
const isUser = require('../policies/isUser.js')
1✔
11
const { ApiError } = require('../helpers/errors.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

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

1✔
34
async function getUser (userId) {
2✔
35
  const user = await User.findById(userId)
2✔
36
  if (!user) {
2!
UNCOV
37
    throw new ApiError('NOT_FOUND', `Unable to find user with id ${userId}`)
×
NEW
38
  }
×
39
  return user
2✔
40
}
2✔
41

1✔
42
async function getArticleByContext (articleId, context) {
2✔
43
  if (context.token.admin === true) {
2!
UNCOV
44
    return await getArticle(articleId)
×
UNCOV
45
  }
×
46

2✔
47
  const userId = context.userId
2✔
48
  if (!userId) {
2!
49
    throw new ApiError(
×
UNCOV
50
      'UNAUTHENTICATED',
×
UNCOV
51
      `Unable to find an authentication context: ${JSON.stringify(context)}`
×
UNCOV
52
    )
×
NEW
53
  }
×
54
  return await getArticleByUser(articleId, userId)
2✔
55
}
2✔
56

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

×
62
  if (!article) {
×
63
    throw new ApiError(
×
64
      'NOT_FOUND',
×
NEW
65
      `Unable to find article with id ${articleId}`
×
66
    )
×
NEW
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!
NEW
84
      throw new ApiError(
×
85
        'NOT_FOUND',
×
UNCOV
86
        `Unable to find article with id ${articleId}`
×
UNCOV
87
      )
×
UNCOV
88
    }
×
89
    return article
1✔
90
  }
1✔
91

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

1✔
103
  if (!article) {
2!
UNCOV
104
    throw new ApiError(
×
UNCOV
105
      'NOT_FOUND',
×
NEW
106
      `Unable to find article with id ${articleId}`
×
107
    )
×
108
  }
✔
109
  return article
1✔
110
}
1✔
111

1✔
112
async function createVersion (article, { major, message, userId, type }) {
×
113
  const { bib, metadata, ydoc } = article.workingVersion
×
114

×
115
  const md = getTextFromYjsDoc(ydoc)
×
116

×
117
  /** @type {Query<Array<Article>>|Array<Article>} */
×
118
  const latestVersions = await Version.find({
×
119
    _id: { $in: article.versions.map((a) => a._id) },
×
120
  }).sort({ createdAt: -1 })
×
121

×
122
  if (latestVersions?.length > 0) {
×
NEW
123
    const latestVersion = latestVersions[0]
×
124
    if (
×
125
      bib === latestVersion.bib &&
×
126
      metadata === latestVersion.metadata &&
×
127
      md === latestVersion.md
×
128
    ) {
×
129
      logger.info('Won\'t create a new version since there\'s no change', {
×
130
        action: 'createVersion',
×
131
        articleId: article._id,
×
132
      })
×
133
      return false
×
134
    }
×
135
  }
×
136

×
137
  let mostRecentVersion = { version: 0, revision: 0 }
×
138
  const latestUserVersions = latestVersions?.filter(
×
139
    (v) => !v.type || v.type === 'userAction'
×
140
  )
×
141
  if (latestUserVersions?.length > 0) {
×
142
    const latestUserVersion = latestUserVersions[0]
×
143
    mostRecentVersion = {
×
144
      version: latestUserVersion.version,
×
145
      revision: latestUserVersion.revision,
×
146
    }
×
147
  }
×
148
  const { revision, version } = major
×
149
    ? computeMajorVersion(mostRecentVersion)
×
150
    : computeMinorVersion(mostRecentVersion)
×
151

×
152
  const createdVersion = await Version.create({
×
153
    md,
×
154
    metadata,
×
155
    bib,
×
156
    version,
×
157
    revision,
×
158
    message: message,
×
159
    owner: userId,
×
UNCOV
160
    type: type || 'userAction',
×
UNCOV
161
  })
×
UNCOV
162
  await createdVersion.populate('owner').execPopulate()
×
UNCOV
163
  article.versions.unshift(createdVersion._id)
×
UNCOV
164
  return true
×
UNCOV
165
}
×
166

1✔
167
module.exports = {
1✔
168
  Mutation: {
1✔
169
    /**
1✔
170
     * Create an article as the current user
1✔
171
     * @param {null} _root
1✔
172
     * @param {*} args
1✔
173
     * @param {{ userId, token }} context
1✔
174
     */
1✔
175
    async createArticle (_root, args, context) {
1✔
176
      const user = await getUser(context.userId)
×
177
      const { title, tags, workspaces } = args.createArticleInput
×
178

×
179
      //Add default article + default version
×
180
      const yDoc = new Y.Doc({ gc: false })
×
181
      const yText = yDoc.getText('main')
×
182
      yText.insert(0, '')
×
183
      const documentState = Y.encodeStateAsUpdate(yDoc) // is a Uint8Array
×
184
      const newArticle = await Article.create({
×
185
        title,
×
186
        owner: user,
×
187
        workingVersion: {
×
188
          md: '',
×
189
          bib: '',
×
190
          metadata: {},
×
191
          ydoc: Buffer.from(documentState).toString('base64'),
×
192
        },
×
193
      })
×
194

×
195
      if (Array.isArray(tags) && tags.length) {
×
NEW
196
        await newArticle.addTags(...tags)
×
NEW
197
      }
×
NEW
198

×
NEW
199
      if (Array.isArray(workspaces) && workspaces.length) {
×
NEW
200
        for await (const id of workspaces) {
×
201
          const workspace = await Workspace.getWorkspaceById(id, user)
×
202
          workspace.articles.push(newArticle)
×
203
          await workspace.save()
×
204
        }
×
205
      }
×
206

×
207
      user.articles.push(newArticle)
×
208
      await user.save()
×
UNCOV
209
      return newArticle
×
210
    },
1✔
211

1✔
212
    async article (_root, { articleId }, context) {
1✔
UNCOV
213
      return getArticleByContext(articleId, context)
×
214
    },
1✔
215

1✔
216
    /**
1✔
217
     * Share an article as the current user
1✔
218
     *
1✔
219
     * @param {*} _root
1✔
220
     * @param {*} args
1✔
221
     * @param {{ userId, token }} context
1✔
222
     * @returns
1✔
223
     */
1✔
224
    async shareArticle (_root, args, context) {
1✔
225
      const withUser = await getUser(args.to)
×
226
      const article = await getArticleByContext(args.article, context)
×
227
      await article.shareWith(withUser)
×
UNCOV
228
      return article
×
229
    },
1✔
230

1✔
231
    /**
1✔
232
     * Unshare an article as the current user
1✔
233
     *
1✔
234
     * @param {null} _root
1✔
235
     * @param {*} args
1✔
236
     * @param {{ userId, token }} context
1✔
237
     * @returns
1✔
238
     */
1✔
239
    async unshareArticle (_root, args, context) {
1✔
240
      const withUser = await getUser(args.to)
×
241
      const article = await getArticleByContext(args.article, context)
×
242
      await article.unshareWith(withUser)
×
UNCOV
243
      return article
×
244
    },
1✔
245

1✔
246
    /**
1✔
247
     * Duplicate an article as the current user
1✔
248
     *
1✔
249
     * @param {null} _root
1✔
250
     * @param {*} args
1✔
251
     * @param {{ userId, token }} context
1✔
252
     * @returns
1✔
253
     */
1✔
254
    async duplicateArticle (_root, args, context) {
1✔
255
      const withUser = await getUser(args.to)
2✔
256
      const article = await getArticleByContext(args.article, context)
2✔
257
      const userId = context.userId
2✔
258
      const prefix = userId === args.to ? '[Copy] ' : '[Sent] '
2!
259

2✔
260
      const newArticle = new Article({
2✔
261
        ...article.toObject(),
2✔
262
        _id: undefined,
2✔
263
        owner: withUser.id,
2✔
264
        contributors: [],
2✔
265
        versions: [],
2✔
266
        createdAt: null,
2✔
267
        updatedAt: null,
2✔
268
        title: prefix + article.title,
2✔
269
      })
2✔
270

2✔
271
      newArticle.isNew = true
2✔
272
      withUser.articles.push(newArticle)
2✔
273

2✔
274
      await Promise.all([newArticle.save(), withUser.save()])
2✔
275

2✔
276
      // Maintain links
2✔
277
      await Corpus.updateMany(
2✔
278
        { 'articles.article': article._id },
2✔
279
        { $push: { articles: { article: newArticle._id } } }
2✔
280
      )
2✔
281

2✔
282
      await Workspace.updateMany(
2✔
283
        { articles: article },
2✔
284
        { $push: { articles: [newArticle._id] } }
2✔
285
      )
2✔
286

2✔
287
      return newArticle
2✔
288
    },
2✔
289
  },
1✔
290

1✔
291
  Query: {
1✔
292
    /**
1✔
293
     * Fetch an article as the current user
1✔
294
     *
1✔
295
     * @param {null} _root
1✔
296
     * @param {{ article: string }} args
1✔
297
     * @param {{ loaders: { article }, userId, token }} context
1✔
298
     * @returns
1✔
299
     */
1✔
300
    async article (_root, args, context) {
1✔
UNCOV
301
      return await getArticleByContext(args.article, context)
×
302
    },
1✔
303

1✔
304
    /**
1✔
305
     * Fetch all the articles related to a user:
1✔
306
     * - one stated by the JWT token (context.user), a User object
1✔
307
     * - one we are supposedly able ot impersonate (args.user), an ID
1✔
308
     *
1✔
309
     * We list:
1✔
310
     * - their articles
1✔
311
     * - their directly shared articles
1✔
312
     * - BUT not the granted account shared articles — we switch into their view for this
1✔
313
     *
1✔
314
     * @param {null} _root
1✔
315
     * @param {{ user?: String, filter?: { workspaceId?: string, corpusId?: string } }} args
1✔
316
     * @param {{ user: User, token: Object, userId: String, loaders: { tags, users } }} context
1✔
317
     * @returns {Promise<Article[]>}
1✔
318
     */
1✔
319
    async articles (_root, args, context) {
1✔
320
      const { userId } = isUser(args, context)
3✔
321
      let filterIds = null
3✔
322

3✔
323
      // prefilter by articles contained in a workspace or corpus
3✔
324
      if (args.filter?.workspaceId) {
3✔
325
        const workspace = await Workspace.findById(args.filter.workspaceId, 'articles').lean()
1✔
326
        filterIds = [...workspace.articles]
1✔
327
      }
1✔
328

2✔
329
      return Article.getArticles({
2✔
330
        filter: !Array.isArray(filterIds) ?
2✔
331
            {
1✔
332
              $or: [
1✔
333
                { owner: userId },
1✔
334
                { contributors: { $elemMatch: { user: userId } } },
1✔
335
              ]
1✔
336
            } : {
3✔
337
              _id: { $in: filterIds }
1✔
338
            },
3✔
339
        loaders: context.loaders,
3✔
340
      })
3✔
341
    },
3✔
342
  },
1✔
343

1✔
344
  Article: {
1✔
345
    async workspaces (article, _, { user, token }) {
1✔
346
      if (token.admin) {
2✔
347
        return Workspace.find({ articles: article._id })
1✔
348
      }
1✔
349
      return Workspace.find({
1✔
350
        $and: [{ articles: article._id }, { 'members.user': user._id }],
1✔
351
      })
1✔
352
    },
1✔
353

1✔
354
    async removeContributor (article, { userId }) {
1✔
UNCOV
355
      const contributorUser = await getUser(userId)
×
UNCOV
356
      await article.unshareWith(contributorUser)
×
UNCOV
357
      return article
×
358
    },
1✔
359

1✔
360
    async addContributor (article, { userId }) {
1✔
NEW
361
      const contributorUser = await User.findById(userId)
×
362
      if (!contributorUser) {
×
363
        throw new Error(`Unable to find user with id: ${userId}`)
×
364
      }
×
UNCOV
365
      await article.shareWith(contributorUser)
×
UNCOV
366
      return article
×
367
    },
1✔
368

1✔
369
    async versions (article, _args, context) {
1✔
370
      const versions = (
×
371
        await Promise.all(
×
372
          article.versions.map(
×
373
            async (versionId) => await context.loaders.versions.load(versionId)
×
UNCOV
374
          )
×
UNCOV
375
        )
×
NEW
376
      ).filter((v) => v) // ignore unresolved versions
×
377
      versions.sort((a, b) => b.createdAt - a.createdAt)
×
378
      return versions
×
379
    },
1✔
380

1✔
381
    /**
1✔
382
     * Delete an article.
1✔
383
     *
1✔
384
     * @param {import('mongoose').Document} article
1✔
385
     * @returns
1✔
386
     */
1✔
387
    async delete (article) {
1✔
388
      // remove article from workspaces
1✔
389
      await Workspace.updateMany(
1✔
390
        {},
1✔
391
        {
1✔
392
          $pull: {
1✔
393
            articles: article._id,
1✔
394
          },
1✔
395
        }
1✔
396
      )
1✔
397
      // remove article from corpus
1✔
398
      await Corpus.updateMany(
1✔
399
        {},
1✔
400
        {
1✔
401
          $pull:
1✔
402
            {
1✔
403
              articles: {
1✔
404
                article: article._id
1✔
405
              }
1✔
406
            }
1✔
407
        }
1✔
408
      )
1✔
409
      // remove article versions
1✔
410
      const versions = article.versions
1✔
411
      for (const version of versions) {
1✔
412
        await Version.findByIdAndDelete(version)
1✔
413
      }
1✔
414
      // remove article!
1✔
415
      await article.remove()
1✔
416
      return article.$isDeleted()
1✔
417
    },
1✔
418

1✔
419
    async rename (article, { title }) {
1✔
UNCOV
420
      article.set('title', title)
×
UNCOV
421
      const result = await article.save({ timestamps: false })
×
UNCOV
422
      return result === article
×
423
    },
1✔
424

1✔
425
    async setZoteroLink (article, { zotero }) {
1✔
426
      article.set('zoteroLink', zotero)
×
427
      const result = await article.save({ timestamps: false })
×
428
      return result === article
×
429
    },
1✔
430

1✔
431
    async addTags (article, { tags }) {
1✔
432
      await article.addTags(...tags)
×
433
      return article.tags
×
434
    },
1✔
435

1✔
436
    async removeTags (article, { tags }) {
1✔
NEW
437
      await article.removeTags(...tags)
×
438
      return article.tags
×
439
    },
1✔
440

1✔
441
    async setPreviewSettings (article, { settings }) {
1✔
NEW
442
      await article.set('preview', settings, { merge: true }).save()
×
443
      return article
×
444
    },
1✔
445

1✔
446
    async updateWorkingVersion (article, { content }) {
1✔
NEW
447
      Object.entries(content).forEach(([key, value]) =>
×
448
        article.set({
×
449
          workingVersion: {
×
UNCOV
450
            [key]: value,
×
UNCOV
451
          },
×
NEW
452
        })
×
453
      )
×
454

×
455
      return article.save()
×
456
    },
1✔
457

1✔
458
    /**
1✔
459
     * @param article
1✔
460
     * @param articleVersionInput
1✔
461
     * @param context
1✔
462
     * @return {Promise<Article>}
1✔
463
     */
1✔
464
    async createVersion (article, { articleVersionInput }) {
1✔
UNCOV
465
      const result = await createVersion(article, {
×
UNCOV
466
        ...articleVersionInput,
×
UNCOV
467
        type: 'userAction',
×
UNCOV
468
      })
×
UNCOV
469
      if (result === false) {
×
NEW
470
        throw new ApiError(
×
471
          'ILLEGAL_STATE',
×
472
          'Unable to create a new version since there\'s no change'
×
473
        )
×
474
      }
×
475
      await article.save()
×
NEW
476
      return article
×
NEW
477
    },
×
478
  },
1✔
479

1✔
480
  WorkingVersion: {
1✔
481
    md ({ ydoc = '' }) {
1✔
482
      try {
×
483
        return getTextFromYjsDoc(ydoc)
×
UNCOV
484
      } catch (err) {
×
UNCOV
485
        Sentry.captureException(err)
×
UNCOV
486
        console.error(
×
NEW
487
          'Unable to load text content (Markdown) from the Y.js document on article',
×
488
          err
×
489
        )
×
490
        return ''
×
491
      }
×
492
    },
1✔
493
    bibPreview ({ bib }) {
1✔
494
      return previewEntries(bib)
×
495
    },
1✔
496
    yaml ({ metadata = {} }, { options }) {
1✔
497
      const legacyMetadata = toLegacyFormat(metadata)
×
UNCOV
498
      const yaml = YAML.dump(legacyMetadata)
×
NEW
499
      return options?.strip_markdown
×
500
        ? reformat(yaml, { replaceBibliography: false })
×
UNCOV
501
        : yaml
×
NEW
502
    },
×
503
  },
1✔
504
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc