Merge pull request #19842 from overleaf/jpa-ro-mirror-on-client

[misc] add readonly mirror of full project content on the client

GitOrigin-RevId: 940bd93bfd587f83ca383d10fc44579b38fc3e88
This commit is contained in:
Jakob Ackermann 2024-08-21 13:28:21 +02:00 committed by Copybot
parent de842c61c3
commit 577497b655
46 changed files with 816 additions and 142 deletions

View file

@ -51,6 +51,25 @@ export type StringFileRawData = {
trackedChanges?: TrackedChangeRawData[] trackedChanges?: TrackedChangeRawData[]
} }
export type RawOrigin = {
kind: string
}
export type RawChange = {
operations: RawOperation[]
timestamp: string
authors?: (number | null)[]
v2Authors: string[]
origin: RawOrigin
projectVersion: string
v2DocVersions: RawV2DocVersions
}
export type RawOperation =
| RawEditFileOperation
// TODO(das7pad): add types for all the other operations
| object
export type RawSnapshot = { export type RawSnapshot = {
files: RawFileMap files: RawFileMap
projectVersion?: string projectVersion?: string

17
package-lock.json generated
View file

@ -22682,6 +22682,15 @@
"node >=0.6.0" "node >=0.6.0"
] ]
}, },
"node_modules/fake-indexeddb": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz",
"integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/fast-copy": { "node_modules/fast-copy": {
"version": "2.1.7", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-2.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-2.1.7.tgz",
@ -44790,6 +44799,7 @@
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"events": "^3.3.0", "events": "^3.3.0",
"fake-indexeddb": "^6.0.0",
"fetch-mock": "^9.10.2", "fetch-mock": "^9.10.2",
"formik": "^2.2.9", "formik": "^2.2.9",
"glob": "^7.1.6", "glob": "^7.1.6",
@ -53365,6 +53375,7 @@
"express-bearer-token": "^2.4.0", "express-bearer-token": "^2.4.0",
"express-http-proxy": "^1.6.0", "express-http-proxy": "^1.6.0",
"express-session": "^1.17.1", "express-session": "^1.17.1",
"fake-indexeddb": "^6.0.0",
"fetch-mock": "^9.10.2", "fetch-mock": "^9.10.2",
"formik": "^2.2.9", "formik": "^2.2.9",
"fs-extra": "^4.0.2", "fs-extra": "^4.0.2",
@ -65322,6 +65333,12 @@
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
}, },
"fake-indexeddb": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz",
"integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==",
"dev": true
},
"fast-copy": { "fast-copy": {
"version": "2.1.7", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-2.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-2.1.7.tgz",

View file

@ -17,6 +17,8 @@ import * as RetryManager from './RetryManager.js'
import * as FlushManager from './FlushManager.js' import * as FlushManager from './FlushManager.js'
import { pipeline } from 'stream' import { pipeline } from 'stream'
const ONE_DAY_IN_SECONDS = 24 * 60 * 60
export function getProjectBlob(req, res, next) { export function getProjectBlob(req, res, next) {
const projectId = req.params.project_id const projectId = req.params.project_id
const blobHash = req.params.hash const blobHash = req.params.hash
@ -27,6 +29,7 @@ export function getProjectBlob(req, res, next) {
if (err != null) { if (err != null) {
return next(OError.tag(err)) return next(OError.tag(err))
} }
res.setHeader('Cache-Control', `private, max-age=${ONE_DAY_IN_SECONDS}`)
pipeline(stream, res, err => { pipeline(stream, res, err => {
if (err) next(err) if (err) next(err)
// res.end() is already called via 'end' event by pipeline. // res.end() is already called via 'end' event by pipeline.
@ -216,6 +219,42 @@ export function getRangesSnapshot(req, res, next) {
) )
} }
export function getLatestSnapshot(req, res, next) {
const { project_id: projectId } = req.params
WebApiManager.getHistoryId(projectId, (error, historyId) => {
if (error) return next(OError.tag(error))
SnapshotManager.getLatestSnapshot(
projectId,
historyId,
(error, { snapshot, version }) => {
if (error != null) {
return next(error)
}
res.json({ snapshot: snapshot.toRaw(), version })
}
)
})
}
export function getChangesSince(req, res, next) {
const { project_id: projectId } = req.params
const { since } = req.query
WebApiManager.getHistoryId(projectId, (error, historyId) => {
if (error) return next(OError.tag(error))
SnapshotManager.getChangesSince(
projectId,
historyId,
since,
(error, changes) => {
if (error != null) {
return next(error)
}
res.json(changes.map(c => c.toRaw()))
}
)
})
}
export function getProjectSnapshot(req, res, next) { export function getProjectSnapshot(req, res, next) {
const { project_id: projectId, version } = req.params const { project_id: projectId, version } = req.params
SnapshotManager.getProjectSnapshot( SnapshotManager.getProjectSnapshot(

View file

@ -21,6 +21,8 @@ export function initialize(app) {
app.delete('/project/:project_id', HttpController.deleteProject) app.delete('/project/:project_id', HttpController.deleteProject)
app.get('/project/:project_id/snapshot', HttpController.getLatestSnapshot)
app.get( app.get(
'/project/:project_id/diff', '/project/:project_id/diff',
validate({ validate({
@ -55,6 +57,16 @@ export function initialize(app) {
HttpController.getUpdates HttpController.getUpdates
) )
app.get(
'/project/:project_id/changes',
validate({
query: {
since: Joi.number().integer(),
},
}),
HttpController.getChangesSince
)
app.get('/project/:project_id/version', HttpController.latestVersion) app.get('/project/:project_id/version', HttpController.latestVersion)
app.post( app.post(

View file

@ -263,6 +263,15 @@ async function _getSnapshotAtVersion(projectId, version) {
return snapshot return snapshot
} }
async function getLatestSnapshotFiles(projectId, historyId) {
const { snapshot } = await getLatestSnapshot(projectId, historyId)
const snapshotFiles = await snapshot.loadFiles(
'lazy',
HistoryStoreManager.getBlobStore(historyId)
)
return snapshotFiles
}
async function getLatestSnapshot(projectId, historyId) { async function getLatestSnapshot(projectId, historyId) {
const data = await HistoryStoreManager.promises.getMostRecentChunk( const data = await HistoryStoreManager.promises.getMostRecentChunk(
projectId, projectId,
@ -277,11 +286,48 @@ async function getLatestSnapshot(projectId, historyId) {
const snapshot = chunk.getSnapshot() const snapshot = chunk.getSnapshot()
const changes = chunk.getChanges() const changes = chunk.getChanges()
snapshot.applyAll(changes) snapshot.applyAll(changes)
const snapshotFiles = await snapshot.loadFiles( return {
'lazy', snapshot,
HistoryStoreManager.getBlobStore(historyId) version: chunk.getEndVersion(),
}
}
async function getChangesSince(projectId, historyId, sinceVersion) {
const allChanges = []
let nextVersion
while (true) {
let data
if (nextVersion) {
data = await HistoryStoreManager.promises.getChunkAtVersion(
projectId,
historyId,
nextVersion
) )
return snapshotFiles } else {
data = await HistoryStoreManager.promises.getMostRecentChunk(
projectId,
historyId
)
}
if (data == null || data.chunk == null) {
throw new OError('undefined chunk')
}
const chunk = Core.Chunk.fromRaw(data.chunk)
if (sinceVersion > chunk.getEndVersion()) {
throw new OError('requested version past the end')
}
const changes = chunk.getChanges()
if (chunk.getStartVersion() > sinceVersion) {
allChanges.unshift(...changes)
nextVersion = chunk.getStartVersion()
} else {
allChanges.unshift(
...changes.slice(sinceVersion - chunk.getStartVersion())
)
break
}
}
return allChanges
} }
async function _loadFilesLimit(snapshot, kind, blobStore) { async function _loadFilesLimit(snapshot, kind, blobStore) {
@ -298,24 +344,30 @@ async function _loadFilesLimit(snapshot, kind, blobStore) {
// EXPORTS // EXPORTS
const getChangesSinceCb = callbackify(getChangesSince)
const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream) const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
const getProjectSnapshotCb = callbackify(getProjectSnapshot) const getProjectSnapshotCb = callbackify(getProjectSnapshot)
const getLatestSnapshotCb = callbackify(getLatestSnapshot) const getLatestSnapshotCb = callbackify(getLatestSnapshot)
const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
const getRangesSnapshotCb = callbackify(getRangesSnapshot) const getRangesSnapshotCb = callbackify(getRangesSnapshot)
const getPathsAtVersionCb = callbackify(getPathsAtVersion) const getPathsAtVersionCb = callbackify(getPathsAtVersion)
export { export {
getChangesSinceCb as getChangesSince,
getFileSnapshotStreamCb as getFileSnapshotStream, getFileSnapshotStreamCb as getFileSnapshotStream,
getProjectSnapshotCb as getProjectSnapshot, getProjectSnapshotCb as getProjectSnapshot,
getLatestSnapshotCb as getLatestSnapshot, getLatestSnapshotCb as getLatestSnapshot,
getLatestSnapshotFilesCb as getLatestSnapshotFiles,
getRangesSnapshotCb as getRangesSnapshot, getRangesSnapshotCb as getRangesSnapshot,
getPathsAtVersionCb as getPathsAtVersion, getPathsAtVersionCb as getPathsAtVersion,
} }
export const promises = { export const promises = {
getChangesSince,
getFileSnapshotStream, getFileSnapshotStream,
getProjectSnapshot, getProjectSnapshot,
getLatestSnapshot, getLatestSnapshot,
getLatestSnapshotFiles,
getRangesSnapshot, getRangesSnapshot,
getPathsAtVersion, getPathsAtVersion,
} }

View file

@ -203,7 +203,7 @@ async function expandSyncUpdates(
const syncState = await _getResyncState(projectId) const syncState = await _getResyncState(projectId)
// compute the current snapshot from the most recent chunk // compute the current snapshot from the most recent chunk
const snapshotFiles = await SnapshotManager.promises.getLatestSnapshot( const snapshotFiles = await SnapshotManager.promises.getLatestSnapshotFiles(
projectId, projectId,
projectHistoryId projectHistoryId
) )

View file

@ -1,5 +1,5 @@
import { HistoryRanges } from '../../../document-updater/app/js/types' import { HistoryRanges } from '../../../document-updater/app/js/types'
import { LinkedFileData } from 'overleaf-editor-core/lib/types' import { LinkedFileData, RawOrigin } from 'overleaf-editor-core/lib/types'
export type Update = export type Update =
| TextUpdate | TextUpdate
@ -161,10 +161,6 @@ export type UpdateWithBlob<T extends Update = Update> = {
: never : never
} }
export type RawOrigin = {
kind: string
}
export type TrackingProps = { export type TrackingProps = {
type: 'insert' | 'delete' type: 'insert' | 'delete'
userId: string userId: string

View file

@ -0,0 +1,122 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import Core from 'overleaf-editor-core'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
import latestChunk from '../fixtures/chunks/7-8.json' with { type: 'json' }
import previousChunk from '../fixtures/chunks/4-6.json' with { type: 'json' }
import firstChunk from '../fixtures/chunks/0-3.json' with { type: 'json' }
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('GetChangesSince', function () {
let projectId, historyId
beforeEach(function (done) {
projectId = new ObjectId().toString()
historyId = new ObjectId().toString()
ProjectHistoryApp.ensureRunning(error => {
if (error) throw error
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
ProjectHistoryClient.initializeProject(historyId, (error, olProject) => {
if (error) throw error
MockWeb()
.get(`/project/${projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: olProject.id } },
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/6/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/3/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
done()
})
})
})
afterEach(function () {
nock.cleanAll()
})
function expectChangesSince(version, changes, done) {
ProjectHistoryClient.getChangesSince(
projectId,
version,
{},
(error, got) => {
if (error) throw error
expect(got.map(c => Core.Change.fromRaw(c))).to.deep.equal(
changes.map(c => Core.Change.fromRaw(c))
)
done()
}
)
}
it('should return zero changes since the latest version', function (done) {
expectChangesSince(8, [], done)
})
it('should return one change when behind one version', function (done) {
expectChangesSince(7, [latestChunk.chunk.history.changes[1]], done)
})
it('should return changes when at the chunk boundary', function (done) {
expect(latestChunk.chunk.startVersion).to.equal(6)
expectChangesSince(6, latestChunk.chunk.history.changes, done)
})
it('should return changes spanning multiple chunks', function (done) {
expectChangesSince(
1,
[
...firstChunk.chunk.history.changes.slice(1),
...previousChunk.chunk.history.changes,
...latestChunk.chunk.history.changes,
],
done
)
})
it('should return all changes when going back to the beginning', function (done) {
expectChangesSince(
0,
[
...firstChunk.chunk.history.changes,
...previousChunk.chunk.history.changes,
...latestChunk.chunk.history.changes,
],
done
)
})
it('should return an error when past the end version', function (done) {
ProjectHistoryClient.getChangesSince(
projectId,
9,
{ allowErrors: true },
(error, body, statusCode) => {
if (error) throw error
expect(statusCode).to.equal(500)
expect(body).to.deep.equal({ message: 'an internal error occurred' })
done()
}
)
})
})

View file

@ -0,0 +1,78 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('LatestSnapshot', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.historyId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
ProjectHistoryClient.initializeProject(
this.historyId,
(error, v1Project) => {
if (error) {
throw error
}
this.projectId = new ObjectId().toString()
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: v1Project.id } },
})
done()
}
)
})
})
afterEach(function () {
nock.cleanAll()
})
it('should return the snapshot with applied changes, metadata and without full content', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
ProjectHistoryClient.getLatestSnapshot(this.projectId, (error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal({
snapshot: {
files: {
'main.tex': {
hash: 'f28571f561d198b87c24cc6a98b78e87b665e22d',
stringLength: 20649,
operations: [{ textOperation: [1912, 'Hello world', 18726] }],
metadata: { main: true },
},
'foo.tex': {
hash: '4f785a4c192155b240e3042b3a7388b47603f423',
stringLength: 41,
operations: [{ textOperation: [26, '\n\nFour five six'] }],
},
},
},
version: 3,
})
done()
})
})
})

View file

@ -108,6 +108,41 @@ export function getFileTreeDiff(projectId, from, to, callback) {
) )
} }
export function getChangesSince(projectId, since, options, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/changes`,
qs: {
since,
},
json: true,
},
(error, res, body) => {
if (error) return callback(error)
if (!options.allowErrors) {
expect(res.statusCode).to.equal(200)
}
callback(null, body, res.statusCode)
}
)
}
export function getLatestSnapshot(projectId, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/snapshot`,
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(null, body)
}
)
}
export function getSnapshot(projectId, pathname, version, options, callback) { export function getSnapshot(projectId, pathname, version, options, callback) {
if (typeof options === 'function') { if (typeof options === 'function') {
callback = options callback = options

View file

@ -84,6 +84,7 @@ describe('HttpController', function () {
json: sinon.stub(), json: sinon.stub(),
send: sinon.stub(), send: sinon.stub(),
sendStatus: sinon.stub(), sendStatus: sinon.stub(),
setHeader: sinon.stub(),
} }
}) })
@ -105,6 +106,13 @@ describe('HttpController', function () {
.should.equal(true) .should.equal(true)
this.pipeline.should.have.been.calledWith(this.stream, this.res) this.pipeline.should.have.been.calledWith(this.stream, this.res)
}) })
it('should set caching header', function () {
this.res.setHeader.should.have.been.calledWith(
'Cache-Control',
'private, max-age=86400'
)
})
}) })
describe('initializeProject', function () { describe('initializeProject', function () {

View file

@ -479,7 +479,7 @@ Four five six\
}) })
}) })
describe('getLatestSnapshot', function () { describe('getLatestSnapshotFiles', function () {
describe('for a project', function () { describe('for a project', function () {
beforeEach(async function () { beforeEach(async function () {
this.HistoryStoreManager.promises.getMostRecentChunk.resolves({ this.HistoryStoreManager.promises.getMostRecentChunk.resolves({
@ -543,7 +543,7 @@ Four five six\
)), )),
getObject: sinon.stub().rejects(), getObject: sinon.stub().rejects(),
}) })
this.data = await this.SnapshotManager.promises.getLatestSnapshot( this.data = await this.SnapshotManager.promises.getLatestSnapshotFiles(
this.projectId, this.projectId,
this.historyId this.historyId
) )
@ -571,7 +571,7 @@ Four five six\
beforeEach(async function () { beforeEach(async function () {
this.HistoryStoreManager.promises.getMostRecentChunk.resolves(null) this.HistoryStoreManager.promises.getMostRecentChunk.resolves(null)
expect( expect(
this.SnapshotManager.promises.getLatestSnapshot( this.SnapshotManager.promises.getLatestSnapshotFiles(
this.projectId, this.projectId,
this.historyId this.historyId
) )

View file

@ -114,7 +114,7 @@ describe('SyncManager', function () {
this.SnapshotManager = { this.SnapshotManager = {
promises: { promises: {
getLatestSnapshot: sinon.stub(), getLatestSnapshotFiles: sinon.stub(),
}, },
} }
@ -517,7 +517,9 @@ describe('SyncManager', function () {
.returns('another.tex') .returns('another.tex')
this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png') this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png') this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
this.SnapshotManager.promises.getLatestSnapshot.resolves(this.fileMap) this.SnapshotManager.promises.getLatestSnapshotFiles.resolves(
this.fileMap
)
}) })
it('returns updates if no sync updates are queued', async function () { it('returns updates if no sync updates are queued', async function () {
@ -530,8 +532,8 @@ describe('SyncManager', function () {
) )
expect(expandedUpdates).to.equal(updates) expect(expandedUpdates).to.equal(updates)
expect(this.SnapshotManager.promises.getLatestSnapshot).to.not.have.been expect(this.SnapshotManager.promises.getLatestSnapshotFiles).to.not.have
.called .been.called
expect(this.extendLock).to.not.have.been.called expect(this.extendLock).to.not.have.been.called
}) })

View file

@ -5,6 +5,9 @@ const settings = require('@overleaf/settings')
// backward-compatible (can be instantiated with string as argument instead // backward-compatible (can be instantiated with string as argument instead
// of object) // of object)
class BackwardCompatibleError extends OError { class BackwardCompatibleError extends OError {
/**
* @param {string | { message: string, info?: Object }} messageOrOptions
*/
constructor(messageOrOptions) { constructor(messageOrOptions) {
if (typeof messageOrOptions === 'string') { if (typeof messageOrOptions === 'string') {
super(messageOrOptions) super(messageOrOptions)

View file

@ -337,6 +337,7 @@ const _ProjectController = {
'revert-file', 'revert-file',
'revert-project', 'revert-project',
'review-panel-redesign', 'review-panel-redesign',
!anonymous && 'ro-mirror-on-client',
'track-pdf-download', 'track-pdf-download',
!anonymous && 'writefull-oauth-promotion', !anonymous && 'writefull-oauth-promotion',
'ieee-stylesheet', 'ieee-stylesheet',

View file

@ -127,6 +127,7 @@ module.exports = ProjectEditorHandler = {
name: file.name, name: file.name,
linkedFileData: file.linkedFileData, linkedFileData: file.linkedFileData,
created: file.created, created: file.created,
hash: file.hash,
} }
}, },

View file

@ -1,5 +1,7 @@
const SplitTestHandler = require('./SplitTestHandler') const SplitTestHandler = require('./SplitTestHandler')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const { expressify } = require('@overleaf/promise-utils')
const Errors = require('../Errors/Errors')
function loadAssignmentsInLocals(splitTestNames) { function loadAssignmentsInLocals(splitTestNames) {
return async function (req, res, next) { return async function (req, res, next) {
@ -17,6 +19,31 @@ function loadAssignmentsInLocals(splitTestNames) {
} }
} }
function ensureSplitTestEnabledForUser(
splitTestName,
enabledVariant = 'enabled'
) {
return expressify(async function (req, res, next) {
const { variant } = await SplitTestHandler.promises.getAssignment(
req,
res,
splitTestName
)
if (variant !== enabledVariant) {
throw new Errors.ForbiddenError({
message: 'missing split test access',
info: {
splitTestName,
variant,
enabledVariant,
},
})
}
next()
})
}
module.exports = { module.exports = {
loadAssignmentsInLocals, loadAssignmentsInLocals,
ensureSplitTestEnabledForUser,
} }

View file

@ -11,6 +11,8 @@ const rclient = RedisWrapper.client('ratelimiter')
* Wrapper over the RateLimiterRedis class * Wrapper over the RateLimiterRedis class
*/ */
class RateLimiter { class RateLimiter {
#opts
/** /**
* Create a rate limiter. * Create a rate limiter.
* *
@ -31,6 +33,7 @@ class RateLimiter {
*/ */
constructor(name, opts = {}) { constructor(name, opts = {}) {
this.name = name this.name = name
this.#opts = Object.assign({}, opts)
this._rateLimiter = new RateLimiterFlexible.RateLimiterRedis({ this._rateLimiter = new RateLimiterFlexible.RateLimiterRedis({
...opts, ...opts,
keyPrefix: `rate-limit:${name}`, keyPrefix: `rate-limit:${name}`,
@ -46,6 +49,11 @@ class RateLimiter {
} }
} }
// Readonly access to the options, useful for aligning rate-limits.
getOptions() {
return Object.assign({}, this.#opts)
}
async consume(key, points = 1, options = { method: 'unknown' }) { async consume(key, points = 1, options = { method: 'unknown' }) {
if (Settings.disableRateLimits) { if (Settings.disableRateLimits) {
// Return a fake result in case it's used somewhere // Return a fake result in case it's used somewhere

View file

@ -296,6 +296,13 @@ webRouter.use(function addNoCacheHeader(req, res, next) {
// don't set no-cache headers on a project file, as it's immutable and can be cached (privately) // don't set no-cache headers on a project file, as it's immutable and can be cached (privately)
return next() return next()
} }
const isProjectBlob = /^\/project\/[a-f0-9]{24}\/blob\/[a-f0-9]{40}$/.test(
req.path
)
if (isProjectBlob) {
// don't set no-cache headers on a project blobs, as they are immutable and can be cached (privately)
return next()
}
const isWikiContent = /^\/learn(-scripts)?(\/|$)/i.test(req.path) const isWikiContent = /^\/learn(-scripts)?(\/|$)/i.test(req.path)
if (isWikiContent) { if (isWikiContent) {

View file

@ -125,6 +125,20 @@ const rateLimiters = {
points: 30, points: 30,
duration: 60 * 60, duration: 60 * 60,
}), }),
flushHistory: new RateLimiter('flush-project-history', {
// Allow flushing once every 30s-1s (allow for network jitter).
points: 1,
duration: 30 - 1,
}),
getProjectBlob: new RateLimiter('get-project-blob', {
// Download project in full once per hour
points: Settings.maxEntitiesPerProject,
duration: 60 * 60,
}),
getHistorySnapshot: new RateLimiter(
'get-history-snapshot',
openProjectRateLimiter.getOptions()
),
endorseEmail: new RateLimiter('endorse-email', { endorseEmail: new RateLimiter('endorse-email', {
points: 30, points: 30,
duration: 60, duration: 60,

View file

@ -901,6 +901,8 @@ module.exports = {
managedGroupEnrollmentInvite: [], managedGroupEnrollmentInvite: [],
ssoCertificateInfo: [], ssoCertificateInfo: [],
v1ImportDataScreen: [], v1ImportDataScreen: [],
snapshotUtils: [],
offlineModeToolbarButtons: [],
}, },
moduleImportSequence: [ moduleImportSequence: [

View file

@ -476,6 +476,7 @@
"from_provider": "", "from_provider": "",
"from_url": "", "from_url": "",
"full_doc_history": "", "full_doc_history": "",
"full_project_search": "",
"full_width": "", "full_width": "",
"generate_token": "", "generate_token": "",
"generic_if_problem_continues_contact_us": "", "generic_if_problem_continues_contact_us": "",
@ -844,6 +845,7 @@
"my_library": "", "my_library": "",
"n_items": "", "n_items": "",
"n_items_plural": "", "n_items_plural": "",
"n_matches": "",
"n_more_updates_above": "", "n_more_updates_above": "",
"n_more_updates_above_plural": "", "n_more_updates_above_plural": "",
"n_more_updates_below": "", "n_more_updates_below": "",
@ -1100,6 +1102,7 @@
"refresh_page_after_starting_free_trial": "", "refresh_page_after_starting_free_trial": "",
"refreshing": "", "refreshing": "",
"regards": "", "regards": "",
"regular_expression": "",
"reject": "", "reject": "",
"reject_all": "", "reject_all": "",
"reject_change": "", "reject_change": "",
@ -1183,6 +1186,7 @@
"saving": "", "saving": "",
"saving_notification_with_seconds": "", "saving_notification_with_seconds": "",
"search": "", "search": "",
"search_all_project_files": "",
"search_bib_files": "", "search_bib_files": "",
"search_by_citekey_author_year_title": "", "search_by_citekey_author_year_title": "",
"search_command_find": "", "search_command_find": "",
@ -1202,6 +1206,7 @@
"search_replace_all": "", "search_replace_all": "",
"search_replace_with": "", "search_replace_with": "",
"search_search_for": "", "search_search_for": "",
"search_terms": "",
"search_whole_word": "", "search_whole_word": "",
"search_within_selection": "", "search_within_selection": "",
"searched_path_for_lines_containing": "", "searched_path_for_lines_containing": "",

View file

@ -15,10 +15,20 @@ import ShareProjectButton from './share-project-button'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import BackToEditorButton from './back-to-editor-button' import BackToEditorButton from './back-to-editor-button'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
const [publishModalModules] = importOverleafModules('publishModal') const [publishModalModules] = importOverleafModules('publishModal')
const PublishButton = publishModalModules?.import.default const PublishButton = publishModalModules?.import.default
const offlineModeToolbarButtons = importOverleafModules(
'offlineModeToolbarButtons'
)
// double opt-in
const enableROMirrorOnClient =
isSplitTestEnabled('ro-mirror-on-client') &&
new URLSearchParams(window.location.search).get('ro-mirror-on-client') ===
'enabled'
const ToolbarHeader = React.memo(function ToolbarHeader({ const ToolbarHeader = React.memo(function ToolbarHeader({
cobranding, cobranding,
onShowLeftMenuClick, onShowLeftMenuClick,
@ -55,6 +65,12 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
<CobrandingLogo {...cobranding} /> <CobrandingLogo {...cobranding} />
)} )}
<BackToProjectsButton /> <BackToProjectsButton />
{enableROMirrorOnClient &&
offlineModeToolbarButtons.map(
({ path, import: { default: OfflineModeToolbarButton } }) => {
return <OfflineModeToolbarButton key={path} />
}
)}
</div> </div>
{getMeta('ol-showUpgradePrompt') && <UpgradePrompt />} {getMeta('ol-showUpgradePrompt') && <UpgradePrompt />}
<ProjectNameEditableLabel <ProjectNameEditableLabel

View file

@ -12,6 +12,7 @@ import { LinkedFileIcon } from './file-view-icons'
import { BinaryFile, hasProvider, LinkedFile } from '../types/binary-file' import { BinaryFile, hasProvider, LinkedFile } from '../types/binary-file'
import FileViewRefreshButton from './file-view-refresh-button' import FileViewRefreshButton from './file-view-refresh-button'
import FileViewRefreshError from './file-view-refresh-error' import FileViewRefreshError from './file-view-refresh-error'
import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context'
const tprFileViewInfo = importOverleafModules('tprFileViewInfo') as { const tprFileViewInfo = importOverleafModules('tprFileViewInfo') as {
import: { TPRFileViewInfo: ElementType } import: { TPRFileViewInfo: ElementType }
@ -49,6 +50,7 @@ type FileViewHeaderProps = {
export default function FileViewHeader({ file }: FileViewHeaderProps) { export default function FileViewHeader({ file }: FileViewHeaderProps) {
const { _id: projectId } = useProjectContext() const { _id: projectId } = useProjectContext()
const { permissionsLevel } = useEditorContext() const { permissionsLevel } = useEditorContext()
const { fileTreeFromHistory } = useSnapshotContext()
const { t } = useTranslation() const { t } = useTranslation()
const [refreshError, setRefreshError] = useState<Nullable<string>>(null) const [refreshError, setRefreshError] = useState<Nullable<string>>(null)
@ -88,8 +90,12 @@ export default function FileViewHeader({ file }: FileViewHeaderProps) {
)} )}
&nbsp; &nbsp;
<a <a
download download={file.name}
href={`/project/${projectId}/file/${file.id}`} href={
fileTreeFromHistory
? `/project/${projectId}/blob/${file.hash}`
: `/project/${projectId}/file/${file.id}`
}
className="btn btn-secondary-info btn-secondary" className="btn btn-secondary-info btn-secondary"
> >
<Icon type="download" fw /> <Icon type="download" fw />

View file

@ -1,24 +1,29 @@
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context'
import { BinaryFile } from '@/features/file-view/types/binary-file'
export default function FileViewImage({ export default function FileViewImage({
fileName, file,
fileId,
onLoad, onLoad,
onError, onError,
}: { }: {
fileName: string file: BinaryFile
fileId: string
onLoad: () => void onLoad: () => void
onError: () => void onError: () => void
}) { }) {
const { _id: projectId } = useProjectContext() const { _id: projectId } = useProjectContext()
const { fileTreeFromHistory } = useSnapshotContext()
return ( return (
<img <img
src={`/project/${projectId}/file/${fileId}`} src={
fileTreeFromHistory
? `/project/${projectId}/blob/${file.hash}`
: `/project/${projectId}/file/${file.id}`
}
onLoad={onLoad} onLoad={onLoad}
onError={onError} onError={onError}
alt={fileName} alt={file.name}
/> />
) )
} }

View file

@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import useAbortController from '../../../shared/hooks/use-abort-controller' import useAbortController from '../../../shared/hooks/use-abort-controller'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context'
const MAX_FILE_SIZE = 2 * 1024 * 1024 const MAX_FILE_SIZE = 2 * 1024 * 1024
@ -10,11 +12,12 @@ export default function FileViewText({
onLoad, onLoad,
onError, onError,
}: { }: {
file: { id: string } file: BinaryFile
onLoad: () => void onLoad: () => void
onError: () => void onError: () => void
}) { }) {
const { _id: projectId } = useProjectContext() const { _id: projectId } = useProjectContext()
const { fileTreeFromHistory } = useSnapshotContext()
const [textPreview, setTextPreview] = useState('') const [textPreview, setTextPreview] = useState('')
const [shouldShowDots, setShouldShowDots] = useState(false) const [shouldShowDots, setShouldShowDots] = useState(false)
@ -27,7 +30,9 @@ export default function FileViewText({
if (inFlight) { if (inFlight) {
return return
} }
let path = `/project/${projectId}/file/${file.id}` let path = fileTreeFromHistory
? `/project/${projectId}/blob/${file.hash}`
: `/project/${projectId}/file/${file.id}`
const fetchContentLengthTimeout = setTimeout( const fetchContentLengthTimeout = setTimeout(
() => fetchContentLengthController.abort(), () => fetchContentLengthController.abort(),
10000 10000
@ -77,8 +82,10 @@ export default function FileViewText({
clearTimeout(fetchDataTimeout) clearTimeout(fetchDataTimeout)
}) })
}, [ }, [
fileTreeFromHistory,
projectId, projectId,
file.id, file.id,
file.hash,
onError, onError,
onLoad, onLoad,
inFlight, inFlight,

View file

@ -44,12 +44,7 @@ export default function FileView({ file }) {
<> <>
<FileViewHeader file={file} /> <FileViewHeader file={file} />
{isImageFile && ( {isImageFile && (
<FileViewImage <FileViewImage file={file} onLoad={handleLoad} onError={handleError} />
fileName={file.name}
fileId={file.id}
onLoad={handleLoad}
onError={handleError}
/>
)} )}
{isEditableTextFile && ( {isEditableTextFile && (
<FileViewText file={file} onLoad={handleLoad} onError={handleError} /> <FileViewText file={file} onLoad={handleLoad} onError={handleError} />
@ -91,5 +86,6 @@ FileView.propTypes = {
file: PropTypes.shape({ file: PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
hash: PropTypes.string,
}).isRequired, }).isRequired,
} }

View file

@ -26,6 +26,7 @@ export type BinaryFile<T extends keyof LinkedFileData = keyof LinkedFileData> =
type: string type: string
selected: boolean selected: boolean
linkedFileData?: LinkedFileData[T] linkedFileData?: LinkedFileData[T]
hash: string
} }
export type LinkedFile<T extends keyof LinkedFileData> = Required<BinaryFile<T>> export type LinkedFile<T extends keyof LinkedFileData> = Required<BinaryFile<T>>

View file

@ -22,6 +22,7 @@ import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { PermissionsProvider } from '@/features/ide-react/context/permissions-context' import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context' import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
import { OutlineProvider } from '@/features/ide-react/context/outline-context' import { OutlineProvider } from '@/features/ide-react/context/outline-context'
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
export const ReactContextRoot: FC = ({ children }) => { export const ReactContextRoot: FC = ({ children }) => {
return ( return (
@ -32,6 +33,7 @@ export const ReactContextRoot: FC = ({ children }) => {
<UserProvider> <UserProvider>
<UserSettingsProvider> <UserSettingsProvider>
<ProjectProvider> <ProjectProvider>
<SnapshotProvider>
<FileTreeDataProvider> <FileTreeDataProvider>
<FileTreePathProvider> <FileTreePathProvider>
<ReferencesProvider> <ReferencesProvider>
@ -65,6 +67,7 @@ export const ReactContextRoot: FC = ({ children }) => {
</ReferencesProvider> </ReferencesProvider>
</FileTreePathProvider> </FileTreePathProvider>
</FileTreeDataProvider> </FileTreeDataProvider>
</SnapshotProvider>
</ProjectProvider> </ProjectProvider>
</UserSettingsProvider> </UserSettingsProvider>
</UserProvider> </UserProvider>

View file

@ -0,0 +1,127 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { Snapshot } from 'overleaf-editor-core'
import { useProjectContext } from '@/shared/context/project-context'
import { debugConsole } from '@/utils/debugging'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { Folder } from '../../../../../types/folder'
export const StubSnapshotUtils = {
SnapshotUpdater: class SnapshotUpdater {
// eslint-disable-next-line no-useless-constructor
constructor(readonly projectId: string) {}
refresh(): Promise<{ snapshot: Snapshot; snapshotVersion: number }> {
throw new Error('not implemented')
}
abort(): void {
throw new Error('not implemented')
}
},
buildFileTree(snapshot: Snapshot): Folder {
throw new Error('not implemented')
},
createFolder(_id: string, name: string): Folder {
throw new Error('not implemented')
},
}
const { SnapshotUpdater } =
(importOverleafModules('snapshotUtils')[0]
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
export type SnapshotLoadingState = '' | 'loading' | 'error'
export const SnapshotContext = createContext<
| {
snapshotVersion: number
snapshot?: Snapshot
snapshotLoadingState: SnapshotLoadingState
fileTreeFromHistory: boolean
setFileTreeFromHistory: (v: boolean) => void
}
| undefined
>(undefined)
export const SnapshotProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const [snapshotLoadingState, setSnapshotLoadingState] =
useState<SnapshotLoadingState>('')
const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId))
const [snapshot, setSnapshot] = useState<Snapshot>()
const [snapshotVersion, setSnapshotVersion] = useState(-1)
const [fileTreeFromHistory, setFileTreeFromHistory] = useState(false)
useEffect(() => {
if (!fileTreeFromHistory) return
let stop = false
let handle: number
const refresh = () => {
setSnapshotLoadingState('loading')
snapshotUpdater
.refresh()
.then(({ snapshot, snapshotVersion }) => {
setSnapshot(snapshot)
setSnapshotVersion(snapshotVersion)
setSnapshotLoadingState('')
})
.catch(err => {
debugConsole.error(err)
setSnapshotLoadingState('error')
})
.finally(() => {
if (stop) return
// use a chain of timeouts to avoid concurrent updates
handle = window.setTimeout(refresh, 30_000)
})
}
refresh()
return () => {
stop = true
snapshotUpdater.abort()
clearInterval(handle)
}
}, [projectId, fileTreeFromHistory, snapshotUpdater])
const value = useMemo(
() => ({
snapshot,
snapshotVersion,
snapshotLoadingState,
fileTreeFromHistory,
setFileTreeFromHistory,
}),
[
snapshot,
snapshotVersion,
snapshotLoadingState,
fileTreeFromHistory,
setFileTreeFromHistory,
]
)
return (
<SnapshotContext.Provider value={value}>
{children}
</SnapshotContext.Provider>
)
}
export function useSnapshotContext() {
const context = useContext(SnapshotContext)
if (!context) {
throw new Error(
'useSnapshotContext is only available within SnapshotProvider'
)
}
return context
}

View file

@ -10,6 +10,7 @@ export function convertFileRefToBinaryFile(fileRef: FileRef): BinaryFile {
selected: true, selected: true,
linkedFileData: fileRef.linkedFileData, linkedFileData: fileRef.linkedFileData,
created: fileRef.created ? new Date(fileRef.created) : new Date(), created: fileRef.created ? new Date(fileRef.created) : new Date(),
hash: fileRef.hash,
} }
} }

View file

@ -244,3 +244,10 @@ export function getUserFacingMessage(error: Error | null) {
return error.message return error.message
} }
export function isRateLimited(error?: Error | FetchError | any) {
if (error && error instanceof FetchError) {
return error.response?.status === 429
}
return false
}

View file

@ -6,6 +6,7 @@ import {
useMemo, useMemo,
useState, useState,
FC, FC,
useEffect,
} from 'react' } from 'react'
import useScopeValue from '../hooks/use-scope-value' import useScopeValue from '../hooks/use-scope-value'
import { import {
@ -22,6 +23,14 @@ import { Folder } from '../../../../types/folder'
import { Project } from '../../../../types/project' import { Project } from '../../../../types/project'
import { MainDocument } from '../../../../types/project-settings' import { MainDocument } from '../../../../types/project-settings'
import { FindResult } from '@/features/file-tree/util/path' import { FindResult } from '@/features/file-tree/util/path'
import {
StubSnapshotUtils,
useSnapshotContext,
} from '@/features/ide-react/context/snapshot-context'
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
const { buildFileTree, createFolder } =
(importOverleafModules('snapshotUtils')[0]
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
const FileTreeDataContext = createContext< const FileTreeDataContext = createContext<
| { | {
@ -170,8 +179,29 @@ export const FileTreeDataProvider: FC = ({ children }) => {
const [project] = useScopeValue<Project>('project') const [project] = useScopeValue<Project>('project')
const [openDocId] = useScopeValue('editor.open_doc_id') const [openDocId] = useScopeValue('editor.open_doc_id')
const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name') const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
const { fileTreeFromHistory, snapshot, snapshotVersion } =
useSnapshotContext()
const { rootFolder } = project || {} const [rootFolder, setRootFolder] = useState(project?.rootFolder)
useEffect(() => {
if (fileTreeFromHistory) return
setRootFolder(project?.rootFolder)
}, [project, fileTreeFromHistory])
useEffect(() => {
if (!fileTreeFromHistory) return
if (!rootFolder || rootFolder?.[0]?._id) {
// Init or replace mongo rootFolder with stub while we load the snapshot.
// In the future, project:joined should only fire once the snapshot is ready.
setRootFolder([createFolder('', '')])
}
}, [fileTreeFromHistory, rootFolder])
useEffect(() => {
if (!fileTreeFromHistory || !snapshot) return
setRootFolder([buildFileTree(snapshot)])
}, [fileTreeFromHistory, snapshot, snapshotVersion])
const [{ fileTreeData, fileCount }, dispatch] = useReducer( const [{ fileTreeData, fileCount }, dispatch] = useReducer(
fileTreeMutableReducer, fileTreeMutableReducer,

View file

@ -42,6 +42,7 @@ import { EditorManagerProvider } from '@/features/ide-react/context/editor-manag
import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context' import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
import { MetadataProvider } from '@/features/ide-react/context/metadata-context' import { MetadataProvider } from '@/features/ide-react/context/metadata-context'
import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context' import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context'
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
const scopeWatchers: [string, (value: any) => void][] = [] const scopeWatchers: [string, (value: any) => void][] = []
@ -73,7 +74,7 @@ const initialize = () => {
{ _id: 'test-file-id', name: 'testfile.tex' }, { _id: 'test-file-id', name: 'testfile.tex' },
{ _id: 'test-bib-file-id', name: 'testsources.bib' }, { _id: 'test-bib-file-id', name: 'testsources.bib' },
], ],
fileRefs: [{ _id: 'test-image-id', name: 'frog.jpg' }], fileRefs: [{ _id: 'test-image-id', name: 'frog.jpg', hash: '42' }],
folders: [], folders: [],
}, },
], ],
@ -179,6 +180,7 @@ export const ScopeDecorator = (
DetachProvider, DetachProvider,
EditorProvider, EditorProvider,
EditorManagerProvider, EditorManagerProvider,
SnapshotProvider,
FileTreeDataProvider, FileTreeDataProvider,
FileTreeOpenProvider, FileTreeOpenProvider,
FileTreePathProvider, FileTreePathProvider,
@ -207,6 +209,7 @@ export const ScopeDecorator = (
<Providers.UserProvider> <Providers.UserProvider>
<Providers.UserSettingsProvider> <Providers.UserSettingsProvider>
<Providers.ProjectProvider> <Providers.ProjectProvider>
<Providers.SnapshotProvider>
<Providers.FileTreeDataProvider> <Providers.FileTreeDataProvider>
<Providers.FileTreePathProvider> <Providers.FileTreePathProvider>
<Providers.ReferencesProvider> <Providers.ReferencesProvider>
@ -240,6 +243,7 @@ export const ScopeDecorator = (
</Providers.ReferencesProvider> </Providers.ReferencesProvider>
</Providers.FileTreePathProvider> </Providers.FileTreePathProvider>
</Providers.FileTreeDataProvider> </Providers.FileTreeDataProvider>
</Providers.SnapshotProvider>
</Providers.ProjectProvider> </Providers.ProjectProvider>
</Providers.UserSettingsProvider> </Providers.UserSettingsProvider>
</Providers.UserProvider> </Providers.UserProvider>

View file

@ -711,6 +711,7 @@
"full_doc_history": "Full document history", "full_doc_history": "Full document history",
"full_doc_history_info_v2": "You can see all the edits in your project and who made every change. Add labels to quickly access specific versions.", "full_doc_history_info_v2": "You can see all the edits in your project and who made every change. Add labels to quickly access specific versions.",
"full_document_history": "Full document <0>history</0>", "full_document_history": "Full document <0>history</0>",
"full_project_search": "Full Project Search",
"full_width": "Full width", "full_width": "Full width",
"gallery": "Gallery", "gallery": "Gallery",
"gallery_find_more": "Find More __itemPlural__", "gallery_find_more": "Find More __itemPlural__",
@ -1234,6 +1235,7 @@
"my_library": "My Library", "my_library": "My Library",
"n_items": "__count__ item", "n_items": "__count__ item",
"n_items_plural": "__count__ items", "n_items_plural": "__count__ items",
"n_matches": "__n__ matches",
"n_more_updates_above": "__count__ more update above", "n_more_updates_above": "__count__ more update above",
"n_more_updates_above_plural": "__count__ more updates above", "n_more_updates_above_plural": "__count__ more updates above",
"n_more_updates_below": "__count__ more update below", "n_more_updates_below": "__count__ more update below",
@ -1612,6 +1614,7 @@
"registered": "Registered", "registered": "Registered",
"registering": "Registering", "registering": "Registering",
"registration_error": "Registration error", "registration_error": "Registration error",
"regular_expression": "Regular Expression",
"reject": "Reject", "reject": "Reject",
"reject_all": "Reject all", "reject_all": "Reject all",
"reject_change": "Reject change", "reject_change": "Reject change",
@ -1725,6 +1728,7 @@
"saving_20_percent_no_exclamation": "Saving 20%", "saving_20_percent_no_exclamation": "Saving 20%",
"saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)", "saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)",
"search": "Search", "search": "Search",
"search_all_project_files": "Search all project files",
"search_bib_files": "Search by author, title, year", "search_bib_files": "Search by author, title, year",
"search_by_citekey_author_year_title": "Search by citation key, author, title, year", "search_by_citekey_author_year_title": "Search by citation key, author, title, year",
"search_command_find": "Find", "search_command_find": "Find",
@ -1744,6 +1748,7 @@
"search_replace_all": "Replace All", "search_replace_all": "Replace All",
"search_replace_with": "Replace with", "search_replace_with": "Replace with",
"search_search_for": "Search for", "search_search_for": "Search for",
"search_terms": "Search terms",
"search_whole_word": "Whole word", "search_whole_word": "Whole word",
"search_within_selection": "Within selection", "search_within_selection": "Within selection",
"searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"", "searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"",

View file

@ -298,6 +298,7 @@
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"events": "^3.3.0", "events": "^3.3.0",
"fake-indexeddb": "^6.0.0",
"fetch-mock": "^9.10.2", "fetch-mock": "^9.10.2",
"formik": "^2.2.9", "formik": "^2.2.9",
"glob": "^7.1.6", "glob": "^7.1.6",

View file

@ -92,3 +92,6 @@ addHook(() => '', { exts: ['.css'], ignoreNodeModules: false })
globalThis.HTMLElement.prototype.scrollIntoView = () => {} globalThis.HTMLElement.prototype.scrollIntoView = () => {}
globalThis.DOMParser = window.DOMParser globalThis.DOMParser = window.DOMParser
// Polyfill for IndexedDB
require('fake-indexeddb/auto')

View file

@ -34,6 +34,7 @@ describe('Path utils', function () {
{ {
_id: 'test-file-in-folder', _id: 'test-file-in-folder',
name: 'example.png', name: 'example.png',
hash: '42',
}, },
], ],
folders: [ folders: [
@ -50,6 +51,7 @@ describe('Path utils', function () {
{ {
_id: 'test-file-in-subfolder', _id: 'test-file-in-subfolder',
name: 'nested-example.png', name: 'nested-example.png',
hash: '43',
}, },
], ],
folders: [], folders: [],
@ -61,10 +63,12 @@ describe('Path utils', function () {
{ {
_id: 'test-image-file', _id: 'test-image-file',
name: 'frog.jpg', name: 'frog.jpg',
hash: '21',
}, },
{ {
_id: 'uppercase-extension-image-file', _id: 'uppercase-extension-image-file',
name: 'frog.JPG', name: 'frog.JPG',
hash: '22',
}, },
], ],
} }

View file

@ -7,6 +7,7 @@ describe('<FileViewImage />', function () {
const file = { const file = {
id: '60097ca20454610027c442a8', id: '60097ca20454610027c442a8',
name: 'file.jpg', name: 'file.jpg',
hash: 'hash',
linkedFileData: { linkedFileData: {
source_entity_path: '/source-entity-path', source_entity_path: '/source-entity-path',
provider: 'project_file', provider: 'project_file',
@ -15,12 +16,7 @@ describe('<FileViewImage />', function () {
it('renders an image', function () { it('renders an image', function () {
renderWithEditorContext( renderWithEditorContext(
<FileViewImage <FileViewImage file={file} onError={() => {}} onLoad={() => {}} />
fileName={file.name}
fileId={file.id}
onError={() => {}}
onLoad={() => {}}
/>
) )
screen.getByRole('img') screen.getByRole('img')
}) })

View file

@ -16,6 +16,7 @@ describe('<FileViewRefreshError />', function () {
name: 'frog.jpg', name: 'frog.jpg',
type: 'file', type: 'file',
selected: true, selected: true,
hash: '42',
} }
render( render(

View file

@ -6,6 +6,8 @@ import FileViewText from '../../../../../frontend/js/features/file-view/componen
describe('<FileViewText/>', function () { describe('<FileViewText/>', function () {
const file = { const file = {
id: '123',
hash: '1234',
name: 'example.tex', name: 'example.tex',
linkedFileData: { linkedFileData: {
v1_source_doc_id: 'v1-source-id', v1_source_doc_id: 'v1-source-id',

View file

@ -44,6 +44,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
{ {
_id: 'test-file-in-folder', _id: 'test-file-in-folder',
name: 'example.png', name: 'example.png',
hash: '42',
}, },
], ],
folders: [], folders: [],
@ -53,10 +54,12 @@ describe('autocomplete', { scrollBehavior: false }, function () {
{ {
_id: 'test-image-file', _id: 'test-image-file',
name: 'frog.jpg', name: 'frog.jpg',
hash: '21',
}, },
{ {
_id: 'uppercase-extension-image-file', _id: 'uppercase-extension-image-file',
name: 'frog.JPG', name: 'frog.JPG',
hash: '22',
}, },
], ],
}, },
@ -194,6 +197,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
{ {
_id: 'test-file-in-folder', _id: 'test-file-in-folder',
name: 'example.png', name: 'example.png',
hash: '42',
}, },
], ],
folders: [], folders: [],
@ -203,6 +207,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
{ {
_id: 'test-image-file', _id: 'test-image-file',
name: 'frog.jpg', name: 'frog.jpg',
hash: '43',
}, },
], ],
}, },

View file

@ -41,10 +41,12 @@ export const mockScope = (content?: string) => {
{ {
_id: figureId, _id: figureId,
name: 'frog.jpg', name: 'frog.jpg',
hash: '42',
}, },
{ {
_id: 'fake-figure-id', _id: 'fake-figure-id',
name: 'unicorn.png', name: 'unicorn.png',
hash: '43',
}, },
], ],
}, },

View file

@ -34,6 +34,7 @@ import { ModalsContextProvider } from '@/features/ide-react/context/modals-conte
import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context' import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context'
import { PermissionsProvider } from '@/features/ide-react/context/permissions-context' import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
import { ReferencesProvider } from '@/features/ide-react/context/references-context' import { ReferencesProvider } from '@/features/ide-react/context/references-context'
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
// these constants can be imported in tests instead of // these constants can be imported in tests instead of
// using magic strings // using magic strings
@ -159,6 +160,7 @@ export function EditorProviders({
DetachProvider, DetachProvider,
EditorProvider, EditorProvider,
EditorManagerProvider, EditorManagerProvider,
SnapshotProvider,
FileTreeDataProvider, FileTreeDataProvider,
FileTreeOpenProvider, FileTreeOpenProvider,
FileTreePathProvider, FileTreePathProvider,
@ -187,6 +189,7 @@ export function EditorProviders({
<Providers.UserProvider> <Providers.UserProvider>
<Providers.UserSettingsProvider> <Providers.UserSettingsProvider>
<Providers.ProjectProvider> <Providers.ProjectProvider>
<Providers.SnapshotProvider>
<Providers.FileTreeDataProvider> <Providers.FileTreeDataProvider>
<Providers.FileTreePathProvider> <Providers.FileTreePathProvider>
<Providers.ReferencesProvider> <Providers.ReferencesProvider>
@ -220,6 +223,7 @@ export function EditorProviders({
</Providers.ReferencesProvider> </Providers.ReferencesProvider>
</Providers.FileTreePathProvider> </Providers.FileTreePathProvider>
</Providers.FileTreeDataProvider> </Providers.FileTreeDataProvider>
</Providers.SnapshotProvider>
</Providers.ProjectProvider> </Providers.ProjectProvider>
</Providers.UserSettingsProvider> </Providers.UserSettingsProvider>
</Providers.UserProvider> </Providers.UserProvider>

View file

@ -3,4 +3,5 @@ export type FileRef = {
name: string name: string
created?: string created?: string
linkedFileData?: any linkedFileData?: any
hash: string
} }