mirror of https://github.com/overleaf/overleaf.git
Compare commits
37 Commits
a7de13c14c
...
ce6e33f2ed
Author | SHA1 | Date |
---|---|---|
William VINCENT | ce6e33f2ed | |
Alf Eaton | 9729befe59 | |
Alf Eaton | ab5495023a | |
CloudBuild | 49a74544b8 | |
Eric Mc Sween | 4114901617 | |
Eric Mc Sween | 65f20a4d56 | |
Jakob Ackermann | 4c49841637 | |
Jakob Ackermann | 0576e02127 | |
Tim Down | a452e1e8cd | |
Tim Down | 56150d9dbc | |
CloudBuild | fb05c0bb82 | |
Jessica Lawshe | a827e925c3 | |
Jessica Lawshe | ae0abd6445 | |
Andrew Rumble | 92f62f91c1 | |
CloudBuild | d02f175afa | |
Jimmy Domagala-Tang | 0ca7a385d5 | |
Antoine Clausse | a26c655220 | |
Antoine Clausse | 6a6f155029 | |
Domagoj Kriskovic | ebb34b40c1 | |
Rebeka Dekany | 62c2937dac | |
Alf Eaton | 417de9ee87 | |
Copybot | faf9bc39c4 | |
Alf Eaton | 08c784f58a | |
Alf Eaton | 8921b8484e | |
Andrew Rumble | 13bb42885e | |
Rebeka Dekany | 285a0cae03 | |
Rebeka Dekany | 46485e0347 | |
Jessica Lawshe | e9586079d4 | |
Eric Mc Sween | 501be34862 | |
Andrew Rumble | 9c3d9ef590 | |
Miguel Serrano | cee678591f | |
Antoine Clausse | cdd79e8ec0 | |
Antoine Clausse | 711d50a2f1 | |
CloudBuild | 70c05dd5f7 | |
Jakob Ackermann | afca054a22 | |
William VINCENT | ddd0170b3d | |
Christopher Schenk | 0c265db259 |
|
@ -13,6 +13,8 @@ services:
|
|||
condition: service_started
|
||||
ports:
|
||||
- 80:80
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=1
|
||||
links:
|
||||
- mongo
|
||||
- redis
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -80,6 +80,13 @@ COPY server-ce/config/custom-environment-variables.json /overleaf/services/histo
|
|||
ADD server-ce/bin/grunt /usr/local/bin/grunt
|
||||
RUN chmod +x /usr/local/bin/grunt
|
||||
|
||||
# Copy history helper scripts
|
||||
# ---------------------------
|
||||
ADD server-ce/bin/flush-history-queues /overleaf/bin/flush-history-queues
|
||||
RUN chmod +x /overleaf/bin/flush-history-queues
|
||||
ADD server-ce/bin/force-history-resyncs /overleaf/bin/force-history-resyncs
|
||||
RUN chmod +x /overleaf/bin/force-history-resyncs
|
||||
|
||||
# File that controls open|closed status of the site
|
||||
# -------------------------------------------------
|
||||
ENV SITE_MAINTENANCE_FILE "/etc/overleaf/site_status"
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source /etc/container_environment.sh
|
||||
source /etc/overleaf/env.sh
|
||||
cd /overleaf/services/project-history
|
||||
node scripts/flush_all.js 100000
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source /etc/container_environment.sh
|
||||
source /etc/overleaf/env.sh
|
||||
cd /overleaf/services/project-history
|
||||
node scripts/force_resync.js 1000 force
|
|
@ -14,6 +14,7 @@ const Metrics = require('@overleaf/metrics')
|
|||
const smokeTest = require('./test/smoke/js/SmokeTests')
|
||||
const ContentTypeMapper = require('./app/js/ContentTypeMapper')
|
||||
const Errors = require('./app/js/Errors')
|
||||
const { createOutputZip } = require('./app/js/OutputController')
|
||||
|
||||
const Path = require('path')
|
||||
|
||||
|
@ -170,6 +171,20 @@ const staticOutputServer = ForbidSymlinks(
|
|||
}
|
||||
)
|
||||
|
||||
// This needs to be before GET /project/:project_id/build/:build_id/output/*
|
||||
app.get(
|
||||
'/project/:project_id/build/:build_id/output/output.zip',
|
||||
bodyParser.json(),
|
||||
createOutputZip
|
||||
)
|
||||
|
||||
// This needs to be before GET /project/:project_id/user/:user_id/build/:build_id/output/*
|
||||
app.get(
|
||||
'/project/:project_id/user/:user_id/build/:build_id/output/output.zip',
|
||||
bodyParser.json(),
|
||||
createOutputZip
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/project/:project_id/user/:user_id/build/:build_id/output/*',
|
||||
function (req, res, next) {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
const logger = require('@overleaf/logger')
|
||||
const OutputFileArchiveManager = require('./OutputFileArchiveManager')
|
||||
const { expressify } = require('@overleaf/promise-utils')
|
||||
|
||||
function cleanFiles(files) {
|
||||
if (!Array.isArray(files)) {
|
||||
return []
|
||||
}
|
||||
return files.filter(file => /^output\./g.test(file))
|
||||
}
|
||||
|
||||
async function createOutputZip(req, res) {
|
||||
const {
|
||||
project_id: projectId,
|
||||
user_id: userId,
|
||||
build_id: buildId,
|
||||
} = req.params
|
||||
const files = cleanFiles(req.query.files)
|
||||
logger.debug({ projectId, userId, buildId, files }, 'Will create zip file')
|
||||
|
||||
const archive = await OutputFileArchiveManager.archiveFilesForBuild(
|
||||
projectId,
|
||||
userId,
|
||||
buildId,
|
||||
files
|
||||
)
|
||||
|
||||
archive.on('error', err => {
|
||||
logger.warn({ err }, 'error emitted when creating output files archive')
|
||||
})
|
||||
|
||||
res.attachment('output.zip')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
archive.pipe(res)
|
||||
}
|
||||
|
||||
module.exports = { createOutputZip: expressify(createOutputZip) }
|
|
@ -0,0 +1,90 @@
|
|||
let OutputFileArchiveManager
|
||||
const archiver = require('archiver')
|
||||
const OutputCacheManager = require('./OutputCacheManager')
|
||||
const OutputFileFinder = require('./OutputFileFinder')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const { open } = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const { NotFoundError } = require('./Errors')
|
||||
|
||||
function getContentDir(projectId, userId) {
|
||||
let subDir
|
||||
if (userId != null) {
|
||||
subDir = `${projectId}-${userId}`
|
||||
} else {
|
||||
subDir = projectId
|
||||
}
|
||||
return `${Settings.path.outputDir}/${subDir}/`
|
||||
}
|
||||
|
||||
module.exports = OutputFileArchiveManager = {
|
||||
async archiveFilesForBuild(projectId, userId, build, files = []) {
|
||||
const contentDir = getContentDir(projectId, userId)
|
||||
|
||||
const validFiles = await (files.length > 0
|
||||
? this._getRequestedOutputFiles(projectId, userId, build, files)
|
||||
: this._getAllOutputFiles(projectId, userId, build))
|
||||
|
||||
const archive = archiver('zip')
|
||||
|
||||
const missingFiles = files.filter(
|
||||
file => !validFiles.includes(path.basename(file))
|
||||
)
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
const fileHandle = await open(
|
||||
`${contentDir}${OutputCacheManager.path(build, file)}`
|
||||
)
|
||||
const fileStream = fileHandle.createReadStream()
|
||||
archive.append(fileStream, { name: file })
|
||||
} catch (error) {
|
||||
missingFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
archive.append(missingFiles.join('\n'), {
|
||||
name: 'missing_files.txt',
|
||||
})
|
||||
}
|
||||
|
||||
await archive.finalize()
|
||||
|
||||
return archive
|
||||
},
|
||||
|
||||
async _getAllOutputFiles(projectId, userId, build) {
|
||||
const contentDir = getContentDir(projectId, userId)
|
||||
|
||||
try {
|
||||
const { outputFiles } = await OutputFileFinder.promises.findOutputFiles(
|
||||
[],
|
||||
`${contentDir}${OutputCacheManager.path(build, '.')}`
|
||||
)
|
||||
|
||||
return outputFiles.map(({ path }) => path)
|
||||
} catch (error) {
|
||||
if (
|
||||
error.code === 'ENOENT' ||
|
||||
error.code === 'ENOTDIR' ||
|
||||
error.code === 'EACCES'
|
||||
) {
|
||||
throw new NotFoundError('Output files not found')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async _getRequestedOutputFiles(projectId, userId, build, files) {
|
||||
const outputFiles = new Set(
|
||||
await OutputFileArchiveManager._getAllOutputFiles(
|
||||
projectId,
|
||||
userId,
|
||||
build
|
||||
)
|
||||
)
|
||||
|
||||
return files.filter(file => outputFiles.has(file))
|
||||
},
|
||||
}
|
|
@ -23,6 +23,7 @@
|
|||
"@overleaf/o-error": "*",
|
||||
"@overleaf/promise-utils": "*",
|
||||
"@overleaf/settings": "*",
|
||||
"archiver": "5.3.2",
|
||||
"async": "3.2.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"bunyan": "^1.8.15",
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const MODULE_PATH = require('path').join(
|
||||
__dirname,
|
||||
'../../../app/js/OutputController'
|
||||
)
|
||||
|
||||
describe('OutputController', function () {
|
||||
describe('createOutputZip', function () {
|
||||
beforeEach(function () {
|
||||
this.archive = {
|
||||
on: sinon.stub(),
|
||||
pipe: sinon.stub(),
|
||||
}
|
||||
|
||||
this.archiveFilesForBuild = sinon.stub().resolves(this.archive)
|
||||
|
||||
this.OutputController = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'./OutputFileArchiveManager': {
|
||||
archiveFilesForBuild: this.archiveFilesForBuild,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('when OutputFileArchiveManager creates an archive', function () {
|
||||
beforeEach(function (done) {
|
||||
this.res = {
|
||||
attachment: sinon.stub(),
|
||||
setHeader: sinon.stub(),
|
||||
}
|
||||
this.req = {
|
||||
params: {
|
||||
project_id: 'project-id-123',
|
||||
user_id: 'user-id-123',
|
||||
build_id: 'build-id-123',
|
||||
},
|
||||
query: {
|
||||
files: ['output.tex', 'not-output.tex'],
|
||||
},
|
||||
}
|
||||
this.archive.pipe.callsFake(() => done())
|
||||
this.OutputController.createOutputZip(this.req, this.res)
|
||||
})
|
||||
|
||||
it('does not pass files that do not start with "output" to OutputFileArchiveManager', function () {
|
||||
sinon.assert.calledWith(
|
||||
this.archiveFilesForBuild,
|
||||
'project-id-123',
|
||||
'user-id-123',
|
||||
'build-id-123',
|
||||
['output.tex']
|
||||
)
|
||||
})
|
||||
|
||||
it('pipes the archive to the response', function () {
|
||||
sinon.assert.calledWith(this.archive.pipe, this.res)
|
||||
})
|
||||
|
||||
it('calls the express convenience method to set attachment headers', function () {
|
||||
sinon.assert.calledWith(this.res.attachment, 'output.zip')
|
||||
})
|
||||
|
||||
it('sets the X-Content-Type-Options header to nosniff', function () {
|
||||
sinon.assert.calledWith(
|
||||
this.res.setHeader,
|
||||
'X-Content-Type-Options',
|
||||
'nosniff'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when OutputFileArchiveManager throws an error', function () {
|
||||
let error
|
||||
|
||||
beforeEach(function (done) {
|
||||
error = new Error('error message')
|
||||
|
||||
this.archiveFilesForBuild.rejects(error)
|
||||
|
||||
this.res = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.stub(),
|
||||
}
|
||||
this.req = {
|
||||
params: {
|
||||
project_id: 'project-id-123',
|
||||
user_id: 'user-id-123',
|
||||
build_id: 'build-id-123',
|
||||
},
|
||||
query: {
|
||||
files: ['output.tex'],
|
||||
},
|
||||
}
|
||||
this.OutputController.createOutputZip(
|
||||
this.req,
|
||||
this.res,
|
||||
(this.next = sinon.stub().callsFake(() => {
|
||||
done()
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
it('calls next with the error', function () {
|
||||
sinon.assert.calledWith(this.next, error)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,234 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const { assert, expect } = require('chai')
|
||||
|
||||
const MODULE_PATH = require('path').join(
|
||||
__dirname,
|
||||
'../../../app/js/OutputFileArchiveManager'
|
||||
)
|
||||
|
||||
describe('OutputFileArchiveManager', function () {
|
||||
const userId = 'user-id-123'
|
||||
const projectId = 'project-id-123'
|
||||
const buildId = 'build-id-123'
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
this.OutputFileFinder = {
|
||||
promises: {
|
||||
findOutputFiles: sinon.stub().resolves({ outputFiles: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
this.OutputCacheManger = {
|
||||
path: sinon.stub().callsFake((build, path) => {
|
||||
return `${build}/${path}`
|
||||
}),
|
||||
}
|
||||
|
||||
this.archive = {
|
||||
append: sinon.stub(),
|
||||
finalize: sinon.stub(),
|
||||
}
|
||||
|
||||
this.archiver = sinon.stub().returns(this.archive)
|
||||
|
||||
this.outputDir = '/output/dir'
|
||||
|
||||
this.fs = {
|
||||
open: sinon.stub().callsFake(file => ({
|
||||
createReadStream: sinon.stub().returns(`handle: ${file}`),
|
||||
})),
|
||||
}
|
||||
|
||||
this.OutputFileArchiveManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'./OutputFileFinder': this.OutputFileFinder,
|
||||
'./OutputCacheManager': this.OutputCacheManger,
|
||||
archiver: this.archiver,
|
||||
'node:fs/promises': this.fs,
|
||||
'node:path': {
|
||||
basename: sinon.stub().callsFake(path => path.split('/').pop()),
|
||||
},
|
||||
'@overleaf/settings': {
|
||||
path: {
|
||||
outputDir: this.outputDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('when called with no files', function () {
|
||||
beforeEach(async function () {
|
||||
this.OutputFileFinder.promises.findOutputFiles.resolves({
|
||||
outputFiles: [
|
||||
{ path: 'file_1' },
|
||||
{ path: 'file_2' },
|
||||
{ path: 'file_3' },
|
||||
{ path: 'file_4' },
|
||||
],
|
||||
})
|
||||
await this.OutputFileArchiveManager.archiveFilesForBuild(
|
||||
projectId,
|
||||
userId,
|
||||
buildId
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a zip archive', function () {
|
||||
sinon.assert.calledWith(this.archiver, 'zip')
|
||||
})
|
||||
|
||||
it('adds all the output files to the archive', function () {
|
||||
expect(this.archive.append.callCount).to.equal(4)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
|
||||
sinon.match({ name: 'file_1' })
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_2`,
|
||||
sinon.match({ name: 'file_2' })
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_3`,
|
||||
sinon.match({ name: 'file_3' })
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_4`,
|
||||
sinon.match({ name: 'file_4' })
|
||||
)
|
||||
})
|
||||
|
||||
it('finalizes the archive after all files are appended', function () {
|
||||
sinon.assert.called(this.archive.finalize)
|
||||
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('when called with a list of files that all are in the output directory', function () {
|
||||
beforeEach(async function () {
|
||||
this.OutputFileFinder.promises.findOutputFiles.resolves({
|
||||
outputFiles: [
|
||||
{ path: 'file_1' },
|
||||
{ path: 'file_2' },
|
||||
{ path: 'file_3' },
|
||||
{ path: 'file_4' },
|
||||
],
|
||||
})
|
||||
await this.OutputFileArchiveManager.archiveFilesForBuild(
|
||||
projectId,
|
||||
userId,
|
||||
buildId,
|
||||
['file_1', 'file_4']
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a zip archive', function () {
|
||||
sinon.assert.calledWith(this.archiver, 'zip')
|
||||
})
|
||||
|
||||
it('adds only output files from the list of files to the archive', function () {
|
||||
expect(this.archive.append.callCount).to.equal(2)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
|
||||
sinon.match({
|
||||
name: 'file_1',
|
||||
})
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_4`,
|
||||
sinon.match({
|
||||
name: 'file_4',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('finalizes the archive after all files are appended', function () {
|
||||
sinon.assert.called(this.archive.finalize)
|
||||
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('when called with a list of files and one of the files is missing from the output directory', function () {
|
||||
beforeEach(async function () {
|
||||
this.OutputFileFinder.promises.findOutputFiles.resolves({
|
||||
outputFiles: [
|
||||
{ path: 'file_1' },
|
||||
{ path: 'file_2' },
|
||||
{ path: 'file_3' },
|
||||
],
|
||||
})
|
||||
await this.OutputFileArchiveManager.archiveFilesForBuild(
|
||||
projectId,
|
||||
userId,
|
||||
buildId,
|
||||
['file_1', 'file_4']
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a zip archive', function () {
|
||||
sinon.assert.calledWith(this.archiver, 'zip')
|
||||
})
|
||||
|
||||
it('adds the files that were found to the archive', function () {
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
`handle: ${this.outputDir}/${projectId}-${userId}/${buildId}/file_1`,
|
||||
sinon.match({ name: 'file_1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('adds a file listing any missing files', function () {
|
||||
sinon.assert.calledWith(
|
||||
this.archive.append,
|
||||
'file_4',
|
||||
sinon.match({
|
||||
name: 'missing_files.txt',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('finalizes the archive after all files are appended', function () {
|
||||
sinon.assert.called(this.archive.finalize)
|
||||
expect(this.archive.finalize.calledBefore(this.archive.append)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the output directory cannot be accessed', function () {
|
||||
beforeEach(async function () {
|
||||
this.OutputFileFinder.promises.findOutputFiles.rejects({
|
||||
code: 'ENOENT',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with a NotFoundError', async function () {
|
||||
try {
|
||||
await this.OutputFileArchiveManager.archiveFilesForBuild(
|
||||
projectId,
|
||||
userId,
|
||||
buildId
|
||||
)
|
||||
assert.fail('should have thrown a NotFoundError')
|
||||
} catch (err) {
|
||||
expect(err).to.haveOwnProperty('name', 'NotFoundError')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create an archive', function () {
|
||||
expect(this.archiver.called).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,183 @@
|
|||
// @ts-check
|
||||
|
||||
const _ = require('lodash')
|
||||
const { isDelete } = require('./Utils')
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').Comment} Comment
|
||||
* @typedef {import('./types').HistoryComment} HistoryComment
|
||||
* @typedef {import('./types').HistoryRanges} HistoryRanges
|
||||
* @typedef {import('./types').HistoryTrackedChange} HistoryTrackedChange
|
||||
* @typedef {import('./types').Ranges} Ranges
|
||||
* @typedef {import('./types').TrackedChange} TrackedChange
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert editor ranges to history ranges
|
||||
*
|
||||
* @param {Ranges} ranges
|
||||
* @return {HistoryRanges}
|
||||
*/
|
||||
function toHistoryRanges(ranges) {
|
||||
const changes = ranges.changes ?? []
|
||||
const comments = (ranges.comments ?? []).slice()
|
||||
|
||||
// Changes are assumed to be sorted, but not comments
|
||||
comments.sort((a, b) => a.op.p - b.op.p)
|
||||
|
||||
/**
|
||||
* This will allow us to go through comments at a different pace as we loop
|
||||
* through tracked changes
|
||||
*/
|
||||
const commentsIterator = new CommentsIterator(comments)
|
||||
|
||||
/**
|
||||
* Current offset between editor pos and history pos
|
||||
*/
|
||||
let offset = 0
|
||||
|
||||
/**
|
||||
* History comments that might overlap with the tracked change considered
|
||||
*
|
||||
* @type {HistoryComment[]}
|
||||
*/
|
||||
let pendingComments = []
|
||||
|
||||
/**
|
||||
* The final history comments generated
|
||||
*
|
||||
* @type {HistoryComment[]}
|
||||
*/
|
||||
const historyComments = []
|
||||
|
||||
/**
|
||||
* The final history tracked changes generated
|
||||
*
|
||||
* @type {HistoryTrackedChange[]}
|
||||
*/
|
||||
const historyChanges = []
|
||||
|
||||
for (const change of changes) {
|
||||
historyChanges.push(toHistoryChange(change, offset))
|
||||
|
||||
// After this point, we're only interested in tracked deletes
|
||||
if (!isDelete(change.op)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fill pendingComments with new comments that start before this tracked
|
||||
// delete and might overlap
|
||||
for (const comment of commentsIterator.nextComments(change.op.p)) {
|
||||
pendingComments.push(toHistoryComment(comment, offset))
|
||||
}
|
||||
|
||||
// Save comments that are fully before this tracked delete
|
||||
const newPendingComments = []
|
||||
for (const historyComment of pendingComments) {
|
||||
const commentEnd = historyComment.op.p + historyComment.op.c.length
|
||||
if (commentEnd <= change.op.p) {
|
||||
historyComments.push(historyComment)
|
||||
} else {
|
||||
newPendingComments.push(historyComment)
|
||||
}
|
||||
}
|
||||
pendingComments = newPendingComments
|
||||
|
||||
// The rest of pending comments overlap with this tracked change. Adjust
|
||||
// their history length.
|
||||
for (const historyComment of pendingComments) {
|
||||
historyComment.op.hlen =
|
||||
(historyComment.op.hlen ?? historyComment.op.c.length) +
|
||||
change.op.d.length
|
||||
}
|
||||
|
||||
// Adjust the offset
|
||||
offset += change.op.d.length
|
||||
}
|
||||
// Save the last pending comments
|
||||
for (const historyComment of pendingComments) {
|
||||
historyComments.push(historyComment)
|
||||
}
|
||||
|
||||
// Save any comments that came after the last tracked change
|
||||
for (const comment of commentsIterator.nextComments()) {
|
||||
historyComments.push(toHistoryComment(comment, offset))
|
||||
}
|
||||
|
||||
const historyRanges = {}
|
||||
if (historyComments.length > 0) {
|
||||
historyRanges.comments = historyComments
|
||||
}
|
||||
if (historyChanges.length > 0) {
|
||||
historyRanges.changes = historyChanges
|
||||
}
|
||||
return historyRanges
|
||||
}
|
||||
|
||||
class CommentsIterator {
|
||||
/**
|
||||
* Build a CommentsIterator
|
||||
*
|
||||
* @param {Comment[]} comments
|
||||
*/
|
||||
constructor(comments) {
|
||||
this.comments = comments
|
||||
this.currentIndex = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator that returns the next comments to consider
|
||||
*
|
||||
* @param {number} beforePos - only return comments that start before this position
|
||||
* @return {Iterable<Comment>}
|
||||
*/
|
||||
*nextComments(beforePos = Infinity) {
|
||||
while (this.currentIndex < this.comments.length) {
|
||||
const comment = this.comments[this.currentIndex]
|
||||
if (comment.op.p < beforePos) {
|
||||
yield comment
|
||||
this.currentIndex += 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an editor tracked change into a history tracked change
|
||||
*
|
||||
* @param {TrackedChange} change
|
||||
* @param {number} offset - how much the history change is ahead of the
|
||||
* editor change
|
||||
* @return {HistoryTrackedChange}
|
||||
*/
|
||||
function toHistoryChange(change, offset) {
|
||||
/** @type {HistoryTrackedChange} */
|
||||
const historyChange = _.cloneDeep(change)
|
||||
if (offset > 0) {
|
||||
historyChange.op.hpos = change.op.p + offset
|
||||
}
|
||||
return historyChange
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an editor comment into a history comment
|
||||
*
|
||||
* @param {Comment} comment
|
||||
* @param {number} offset - how much the history comment is ahead of the
|
||||
* editor comment
|
||||
* @return {HistoryComment}
|
||||
*/
|
||||
function toHistoryComment(comment, offset) {
|
||||
/** @type {HistoryComment} */
|
||||
const historyComment = _.cloneDeep(comment)
|
||||
if (offset > 0) {
|
||||
historyComment.op.hpos = comment.op.p + offset
|
||||
}
|
||||
return historyComment
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toHistoryRanges,
|
||||
}
|
|
@ -10,6 +10,7 @@ const logger = require('@overleaf/logger')
|
|||
const metrics = require('./Metrics')
|
||||
const { docIsTooLarge } = require('./Limits')
|
||||
const { addTrackedDeletesToContent } = require('./Utils')
|
||||
const HistoryConversions = require('./HistoryConversions')
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
/**
|
||||
|
@ -170,7 +171,8 @@ const ProjectHistoryRedisManager = {
|
|||
}
|
||||
|
||||
if (historyRangesSupport) {
|
||||
projectUpdate.resyncDocContent.ranges = ranges
|
||||
projectUpdate.resyncDocContent.ranges =
|
||||
HistoryConversions.toHistoryRanges(ranges)
|
||||
}
|
||||
|
||||
const jsonUpdate = JSON.stringify(projectUpdate)
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { TrackingPropsRawData } from 'overleaf-editor-core/types/lib/types'
|
||||
|
||||
/**
|
||||
* An update coming from the editor
|
||||
*/
|
||||
export type Update = {
|
||||
doc: string
|
||||
op: Op[]
|
||||
|
@ -37,6 +40,9 @@ export type CommentOp = {
|
|||
u?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranges record on a document
|
||||
*/
|
||||
export type Ranges = {
|
||||
comments?: Comment[]
|
||||
changes?: TrackedChange[]
|
||||
|
@ -53,14 +59,35 @@ export type Comment = {
|
|||
|
||||
export type TrackedChange = {
|
||||
id: string
|
||||
op: Op
|
||||
op: InsertOp | DeleteOp
|
||||
metadata: {
|
||||
user_id: string
|
||||
ts: string
|
||||
}
|
||||
}
|
||||
|
||||
export type HistoryOp = HistoryInsertOp | HistoryDeleteOp | HistoryCommentOp | HistoryRetainOp
|
||||
/**
|
||||
* Updates sent to project-history
|
||||
*/
|
||||
export type HistoryUpdate = {
|
||||
op: HistoryOp[]
|
||||
doc: string
|
||||
v?: number
|
||||
meta?: {
|
||||
pathname?: string
|
||||
doc_length?: number
|
||||
history_doc_length?: number
|
||||
tc?: boolean
|
||||
user_id?: string
|
||||
}
|
||||
projectHistoryId?: string
|
||||
}
|
||||
|
||||
export type HistoryOp =
|
||||
| HistoryInsertOp
|
||||
| HistoryDeleteOp
|
||||
| HistoryCommentOp
|
||||
| HistoryRetainOp
|
||||
|
||||
export type HistoryInsertOp = InsertOp & {
|
||||
commentIds?: string[]
|
||||
|
@ -89,16 +116,13 @@ export type HistoryCommentOp = CommentOp & {
|
|||
hlen?: number
|
||||
}
|
||||
|
||||
export type HistoryUpdate = {
|
||||
op: HistoryOp[]
|
||||
doc: string
|
||||
v?: number
|
||||
meta?: {
|
||||
pathname?: string
|
||||
doc_length?: number
|
||||
history_doc_length?: number
|
||||
tc?: boolean
|
||||
user_id?: string
|
||||
}
|
||||
projectHistoryId?: string
|
||||
export type HistoryRanges = {
|
||||
comments?: HistoryComment[]
|
||||
changes?: HistoryTrackedChange[]
|
||||
}
|
||||
|
||||
export type HistoryComment = Comment & { op: HistoryCommentOp }
|
||||
|
||||
export type HistoryTrackedChange = TrackedChange & {
|
||||
op: HistoryInsertOp | HistoryDeleteOp
|
||||
}
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
const HistoryConversions = require('../../../app/js/HistoryConversions')
|
||||
|
||||
describe('HistoryConversions', function () {
|
||||
describe('toHistoryRanges', function () {
|
||||
it('handles empty ranges', function () {
|
||||
expect(HistoryConversions.toHistoryRanges({})).to.deep.equal({})
|
||||
})
|
||||
|
||||
it("doesn't modify comments when there are no tracked changes", function () {
|
||||
const ranges = {
|
||||
comments: [makeComment('comment1', 5, 12)],
|
||||
}
|
||||
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
|
||||
expect(historyRanges).to.deep.equal(ranges)
|
||||
})
|
||||
|
||||
it('adjusts comments and tracked changes to account for tracked deletes', function () {
|
||||
const comments = [
|
||||
makeComment('comment0', 0, 1),
|
||||
makeComment('comment1', 10, 12),
|
||||
makeComment('comment2', 20, 10),
|
||||
makeComment('comment3', 15, 3),
|
||||
]
|
||||
const changes = [
|
||||
makeTrackedDelete('change0', 2, 5),
|
||||
makeTrackedInsert('change1', 4, 5),
|
||||
makeTrackedDelete('change2', 10, 10),
|
||||
makeTrackedDelete('change3', 21, 6),
|
||||
makeTrackedDelete('change4', 50, 7),
|
||||
]
|
||||
const ranges = { comments, changes }
|
||||
|
||||
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
|
||||
expect(historyRanges.comments).to.have.deep.members([
|
||||
comments[0],
|
||||
// shifted by change0 and change2, extended by change3
|
||||
enrichOp(comments[1], {
|
||||
hpos: 25, // 10 + 5 + 10
|
||||
hlen: 18, // 12 + 6
|
||||
}),
|
||||
// shifted by change0 and change2, extended by change3
|
||||
enrichOp(comments[2], {
|
||||
hpos: 35, // 20 + 5 + 10
|
||||
hlen: 16, // 10 + 6
|
||||
}),
|
||||
// shifted by change0 and change2
|
||||
enrichOp(comments[3], {
|
||||
hpos: 30, // 15 + 5 + 10
|
||||
}),
|
||||
])
|
||||
expect(historyRanges.changes).to.deep.equal([
|
||||
changes[0],
|
||||
enrichOp(changes[1], {
|
||||
hpos: 9, // 4 + 5
|
||||
}),
|
||||
enrichOp(changes[2], {
|
||||
hpos: 15, // 10 + 5
|
||||
}),
|
||||
enrichOp(changes[3], {
|
||||
hpos: 36, // 21 + 5 + 10
|
||||
}),
|
||||
enrichOp(changes[4], {
|
||||
hpos: 71, // 50 + 5 + 10 + 6
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeComment(id, pos, length) {
|
||||
return {
|
||||
id,
|
||||
op: {
|
||||
c: 'c'.repeat(length),
|
||||
p: pos,
|
||||
t: id,
|
||||
},
|
||||
metadata: makeMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTrackedInsert(id, pos, length) {
|
||||
return {
|
||||
id,
|
||||
op: {
|
||||
i: 'i'.repeat(length),
|
||||
p: pos,
|
||||
},
|
||||
metadata: makeMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTrackedDelete(id, pos, length) {
|
||||
return {
|
||||
id,
|
||||
op: {
|
||||
d: 'd'.repeat(length),
|
||||
p: pos,
|
||||
},
|
||||
metadata: makeMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
function makeMetadata() {
|
||||
return {
|
||||
user_id: 'user-id',
|
||||
ts: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function enrichOp(commentOrChange, extraFields) {
|
||||
const result = _.cloneDeep(commentOrChange)
|
||||
Object.assign(result.op, extraFields)
|
||||
return result
|
||||
}
|
|
@ -170,10 +170,11 @@ _mocks._countAndProcessUpdates = (
|
|||
_processUpdatesBatch(projectId, updates, extendLock, cb)
|
||||
},
|
||||
error => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
callback(null, queueSize)
|
||||
// Unconventional callback signature. The caller needs the queue size
|
||||
// even when an error is thrown in order to record the queue size in
|
||||
// the projectHistoryFailures collection. We'll have to find another
|
||||
// way to achieve this when we promisify.
|
||||
callback(error, queueSize)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["stylelint-config-standard-scss"]
|
||||
}
|
|
@ -11,7 +11,6 @@ const basicAuth = require('basic-auth')
|
|||
const tsscmp = require('tsscmp')
|
||||
const UserHandler = require('../User/UserHandler')
|
||||
const UserSessionsManager = require('../User/UserSessionsManager')
|
||||
const SessionStoreManager = require('../../infrastructure/SessionStoreManager')
|
||||
const Analytics = require('../Analytics/AnalyticsManager')
|
||||
const passport = require('passport')
|
||||
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
||||
|
@ -409,31 +408,6 @@ const AuthenticationController = {
|
|||
return expressify(middleware)
|
||||
},
|
||||
|
||||
validateUserSession: function () {
|
||||
// Middleware to check that the user's session is still good on key actions,
|
||||
// such as opening a a project. Could be used to check that session has not
|
||||
// exceeded a maximum lifetime (req.session.session_created), or for session
|
||||
// hijacking checks (e.g. change of ip address, req.session.ip_address). For
|
||||
// now, just check that the session has been loaded from the session store
|
||||
// correctly.
|
||||
return function (req, res, next) {
|
||||
// check that the session store is returning valid results
|
||||
if (req.session && !SessionStoreManager.hasValidationToken(req)) {
|
||||
// Force user to update session by destroying the current one.
|
||||
// A new session will be created on the next request.
|
||||
req.session.destroy(() => {
|
||||
// need to destroy the existing session and generate a new one
|
||||
// otherwise they will already be logged in when they are redirected
|
||||
// to the login page
|
||||
if (acceptsJson(req)) return send401WithChallenge(res)
|
||||
AuthenticationController._redirectToLoginOrRegisterPage(req, res)
|
||||
})
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_globalLoginWhitelist: [],
|
||||
addEndpointToLoginWhitelist(endpoint) {
|
||||
return AuthenticationController._globalLoginWhitelist.push(endpoint)
|
||||
|
|
|
@ -9,254 +9,219 @@ const ClsiManager = require('./ClsiManager')
|
|||
const Metrics = require('@overleaf/metrics')
|
||||
const { RateLimiter } = require('../../infrastructure/RateLimiter')
|
||||
const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
|
||||
const {
|
||||
callbackify,
|
||||
callbackifyMultiResult,
|
||||
} = require('@overleaf/promise-utils')
|
||||
|
||||
function instrumentWithTimer(fn, key) {
|
||||
return async (...args) => {
|
||||
const timer = new Metrics.Timer(key)
|
||||
try {
|
||||
return await fn(...args)
|
||||
} finally {
|
||||
timer.done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compile(projectId, userId, options = {}) {
|
||||
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
|
||||
projectId,
|
||||
userId
|
||||
)
|
||||
if (recentlyCompiled) {
|
||||
return { status: 'too-recently-compiled', outputFiles: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const canCompile = await CompileManager._checkIfAutoCompileLimitHasBeenHit(
|
||||
options.isAutoCompile,
|
||||
'everyone'
|
||||
)
|
||||
if (!canCompile) {
|
||||
return { status: 'autocompile-backoff', outputFiles: [] }
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'autocompile-backoff', outputFiles: [] }
|
||||
}
|
||||
|
||||
await ProjectRootDocManager.promises.ensureRootDocumentIsSet(projectId)
|
||||
|
||||
const limits =
|
||||
await CompileManager.promises.getProjectCompileLimits(projectId)
|
||||
for (const key in limits) {
|
||||
const value = limits[key]
|
||||
options[key] = value
|
||||
}
|
||||
|
||||
try {
|
||||
const canCompile = await CompileManager._checkCompileGroupAutoCompileLimit(
|
||||
options.isAutoCompile,
|
||||
limits.compileGroup
|
||||
)
|
||||
if (!canCompile) {
|
||||
return { status: 'autocompile-backoff', outputFiles: [] }
|
||||
}
|
||||
} catch (error) {
|
||||
return { message: 'autocompile-backoff', outputFiles: [] }
|
||||
}
|
||||
|
||||
// only pass userId down to clsi if this is a per-user compile
|
||||
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
|
||||
const {
|
||||
status,
|
||||
outputFiles,
|
||||
clsiServerId,
|
||||
validationProblems,
|
||||
stats,
|
||||
timings,
|
||||
outputUrlPrefix,
|
||||
} = await ClsiManager.promises.sendRequest(projectId, compileAsUser, options)
|
||||
|
||||
return {
|
||||
status,
|
||||
outputFiles,
|
||||
clsiServerId,
|
||||
limits,
|
||||
validationProblems,
|
||||
stats,
|
||||
timings,
|
||||
outputUrlPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
const instrumentedCompile = instrumentWithTimer(compile, 'editor.compile')
|
||||
|
||||
async function getProjectCompileLimits(projectId) {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
owner_ref: 1,
|
||||
})
|
||||
|
||||
const owner = await UserGetter.promises.getUser(project.owner_ref, {
|
||||
_id: 1,
|
||||
alphaProgram: 1,
|
||||
analyticsId: 1,
|
||||
betaProgram: 1,
|
||||
features: 1,
|
||||
})
|
||||
|
||||
const ownerFeatures = (owner && owner.features) || {}
|
||||
// put alpha users into their own compile group
|
||||
if (owner && owner.alphaProgram) {
|
||||
ownerFeatures.compileGroup = 'alpha'
|
||||
}
|
||||
const analyticsId = await UserAnalyticsIdCache.get(owner._id)
|
||||
|
||||
const compileGroup =
|
||||
ownerFeatures.compileGroup || Settings.defaultFeatures.compileGroup
|
||||
const limits = {
|
||||
timeout:
|
||||
ownerFeatures.compileTimeout || Settings.defaultFeatures.compileTimeout,
|
||||
compileGroup,
|
||||
compileBackendClass: compileGroup === 'standard' ? 'n2d' : 'c2d',
|
||||
ownerAnalyticsId: analyticsId,
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
async function wordCount(projectId, userId, file, clsiserverid) {
|
||||
const limits =
|
||||
await CompileManager.promises.getProjectCompileLimits(projectId)
|
||||
return await ClsiManager.promises.wordCount(
|
||||
projectId,
|
||||
userId,
|
||||
file,
|
||||
limits,
|
||||
clsiserverid
|
||||
)
|
||||
}
|
||||
|
||||
async function stopCompile(projectId, userId) {
|
||||
const limits =
|
||||
await CompileManager.promises.getProjectCompileLimits(projectId)
|
||||
|
||||
return await ClsiManager.promises.stopCompile(projectId, userId, limits)
|
||||
}
|
||||
|
||||
async function deleteAuxFiles(projectId, userId, clsiserverid) {
|
||||
const limits =
|
||||
await CompileManager.promises.getProjectCompileLimits(projectId)
|
||||
|
||||
return await ClsiManager.promises.deleteAuxFiles(
|
||||
projectId,
|
||||
userId,
|
||||
limits,
|
||||
clsiserverid
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = CompileManager = {
|
||||
compile(projectId, userId, options = {}, _callback) {
|
||||
const timer = new Metrics.Timer('editor.compile')
|
||||
const callback = function (...args) {
|
||||
timer.done()
|
||||
_callback(...args)
|
||||
}
|
||||
|
||||
CompileManager._checkIfRecentlyCompiled(
|
||||
projectId,
|
||||
userId,
|
||||
function (error, recentlyCompiled) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
if (recentlyCompiled) {
|
||||
return callback(null, 'too-recently-compiled', [])
|
||||
}
|
||||
|
||||
CompileManager._checkIfAutoCompileLimitHasBeenHit(
|
||||
options.isAutoCompile,
|
||||
'everyone',
|
||||
function (err, canCompile) {
|
||||
if (err || !canCompile) {
|
||||
return callback(null, 'autocompile-backoff', [])
|
||||
}
|
||||
|
||||
ProjectRootDocManager.ensureRootDocumentIsSet(
|
||||
projectId,
|
||||
function (error) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
CompileManager.getProjectCompileLimits(
|
||||
projectId,
|
||||
function (error, limits) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
for (const key in limits) {
|
||||
const value = limits[key]
|
||||
options[key] = value
|
||||
}
|
||||
// Put a lower limit on autocompiles for free users, based on compileGroup
|
||||
CompileManager._checkCompileGroupAutoCompileLimit(
|
||||
options.isAutoCompile,
|
||||
limits.compileGroup,
|
||||
function (err, canCompile) {
|
||||
if (err || !canCompile) {
|
||||
return callback(null, 'autocompile-backoff', [])
|
||||
}
|
||||
// only pass userId down to clsi if this is a per-user compile
|
||||
const compileAsUser = Settings.disablePerUserCompiles
|
||||
? undefined
|
||||
: userId
|
||||
ClsiManager.sendRequest(
|
||||
projectId,
|
||||
compileAsUser,
|
||||
options,
|
||||
function (
|
||||
error,
|
||||
status,
|
||||
outputFiles,
|
||||
clsiServerId,
|
||||
validationProblems,
|
||||
stats,
|
||||
timings,
|
||||
outputUrlPrefix
|
||||
) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
callback(
|
||||
null,
|
||||
status,
|
||||
outputFiles,
|
||||
clsiServerId,
|
||||
limits,
|
||||
validationProblems,
|
||||
stats,
|
||||
timings,
|
||||
outputUrlPrefix
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
promises: {
|
||||
compile: instrumentedCompile,
|
||||
deleteAuxFiles,
|
||||
getProjectCompileLimits,
|
||||
stopCompile,
|
||||
wordCount,
|
||||
},
|
||||
compile: callbackifyMultiResult(instrumentedCompile, [
|
||||
'status',
|
||||
'outputFiles',
|
||||
'clsiServerId',
|
||||
'limits',
|
||||
'validationProblems',
|
||||
'stats',
|
||||
'timings',
|
||||
'outputUrlPrefix',
|
||||
]),
|
||||
|
||||
stopCompile(projectId, userId, callback) {
|
||||
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
ClsiManager.stopCompile(projectId, userId, limits, callback)
|
||||
})
|
||||
},
|
||||
stopCompile: callbackify(stopCompile),
|
||||
|
||||
deleteAuxFiles(projectId, userId, clsiserverid, callback) {
|
||||
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
ClsiManager.deleteAuxFiles(
|
||||
projectId,
|
||||
userId,
|
||||
limits,
|
||||
clsiserverid,
|
||||
callback
|
||||
)
|
||||
})
|
||||
},
|
||||
deleteAuxFiles: callbackify(deleteAuxFiles),
|
||||
|
||||
getProjectCompileLimits(projectId, callback) {
|
||||
ProjectGetter.getProject(
|
||||
projectId,
|
||||
{ owner_ref: 1 },
|
||||
function (error, project) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
UserGetter.getUser(
|
||||
project.owner_ref,
|
||||
{
|
||||
_id: 1,
|
||||
alphaProgram: 1,
|
||||
analyticsId: 1,
|
||||
betaProgram: 1,
|
||||
features: 1,
|
||||
},
|
||||
function (err, owner) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
const ownerFeatures = (owner && owner.features) || {}
|
||||
// put alpha users into their own compile group
|
||||
if (owner && owner.alphaProgram) {
|
||||
ownerFeatures.compileGroup = 'alpha'
|
||||
}
|
||||
UserAnalyticsIdCache.callbacks.get(
|
||||
owner._id,
|
||||
function (err, analyticsId) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
const compileGroup =
|
||||
ownerFeatures.compileGroup ||
|
||||
Settings.defaultFeatures.compileGroup
|
||||
const limits = {
|
||||
timeout:
|
||||
ownerFeatures.compileTimeout ||
|
||||
Settings.defaultFeatures.compileTimeout,
|
||||
compileGroup,
|
||||
compileBackendClass:
|
||||
compileGroup === 'standard' ? 'n2d' : 'c2d',
|
||||
ownerAnalyticsId: analyticsId,
|
||||
}
|
||||
callback(null, limits)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
getProjectCompileLimits: callbackify(getProjectCompileLimits),
|
||||
|
||||
COMPILE_DELAY: 1, // seconds
|
||||
_checkIfRecentlyCompiled(projectId, userId, callback) {
|
||||
async _checkIfRecentlyCompiled(projectId, userId) {
|
||||
const key = `compile:${projectId}:${userId}`
|
||||
rclient.set(
|
||||
key,
|
||||
true,
|
||||
'EX',
|
||||
this.COMPILE_DELAY,
|
||||
'NX',
|
||||
function (error, ok) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
if (ok === 'OK') {
|
||||
callback(null, false)
|
||||
} else {
|
||||
callback(null, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
const ok = await rclient.set(key, true, 'EX', this.COMPILE_DELAY, 'NX')
|
||||
return ok !== 'OK'
|
||||
},
|
||||
|
||||
_checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup, callback) {
|
||||
async _checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup) {
|
||||
if (!isAutoCompile) {
|
||||
return callback(null, true)
|
||||
return true
|
||||
}
|
||||
if (compileGroup === 'standard') {
|
||||
// apply extra limits to the standard compile group
|
||||
CompileManager._checkIfAutoCompileLimitHasBeenHit(
|
||||
return await CompileManager._checkIfAutoCompileLimitHasBeenHit(
|
||||
isAutoCompile,
|
||||
compileGroup,
|
||||
callback
|
||||
compileGroup
|
||||
)
|
||||
} else {
|
||||
Metrics.inc(`auto-compile-${compileGroup}`)
|
||||
callback(null, true)
|
||||
return true
|
||||
}
|
||||
}, // always allow priority group users to compile
|
||||
|
||||
_checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup, callback) {
|
||||
async _checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup) {
|
||||
if (!isAutoCompile) {
|
||||
return callback(null, true)
|
||||
return true
|
||||
}
|
||||
Metrics.inc(`auto-compile-${compileGroup}`)
|
||||
const rateLimiter = getAutoCompileRateLimiter(compileGroup)
|
||||
rateLimiter
|
||||
.consume('global', 1, { method: 'global' })
|
||||
.then(() => {
|
||||
callback(null, true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't differentiate between errors and rate limits. Silently trigger
|
||||
// the rate limit if there's an error consuming the points.
|
||||
Metrics.inc(`auto-compile-${compileGroup}-limited`)
|
||||
callback(null, false)
|
||||
})
|
||||
try {
|
||||
await rateLimiter.consume('global', 1, { method: 'global' })
|
||||
return true
|
||||
} catch (e) {
|
||||
// Don't differentiate between errors and rate limits. Silently trigger
|
||||
// the rate limit if there's an error consuming the points.
|
||||
Metrics.inc(`auto-compile-${compileGroup}-limited`)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
wordCount(projectId, userId, file, clsiserverid, callback) {
|
||||
CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
ClsiManager.wordCount(
|
||||
projectId,
|
||||
userId,
|
||||
file,
|
||||
limits,
|
||||
clsiserverid,
|
||||
callback
|
||||
)
|
||||
})
|
||||
},
|
||||
wordCount: callbackify(wordCount),
|
||||
}
|
||||
|
||||
const autoCompileRateLimiters = new Map()
|
||||
|
|
|
@ -12,6 +12,7 @@ const {
|
|||
handleAdminDomainRedirect,
|
||||
} = require('../Authorization/AuthorizationMiddleware')
|
||||
const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
|
||||
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
|
||||
|
||||
const orderedPrivilegeLevels = [
|
||||
PrivilegeLevels.NONE,
|
||||
|
@ -97,7 +98,18 @@ async function tokenAccessPage(req, res, next) {
|
|||
}
|
||||
}
|
||||
|
||||
res.render('project/token/access', {
|
||||
const { variant } = await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
res,
|
||||
'token-access-page'
|
||||
)
|
||||
|
||||
const view =
|
||||
variant === 'react'
|
||||
? 'project/token/access-react'
|
||||
: 'project/token/access'
|
||||
|
||||
res.render(view, {
|
||||
postUrl: makePostUrl(token),
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
|
@ -4,6 +4,7 @@ const metrics = require('@overleaf/metrics')
|
|||
const logger = require('@overleaf/logger')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const SessionManager = require('../Features/Authentication/SessionManager')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
|
||||
const MAX_SESSION_SIZE_THRESHOLD = 4096
|
||||
|
||||
|
@ -55,22 +56,30 @@ class CustomSessionStore extends RedisStore {
|
|||
}
|
||||
}
|
||||
|
||||
// Override the get, set, touch, and destroy methods to record metrics
|
||||
get(sid, cb) {
|
||||
super.get(sid, (err, ...args) => {
|
||||
if (args[0]) {
|
||||
CustomSessionStore.metric('get', args[0])
|
||||
}
|
||||
cb(err, ...args)
|
||||
super.get(sid, (err, sess) => {
|
||||
if (err || !sess || !checkValidationToken(sid, sess)) return cb(err, null)
|
||||
CustomSessionStore.metric('get', sess)
|
||||
cb(null, sess)
|
||||
})
|
||||
}
|
||||
|
||||
set(sid, sess, cb) {
|
||||
// Refresh the validation token just before writing to Redis
|
||||
// This will ensure that the token is always matching to the sessionID that we write the session value for.
|
||||
// Potential reasons for missing/mismatching token:
|
||||
// - brand-new session
|
||||
// - cycling of the sessionID as part of the login flow
|
||||
// - upgrade from a client side session to a redis session
|
||||
// - accidental writes in the app code
|
||||
sess.validationToken = computeValidationToken(sid)
|
||||
|
||||
CustomSessionStore.metric('set', sess)
|
||||
const originalId = sess.req.signedCookies[Settings.cookieName]
|
||||
if (sid === originalId || sid === sess.req.newSessionId) {
|
||||
this.#updateInPlaceStore.set(sid, sess, cb)
|
||||
} else {
|
||||
Metrics.inc('security.session', 1, { status: 'new' })
|
||||
// Multiple writes can get issued with the new sid. Keep track of it.
|
||||
Object.defineProperty(sess.req, 'newSessionId', { value: sid })
|
||||
this.#initialSetStore.set(sid, sess, cb)
|
||||
|
@ -89,6 +98,35 @@ class CustomSessionStore extends RedisStore {
|
|||
}
|
||||
}
|
||||
|
||||
function computeValidationToken(sid) {
|
||||
// This should be a deterministic function of the client-side sessionID,
|
||||
// prepended with a version number in case we want to change it later.
|
||||
return 'v1:' + sid.slice(-4)
|
||||
}
|
||||
|
||||
function checkValidationToken(sid, sess) {
|
||||
const sessionToken = sess.validationToken
|
||||
if (sessionToken) {
|
||||
const clientToken = computeValidationToken(sid)
|
||||
// Reject sessions where the validation token is out of sync with the sessionID.
|
||||
// If you change the method for computing the token (above) then you need to either check or ignore previous versions of the token.
|
||||
if (sessionToken === clientToken) {
|
||||
Metrics.inc('security.session', 1, { status: 'ok' })
|
||||
return true
|
||||
} else {
|
||||
logger.warn(
|
||||
{ sid, sessionToken, clientToken },
|
||||
'session token validation failed'
|
||||
)
|
||||
Metrics.inc('security.session', 1, { status: 'error' })
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Metrics.inc('security.session', 1, { status: 'missing' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to return a redacted version of session object
|
||||
// so we can identify the largest keys without exposing sensitive
|
||||
// data
|
||||
|
|
|
@ -14,7 +14,6 @@ const HttpPermissionsPolicyMiddleware = require('./HttpPermissionsPolicy')
|
|||
const sessionsRedisClient = UserSessionsRedis.client()
|
||||
|
||||
const SessionAutostartMiddleware = require('./SessionAutostartMiddleware')
|
||||
const SessionStoreManager = require('./SessionStoreManager')
|
||||
const AnalyticsManager = require('../Features/Analytics/AnalyticsManager')
|
||||
const session = require('express-session')
|
||||
const CustomSessionStore = require('./CustomSessionStore')
|
||||
|
@ -181,11 +180,6 @@ if (Features.hasFeature('saas')) {
|
|||
webRouter.use(AnalyticsManager.analyticsIdMiddleware)
|
||||
}
|
||||
|
||||
// patch the session store to generate a validation token for every new session
|
||||
SessionStoreManager.enableValidationToken(sessionStore)
|
||||
// use middleware to reject all requests with invalid tokens
|
||||
webRouter.use(SessionStoreManager.validationMiddleware)
|
||||
|
||||
// passport
|
||||
webRouter.use(passport.initialize())
|
||||
webRouter.use(passport.session())
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
const Metrics = require('@overleaf/metrics')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
function computeValidationToken(req) {
|
||||
// this should be a deterministic function of the client-side sessionID,
|
||||
// prepended with a version number in case we want to change it later
|
||||
return 'v1:' + req.sessionID.slice(-4)
|
||||
}
|
||||
|
||||
function checkValidationToken(req) {
|
||||
if (req.session) {
|
||||
const sessionToken = req.session.validationToken
|
||||
if (sessionToken) {
|
||||
const clientToken = computeValidationToken(req)
|
||||
// Reject invalid sessions. If you change the method for computing the
|
||||
// token (above) then you need to either check or ignore previous
|
||||
// versions of the token.
|
||||
if (sessionToken === clientToken) {
|
||||
Metrics.inc('security.session', 1, { status: 'ok' })
|
||||
return true
|
||||
} else {
|
||||
logger.error(
|
||||
{
|
||||
sessionToken,
|
||||
clientToken,
|
||||
},
|
||||
'session token validation failed'
|
||||
)
|
||||
Metrics.inc('security.session', 1, { status: 'error' })
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Metrics.inc('security.session', 1, { status: 'missing' })
|
||||
}
|
||||
}
|
||||
return true // fallback to allowing session
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enableValidationToken(sessionStore) {
|
||||
// generate an identifier from the sessionID for every new session
|
||||
const originalGenerate = sessionStore.generate
|
||||
sessionStore.generate = function (req) {
|
||||
originalGenerate(req)
|
||||
// add the validation token as a property that cannot be overwritten
|
||||
Object.defineProperty(req.session, 'validationToken', {
|
||||
value: computeValidationToken(req),
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
})
|
||||
Metrics.inc('security.session', 1, { status: 'new' })
|
||||
}
|
||||
},
|
||||
|
||||
validationMiddleware(req, res, next) {
|
||||
if (!req.session.noSessionCallback) {
|
||||
if (!checkValidationToken(req)) {
|
||||
// the session must exist for it to fail validation
|
||||
return req.session.destroy(() => {
|
||||
return next(new Error('invalid session'))
|
||||
})
|
||||
}
|
||||
}
|
||||
next()
|
||||
},
|
||||
|
||||
hasValidationToken(req) {
|
||||
if (req && req.session && req.session.validationToken) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
computeValidationToken,
|
||||
checkValidationToken,
|
||||
}
|
|
@ -513,7 +513,6 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
RateLimiterMiddleware.rateLimit(openProjectRateLimiter, {
|
||||
params: ['Project_id'],
|
||||
}),
|
||||
AuthenticationController.validateUserSession(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
ProjectController.loadEditor
|
||||
)
|
||||
|
@ -1324,28 +1323,6 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
}
|
||||
)
|
||||
|
||||
webRouter.get('/no-cache', function (req, res, next) {
|
||||
res.header('Cache-Control', 'max-age=0')
|
||||
res.sendStatus(404)
|
||||
})
|
||||
|
||||
webRouter.get('/oops-express', (req, res, next) =>
|
||||
next(new Error('Test error'))
|
||||
)
|
||||
webRouter.get('/oops-internal', function (req, res, next) {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
webRouter.get('/oops-mongo', (req, res, next) =>
|
||||
require('./models/Project').Project.findOne({}, function () {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
)
|
||||
|
||||
privateApiRouter.get('/opps-small', function (req, res, next) {
|
||||
logger.err('test error occured')
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
webRouter.post('/error/client', function (req, res, next) {
|
||||
logger.warn(
|
||||
{ err: req.body.error, meta: req.body.meta },
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
extends ../../layout-marketing
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'pages/token-access'
|
||||
|
||||
block vars
|
||||
- var suppressFooter = true
|
||||
- var suppressCookieBanner = true
|
||||
- var suppressSkipToContent = true
|
||||
|
||||
block append meta
|
||||
meta(name="ol-postUrl" data-type="string" content=postUrl)
|
||||
meta(name="ol-user" data-type="json" content=user)
|
||||
|
||||
block content
|
||||
div#token-access-page
|
|
@ -18,7 +18,7 @@ block content
|
|||
.col-md-8.col-md-offset-2.text-center(ng-cloak)
|
||||
.card(ng-controller="TeamInviteController")
|
||||
.page-header
|
||||
h1.text-centered(ng-non-bindable) #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
|
||||
h1.text-centered(ng-non-bindable) !{translate("invited_to_group", {inviterName: inviterName, appName: appName}, [{name: 'span', attrs: {class: 'team-invite-name'}}])}
|
||||
|
||||
div(ng-show="view =='restrictedByManagedGroup'")
|
||||
.alert.alert-info
|
||||
|
|
|
@ -7,7 +7,7 @@ block content
|
|||
.col-md-8.col-md-offset-2.text-center
|
||||
.card
|
||||
.page-header
|
||||
h1.text-centered #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
|
||||
h1.text-centered !{translate("invited_to_group", {inviterName: inviterName, appName: appName }, [{name: 'span', attrs: {class: 'team-invite-name'}}])}
|
||||
|
||||
if (accountExists)
|
||||
div
|
||||
|
|
|
@ -364,7 +364,10 @@ module.exports = {
|
|||
|
||||
robotsNoindex: process.env.ROBOTS_NOINDEX === 'true' || false,
|
||||
|
||||
maxEntitiesPerProject: 2000,
|
||||
maxEntitiesPerProject: parseInt(
|
||||
process.env.MAX_ENTITIES_PER_PROJECT || '2000',
|
||||
10
|
||||
),
|
||||
|
||||
projectUploadTimeout: parseInt(
|
||||
process.env.PROJECT_UPLOAD_TIMEOUT || '120000',
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"accept_or_reject_each_changes_individually": "",
|
||||
"accept_terms_and_conditions": "",
|
||||
"accepted_invite": "",
|
||||
"accepting_invite_as": "",
|
||||
"access_denied": "",
|
||||
"account_has_been_link_to_institution_account": "",
|
||||
"account_has_past_due_invoice_change_plan_warning": "",
|
||||
|
@ -97,6 +98,7 @@
|
|||
"autocomplete_references": "",
|
||||
"back": "",
|
||||
"back_to_configuration": "",
|
||||
"back_to_editor": "",
|
||||
"back_to_subscription": "",
|
||||
"back_to_your_projects": "",
|
||||
"beta_program_already_participating": "",
|
||||
|
@ -531,6 +533,7 @@
|
|||
"history_view_all": "",
|
||||
"history_view_labels": "",
|
||||
"hit_enter_to_reply": "",
|
||||
"home": "",
|
||||
"hotkey_add_a_comment": "",
|
||||
"hotkey_autocomplete_menu": "",
|
||||
"hotkey_beginning_of_document": "",
|
||||
|
@ -618,6 +621,7 @@
|
|||
"invite_not_accepted": "",
|
||||
"invited_to_group": "",
|
||||
"invited_to_group_have_individual_subcription": "",
|
||||
"invited_to_join": "",
|
||||
"ip_address": "",
|
||||
"is_email_affiliated": "",
|
||||
"issued_on": "",
|
||||
|
@ -1334,6 +1338,7 @@
|
|||
"to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "",
|
||||
"toggle_compile_options_menu": "",
|
||||
"token": "",
|
||||
"token_access_failure": "",
|
||||
"token_limit_reached": "",
|
||||
"token_read_only": "",
|
||||
"token_read_write": "",
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function BackToEditorButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
bsSize="sm"
|
||||
bsStyle={null}
|
||||
onClick={onClick}
|
||||
className="back-to-editor-btn btn-secondary"
|
||||
>
|
||||
<MaterialIcon type="arrow_back" className="toolbar-btn-secondary-icon" />
|
||||
<p className="toolbar-label">{t('back_to_editor')}</p>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackToEditorButton
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ToolbarHeader from './toolbar-header'
|
||||
import { useEditorContext } from '../../../shared/context/editor-context'
|
||||
import { useChatContext } from '../../chat/context/chat-context'
|
||||
|
@ -66,11 +66,21 @@ const EditorNavigationToolbarRoot = React.memo(
|
|||
[reviewPanelOpen, setReviewPanelOpen]
|
||||
)
|
||||
|
||||
const [shouldReopenChat, setShouldReopenChat] = useState(chatIsOpen)
|
||||
const toggleHistoryOpen = useCallback(() => {
|
||||
const action = view === 'history' ? 'close' : 'open'
|
||||
eventTracking.sendMB('navigation-clicked-history', { action })
|
||||
|
||||
if (chatIsOpen && action === 'open') {
|
||||
setShouldReopenChat(true)
|
||||
toggleChatOpen()
|
||||
}
|
||||
if (shouldReopenChat && action === 'close') {
|
||||
setShouldReopenChat(false)
|
||||
toggleChatOpen()
|
||||
}
|
||||
setView(view === 'history' ? 'editor' : 'history')
|
||||
}, [view, setView])
|
||||
}, [view, chatIsOpen, shouldReopenChat, setView, toggleChatOpen])
|
||||
|
||||
const openShareModal = useCallback(() => {
|
||||
eventTracking.sendMB('navigation-clicked-share')
|
||||
|
|
|
@ -1,18 +1,13 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../shared/components/icon'
|
||||
|
||||
function HistoryToggleButton({ historyIsOpen, onClick }) {
|
||||
function HistoryToggleButton({ onClick }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const classes = classNames('btn', 'btn-full-height', {
|
||||
active: historyIsOpen,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button className={classes} onClick={onClick}>
|
||||
<button className="btn btn-full-height" onClick={onClick}>
|
||||
<Icon type="history" fw />
|
||||
<p className="toolbar-label">{t('history')}</p>
|
||||
</button>
|
||||
|
@ -21,7 +16,6 @@ function HistoryToggleButton({ historyIsOpen, onClick }) {
|
|||
}
|
||||
|
||||
HistoryToggleButton.propTypes = {
|
||||
historyIsOpen: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import TrackChangesToggleButton from './track-changes-toggle-button'
|
|||
import HistoryToggleButton from './history-toggle-button'
|
||||
import ShareProjectButton from './share-project-button'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import BackToEditorButton from './back-to-editor-button'
|
||||
|
||||
const [publishModalModules] = importOverleafModules('publishModal')
|
||||
const PublishButton = publishModalModules?.import.default
|
||||
|
@ -64,34 +65,37 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
|
|||
<div className="toolbar-right">
|
||||
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
|
||||
|
||||
{trackChangesVisible && (
|
||||
<TrackChangesToggleButton
|
||||
onMouseDown={toggleReviewPanelOpen}
|
||||
disabled={historyIsOpen}
|
||||
trackChangesIsOpen={reviewPanelOpen}
|
||||
/>
|
||||
)}
|
||||
{historyIsOpen ? (
|
||||
<BackToEditorButton onClick={toggleHistoryOpen} />
|
||||
) : (
|
||||
<>
|
||||
{trackChangesVisible && (
|
||||
<TrackChangesToggleButton
|
||||
onMouseDown={toggleReviewPanelOpen}
|
||||
disabled={historyIsOpen}
|
||||
trackChangesIsOpen={reviewPanelOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShareProjectButton onClick={openShareModal} />
|
||||
{shouldDisplayPublishButton && (
|
||||
<PublishButton cobranding={cobranding} />
|
||||
)}
|
||||
<ShareProjectButton onClick={openShareModal} />
|
||||
{shouldDisplayPublishButton && (
|
||||
<PublishButton cobranding={cobranding} />
|
||||
)}
|
||||
|
||||
{!isRestrictedTokenMember && (
|
||||
<HistoryToggleButton
|
||||
historyIsOpen={historyIsOpen}
|
||||
onClick={toggleHistoryOpen}
|
||||
/>
|
||||
)}
|
||||
{!isRestrictedTokenMember && (
|
||||
<HistoryToggleButton onClick={toggleHistoryOpen} />
|
||||
)}
|
||||
|
||||
<LayoutDropdownButton />
|
||||
<LayoutDropdownButton />
|
||||
|
||||
{!isRestrictedTokenMember && (
|
||||
<ChatToggleButton
|
||||
chatIsOpen={chatIsOpen}
|
||||
onClick={toggleChatOpen}
|
||||
unreadMessageCount={unreadMessageCount}
|
||||
/>
|
||||
{!isRestrictedTokenMember && (
|
||||
<ChatToggleButton
|
||||
chatIsOpen={chatIsOpen}
|
||||
onClick={toggleChatOpen}
|
||||
unreadMessageCount={unreadMessageCount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
|
||||
import { paywallPrompt } from '../../../../main/account-upgrade'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
|
@ -18,8 +18,7 @@ export function OwnerPaywallPrompt() {
|
|||
const { t } = useTranslation()
|
||||
const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
|
||||
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
|
||||
|
||||
useEffect(() => {
|
||||
eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')
|
||||
|
|
|
@ -6,7 +6,7 @@ import ToolbarFileInfo from './toolbar-file-info'
|
|||
import ToolbarRestoreFileButton from './toolbar-restore-file-button'
|
||||
import { isFileRemoved } from '../../../utils/file-diff'
|
||||
import ToolbarRevertFileButton from './toolbar-revert-file-button'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
type ToolbarProps = {
|
||||
diff: Nullable<Diff>
|
||||
|
@ -14,13 +14,13 @@ type ToolbarProps = {
|
|||
}
|
||||
|
||||
export default function Toolbar({ diff, selection }: ToolbarProps) {
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasRevertFiles = useFeatureFlag('revert-files')
|
||||
|
||||
const showRestoreFileButton =
|
||||
selection.selectedFile && isFileRemoved(selection.selectedFile)
|
||||
|
||||
const showRevertFileButton =
|
||||
splitTestVariants['revert-file'] === 'enabled' &&
|
||||
hasRevertFiles &&
|
||||
selection.selectedFile &&
|
||||
!isFileRemoved(selection.selectedFile)
|
||||
|
||||
|
|
|
@ -2,15 +2,14 @@ import Notification from '@/shared/components/notification'
|
|||
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FC } from 'react'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
export const CompileTimeWarningUpgradePromptInner: FC<{
|
||||
handleDismissWarning: () => void
|
||||
}> = ({ handleDismissWarning }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
|
||||
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
|
||||
|
||||
return (
|
||||
<Notification
|
||||
|
|
|
@ -46,6 +46,7 @@ function PdfLogEntry({
|
|||
<div
|
||||
className={classNames('log-entry', customClass)}
|
||||
aria-label={entryAriaLabel}
|
||||
data-ruleid={ruleId}
|
||||
>
|
||||
<PreviewLogEntryHeader
|
||||
level={level}
|
||||
|
|
|
@ -6,7 +6,7 @@ import PdfLogEntry from './pdf-log-entry'
|
|||
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function TimeoutUpgradePromptNew() {
|
||||
const {
|
||||
|
@ -49,8 +49,7 @@ const CompileTimeout = memo(function CompileTimeout({
|
|||
}: CompileTimeoutProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
|
||||
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
|
||||
|
||||
return (
|
||||
<PdfLogEntry
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import Notification from '../../notification'
|
||||
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
@ -30,7 +30,18 @@ export default function GroupInvitationNotificationJoin({
|
|||
<Notification
|
||||
bsStyle="info"
|
||||
onDismiss={dismissGroupInviteNotification}
|
||||
body={t('invited_to_group', { inviterName })}
|
||||
body={
|
||||
<Trans
|
||||
i18nKey="invited_to_group"
|
||||
values={{ inviterName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={
|
||||
/* eslint-disable-next-line react/jsx-key */
|
||||
[<span className="team-invite-name" />]
|
||||
}
|
||||
/>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
bsStyle={newNotificationStyle ? null : 'info'}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { FC } from 'react'
|
||||
import { UserRef } from '../../../../../../../types/project/dashboard/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUserName } from '@/features/project-list/util/user'
|
||||
|
||||
export const LastUpdatedBy: FC<{
|
||||
lastUpdatedBy: UserRef
|
||||
lastUpdatedDate: string
|
||||
}> = ({ lastUpdatedBy, lastUpdatedDate }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const userName = getUserName(lastUpdatedBy)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t('last_updated_date_by_x', {
|
||||
lastUpdatedDate,
|
||||
person: userName === 'You' ? t('you') : userName,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -1,22 +1,14 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { formatDate, fromNowDate } from '../../../../../utils/dates'
|
||||
import { Project } from '../../../../../../../types/project/dashboard/api'
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import { getUserName } from '../../../util/user'
|
||||
import { LastUpdatedBy } from '@/features/project-list/components/table/cells/last-updated-by'
|
||||
|
||||
type LastUpdatedCellProps = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const displayText = project.lastUpdatedBy
|
||||
? t('last_updated_date_by_x', {
|
||||
lastUpdatedDate: fromNowDate(project.lastUpdated),
|
||||
person: getUserName(project.lastUpdatedBy),
|
||||
})
|
||||
: fromNowDate(project.lastUpdated)
|
||||
const lastUpdatedDate = fromNowDate(project.lastUpdated)
|
||||
|
||||
const tooltipText = formatDate(project.lastUpdated)
|
||||
return (
|
||||
|
@ -26,8 +18,14 @@ export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
|
|||
description={tooltipText}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
|
||||
<span>{displayText}</span>
|
||||
{project.lastUpdatedBy ? (
|
||||
<LastUpdatedBy
|
||||
lastUpdatedBy={project.lastUpdatedBy}
|
||||
lastUpdatedDate={lastUpdatedDate}
|
||||
/>
|
||||
) : (
|
||||
<span>{lastUpdatedDate}</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -41,11 +41,13 @@ type OwnerCellProps = {
|
|||
}
|
||||
|
||||
export default function OwnerCell({ project }: OwnerCellProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const ownerName = getOwnerName(project)
|
||||
|
||||
return (
|
||||
<>
|
||||
{ownerName}
|
||||
{ownerName === 'You' ? t('you') : ownerName}
|
||||
{project.source === 'token' ? (
|
||||
<LinkSharingIcon
|
||||
className="hidden-xs"
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const ProjectListOwnerName = memo<{ ownerName: string }>(
|
||||
({ ownerName }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const x = ownerName === 'You' ? t('you') : ownerName
|
||||
|
||||
return <> — {t('owned_by_x', { x })}</>
|
||||
}
|
||||
)
|
||||
ProjectListOwnerName.displayName = 'ProjectListOwnerName'
|
|
@ -8,12 +8,14 @@ import ActionsDropdown from '../dropdown/actions-dropdown'
|
|||
import { getOwnerName } from '../../util/project'
|
||||
import { Project } from '../../../../../../types/project/dashboard/api'
|
||||
import { ProjectCheckbox } from './project-checkbox'
|
||||
import { ProjectListOwnerName } from '@/features/project-list/components/table/project-list-owner-name'
|
||||
|
||||
type ProjectListTableRowProps = {
|
||||
project: Project
|
||||
}
|
||||
function ProjectListTableRow({ project }: ProjectListTableRowProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const ownerName = getOwnerName(project)
|
||||
|
||||
return (
|
||||
|
@ -30,7 +32,7 @@ function ProjectListTableRow({ project }: ProjectListTableRowProps) {
|
|||
</td>
|
||||
<td className="dash-cell-date-owner visible-xs pb-0">
|
||||
<LastUpdatedCell project={project} />
|
||||
{ownerName ? <> — {t('owned_by_x', { x: ownerName })}</> : null}
|
||||
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
||||
</td>
|
||||
<td className="dash-cell-owner hidden-xs">
|
||||
<OwnerCell project={project} />
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useSSOContext, SSOSubscription } from '../context/sso-context'
|
|||
import { SSOLinkingWidget } from './linking/sso-widget'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
|
||||
|
||||
function LinkingSection() {
|
||||
|
@ -51,16 +51,12 @@ function LinkingSection() {
|
|||
// currently the only thing that is in the langFeedback section is writefull,
|
||||
// which is behind a split test. we should hide this section if the user is not in the split test
|
||||
// todo: remove split test check, and split test context after gradual rollout is complete
|
||||
const {
|
||||
splitTestVariants,
|
||||
}: { splitTestVariants: Record<string, string | undefined> } =
|
||||
useSplitTestContext()
|
||||
const hasWritefullOauthPromotion = useFeatureFlag('writefull-oauth-promotion')
|
||||
|
||||
// even if they arent in the split test, if they have it enabled let them toggle it off
|
||||
const user = getMeta('ol-user')
|
||||
const shouldLoadWritefull =
|
||||
(splitTestVariants['writefull-oauth-promotion'] === 'enabled' ||
|
||||
user.writefull?.enabled === true) &&
|
||||
(hasWritefullOauthPromotion || user.writefull?.enabled === true) &&
|
||||
!window.writefull // check if the writefull extension is installed, in which case we dont handle the integration
|
||||
|
||||
const haslangFeedbackLinkingWidgets =
|
||||
|
|
|
@ -5,16 +5,14 @@ import { useUserContext } from '../../../shared/context/user-context'
|
|||
import { upgradePlan } from '../../../main/account-upgrade'
|
||||
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import { useSplitTestContext } from '../../../shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '../../../shared/context/split-test-context'
|
||||
|
||||
export default function AddCollaboratorsUpgrade() {
|
||||
const { t } = useTranslation()
|
||||
const user = useUserContext()
|
||||
|
||||
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
|
||||
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
|
||||
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
|
||||
|
||||
return (
|
||||
<div className="add-collaborators-upgrade">
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useProjectContext } from '../../../../shared/context/project-context'
|
|||
import { useUserContext } from '../../../../shared/context/user-context'
|
||||
import { startFreeTrial, upgradePlan } from '../../../../main/account-upgrade'
|
||||
import { memo } from 'react'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
type UpgradeTrackChangesModalProps = {
|
||||
show: boolean
|
||||
|
@ -21,8 +21,7 @@ function UpgradeTrackChangesModal({
|
|||
const project = useProjectContext()
|
||||
const user = useUserContext()
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasNewPaywallCta = splitTestVariants['paywall-cta'] === 'enabled'
|
||||
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
|
||||
|
||||
return (
|
||||
<AccessibleModal show={show} onHide={() => setShow(false)}>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Button, Overlay, Popover } from 'react-bootstrap'
|
|||
import Close from '../../../../../shared/components/close'
|
||||
import { postJSON } from '../../../../../infrastructure/fetch-json'
|
||||
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
||||
import { useSplitTestContext } from '../../../../../shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '../../../../../shared/context/split-test-context'
|
||||
import { User } from '../../../../../../../types/user'
|
||||
import { useUserContext } from '../../../../../shared/context/user-context'
|
||||
import grammarlyExtensionPresent from '../../../../../shared/utils/grammarly'
|
||||
|
@ -30,10 +30,8 @@ export const PromotionOverlay: FC = ({ children }) => {
|
|||
|
||||
const { inactiveTutorials, currentPopup, setCurrentPopup } =
|
||||
useEditorContext()
|
||||
const {
|
||||
splitTestVariants,
|
||||
}: { splitTestVariants: Record<string, string | undefined> } =
|
||||
useSplitTestContext()
|
||||
|
||||
const hasTableGeneratorPromotion = useFeatureFlag('table-generator-promotion')
|
||||
|
||||
const user = useUserContext() as User | undefined
|
||||
|
||||
|
@ -50,7 +48,7 @@ export const PromotionOverlay: FC = ({ children }) => {
|
|||
currentPopup && currentPopup !== 'table-generator-promotion'
|
||||
|
||||
const showPromotion =
|
||||
splitTestVariants['table-generator-promotion'] === 'enabled' &&
|
||||
hasTableGeneratorPromotion &&
|
||||
!popupPresent &&
|
||||
!inactiveTutorials.includes('table-generator-promotion') &&
|
||||
!hideBecauseNewUser
|
||||
|
|
|
@ -10,8 +10,10 @@ import {
|
|||
ancestorListType,
|
||||
toggleListForRanges,
|
||||
unwrapBulletList,
|
||||
unwrapDescriptionList,
|
||||
unwrapNumberedList,
|
||||
wrapInBulletList,
|
||||
wrapInDescriptionList,
|
||||
wrapInNumberedList,
|
||||
} from './lists'
|
||||
import { snippet } from '@codemirror/autocomplete'
|
||||
|
@ -114,6 +116,8 @@ export const indentDecrease: Command = view => {
|
|||
return unwrapBulletList(view)
|
||||
case 'enumerate':
|
||||
return unwrapNumberedList(view)
|
||||
case 'description':
|
||||
return unwrapDescriptionList(view)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -136,6 +140,8 @@ export const indentIncrease: Command = view => {
|
|||
return wrapInBulletList(view)
|
||||
case 'enumerate':
|
||||
return wrapInNumberedList(view)
|
||||
case 'description':
|
||||
return wrapInDescriptionList(view)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -343,5 +343,7 @@ export const toggleListForRanges =
|
|||
|
||||
export const wrapInBulletList = wrapRangesInList('itemize')
|
||||
export const wrapInNumberedList = wrapRangesInList('enumerate')
|
||||
export const wrapInDescriptionList = wrapRangesInList('description')
|
||||
export const unwrapBulletList = unwrapRangesFromList('itemize')
|
||||
export const unwrapNumberedList = unwrapRangesFromList('enumerate')
|
||||
export const unwrapDescriptionList = unwrapRangesFromList('description')
|
||||
|
|
|
@ -51,6 +51,9 @@ import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-g
|
|||
import {
|
||||
CloseBrace,
|
||||
OpenBrace,
|
||||
CloseBracket,
|
||||
OpenBracket,
|
||||
OptionalArgument,
|
||||
ShortTextArgument,
|
||||
TextArgument,
|
||||
} from '../../lezer-latex/latex.terms.mjs'
|
||||
|
@ -74,6 +77,7 @@ import {
|
|||
validateParsedTable,
|
||||
} from '../../components/table-generator/utils'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { DescriptionItemWidget } from './visual-widgets/description-item'
|
||||
|
||||
type Options = {
|
||||
previewByPath: (path: string) => PreviewPath | null
|
||||
|
@ -101,13 +105,17 @@ function decorateArgumentBraces(
|
|||
argumentNode: SyntaxNode | null | undefined,
|
||||
start: number,
|
||||
decorateEmptyArguments = false,
|
||||
endWidget?: WidgetType
|
||||
endWidget?: WidgetType,
|
||||
braceTypes = {
|
||||
open: OpenBrace,
|
||||
close: CloseBrace,
|
||||
}
|
||||
): Range<Decoration>[] {
|
||||
if (!argumentNode) {
|
||||
return []
|
||||
}
|
||||
const openBrace = argumentNode.getChild('OpenBrace')
|
||||
const closeBrace = argumentNode.getChild('CloseBrace')
|
||||
const openBrace = argumentNode.getChild(braceTypes.open)
|
||||
const closeBrace = argumentNode.getChild(braceTypes.close)
|
||||
|
||||
if (openBrace && closeBrace) {
|
||||
if (
|
||||
|
@ -120,10 +128,9 @@ function decorateArgumentBraces(
|
|||
widget: startWidget,
|
||||
}).range(start, openBrace.to),
|
||||
|
||||
Decoration.replace({ widget: endWidget }).range(
|
||||
closeBrace.from,
|
||||
closeBrace.to
|
||||
),
|
||||
Decoration.replace({
|
||||
widget: endWidget,
|
||||
}).range(closeBrace.from, closeBrace.to),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -377,6 +384,7 @@ export const atomicDecorations = (options: Options) => {
|
|||
switch (envName) {
|
||||
case 'itemize':
|
||||
case 'enumerate':
|
||||
case 'description':
|
||||
startListEnvironment(envName)
|
||||
listDepth++
|
||||
break
|
||||
|
@ -480,6 +488,7 @@ export const atomicDecorations = (options: Options) => {
|
|||
switch (envName) {
|
||||
case 'itemize':
|
||||
case 'enumerate':
|
||||
case 'description':
|
||||
if (currentListEnvironment === envName) {
|
||||
endListEnvironment()
|
||||
}
|
||||
|
@ -963,16 +972,51 @@ export const atomicDecorations = (options: Options) => {
|
|||
state.sliceDoc(line.from, nodeRef.from)
|
||||
)
|
||||
const from = onlySpaceBeforeNode ? line.from : nodeRef.from
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new ItemWidget(
|
||||
currentListEnvironment || 'document',
|
||||
currentOrdinal,
|
||||
listDepth
|
||||
),
|
||||
}).range(from, nodeRef.to)
|
||||
)
|
||||
return false
|
||||
|
||||
if (currentListEnvironment === 'description') {
|
||||
const argumentNode = nodeRef.node.getChild(OptionalArgument)
|
||||
const to = argumentNode ? argumentNode.from : nodeRef.to
|
||||
|
||||
const onlySpaceAfterNode =
|
||||
!argumentNode &&
|
||||
/^\s*$/.test(state.sliceDoc(nodeRef.to, line.to))
|
||||
|
||||
if (!onlySpaceAfterNode) {
|
||||
// decorate the \item command and subsequent whitespace, if there is other content on the line
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new DescriptionItemWidget(listDepth),
|
||||
}).range(from, to)
|
||||
)
|
||||
}
|
||||
|
||||
if (argumentNode) {
|
||||
// decorate the optional argument
|
||||
const decorateBrackets = shouldDecorate(state, argumentNode)
|
||||
|
||||
decorations.push(
|
||||
...decorateArgumentBraces(
|
||||
new BraceWidget(decorateBrackets ? '' : '['),
|
||||
argumentNode,
|
||||
from,
|
||||
false,
|
||||
new BraceWidget(decorateBrackets ? '' : ']'),
|
||||
{ open: OpenBracket, close: CloseBracket }
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new ItemWidget(
|
||||
currentListEnvironment || 'document',
|
||||
currentOrdinal,
|
||||
listDepth
|
||||
),
|
||||
}).range(from, nodeRef.to)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else if (nodeRef.type.is('NewTheoremCommand')) {
|
||||
const result = parseTheoremArguments(state, nodeRef.node)
|
||||
|
|
|
@ -49,7 +49,10 @@ const chooseTargetPosition = (
|
|||
targetNode = node
|
||||
} else if (node.type.is('ItemCtrlSeq')) {
|
||||
targetNode = node.parent
|
||||
} else if (node.type.is('Whitespace')) {
|
||||
} else if (
|
||||
node.type.is('Whitespace') &&
|
||||
node.nextSibling?.type.is('Command')
|
||||
) {
|
||||
targetNode = node.nextSibling?.firstChild?.firstChild
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
indentIncrease,
|
||||
} from '../toolbar/commands'
|
||||
import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item'
|
||||
import { getListType } from '../../utils/tree-operations/lists'
|
||||
|
||||
/**
|
||||
* A keymap which provides behaviours for the visual editor,
|
||||
|
@ -37,7 +38,7 @@ export const visualKeymap = Prec.highest(
|
|||
|
||||
if (line.number === endLine.number - 1) {
|
||||
// last item line
|
||||
if (line.text.trim() === '\\item') {
|
||||
if (/^\\item(\[])?$/.test(line.text.trim())) {
|
||||
// no content on this line
|
||||
|
||||
// outside the end of the current list
|
||||
|
@ -85,8 +86,7 @@ export const visualKeymap = Prec.highest(
|
|||
}
|
||||
|
||||
// handle a list item that isn't at the end of a list
|
||||
|
||||
const insert = '\n' + createListItem(state, from)
|
||||
let insert = '\n' + createListItem(state, from)
|
||||
|
||||
const countWhitespaceAfterPosition = (pos: number) => {
|
||||
const line = state.doc.lineAt(pos)
|
||||
|
@ -95,9 +95,16 @@ export const visualKeymap = Prec.highest(
|
|||
return matches ? matches[1].length : 0
|
||||
}
|
||||
|
||||
// move the cursor past any whitespace on the new line
|
||||
const pos =
|
||||
from + insert.length + countWhitespaceAfterPosition(from)
|
||||
let pos: number
|
||||
|
||||
if (getListType(state, listNode) === 'description') {
|
||||
insert = insert.replace(/\\item $/, '\\item[] ')
|
||||
// position the cursor inside the square brackets
|
||||
pos = from + insert.length - 2
|
||||
} else {
|
||||
// move the cursor past any whitespace on the new line
|
||||
pos = from + insert.length + countWhitespaceAfterPosition(from)
|
||||
}
|
||||
|
||||
handled = true
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ export const visualHighlightStyle = syntaxHighlighting(
|
|||
{ tag: tags.string, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.punctuation, class: 'ol-cm-punctuation' },
|
||||
{ tag: tags.literal, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.strong, class: 'ol-cm-strong' },
|
||||
{
|
||||
tag: tags.monospace,
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
|
@ -70,6 +71,9 @@ const mainVisualTheme = EditorView.theme({
|
|||
fontVariant: 'normal',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'.ol-cm-strong': {
|
||||
fontWeight: 700,
|
||||
},
|
||||
'.ol-cm-punctuation': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
lineHeight: 1,
|
||||
|
@ -184,7 +188,7 @@ const mainVisualTheme = EditorView.theme({
|
|||
'.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': {
|
||||
flex: 0,
|
||||
},
|
||||
'.ol-cm-item': {
|
||||
'.ol-cm-item, .ol-cm-description-item': {
|
||||
paddingInlineStart: 'calc(var(--list-depth) * 2ch)',
|
||||
},
|
||||
'.ol-cm-item::before': {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class DescriptionItemWidget extends WidgetType {
|
||||
constructor(public listDepth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-description-item')
|
||||
this.setProperties(element)
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: DescriptionItemWidget) {
|
||||
return widget.listDepth === this.listDepth
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement) {
|
||||
this.setProperties(element)
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
setProperties(element: HTMLElement) {
|
||||
element.style.setProperty('--list-depth', String(this.listDepth))
|
||||
}
|
||||
}
|
|
@ -18,6 +18,12 @@ export const environments = new Map([
|
|||
\\end{array}`,
|
||||
],
|
||||
['center', snippet('center')],
|
||||
[
|
||||
'description',
|
||||
`\\begin{description}
|
||||
\t\\item[$1] $2
|
||||
\\end{description}`,
|
||||
],
|
||||
['document', snippetNoIndent('document')],
|
||||
['equation', snippet('equation')],
|
||||
['equation*', snippet('equation*')],
|
||||
|
|
|
@ -155,6 +155,7 @@ export const LaTeXLanguage = LRLanguage.define({
|
|||
'DocumentClass/OptionalArgument/ShortOptionalArg/Normal':
|
||||
t.attributeValue,
|
||||
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
|
||||
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
|
||||
Number: t.number,
|
||||
OpenBrace: t.brace,
|
||||
CloseBrace: t.brace,
|
||||
|
@ -193,6 +194,7 @@ export const LaTeXLanguage = LRLanguage.define({
|
|||
'BareFilePathArgument/SpaceDelimitedLiteralArgContent':
|
||||
t.attributeValue,
|
||||
TrailingContent: t.comment,
|
||||
'Item/OptionalArgument/ShortOptionalArg/...': t.strong,
|
||||
// TODO: t.strong, t.emphasis
|
||||
}),
|
||||
],
|
||||
|
|
|
@ -354,7 +354,7 @@ KnownCommand {
|
|||
CenteringCtrlSeq
|
||||
} |
|
||||
Item {
|
||||
ItemCtrlSeq optionalWhitespace?
|
||||
ItemCtrlSeq OptionalArgument? optionalWhitespace?
|
||||
} |
|
||||
Maketitle {
|
||||
MaketitleCtrlSeq optionalWhitespace?
|
||||
|
|
|
@ -744,6 +744,7 @@ const otherKnownEnvNames = {
|
|||
enumerate: ListEnvName,
|
||||
itemize: ListEnvName,
|
||||
table: TableEnvName,
|
||||
description: ListEnvName,
|
||||
}
|
||||
|
||||
export const specializeEnvName = (name, terms) => {
|
||||
|
|
|
@ -257,7 +257,7 @@ export const withinFormattingCommand = (state: EditorState) => {
|
|||
}
|
||||
}
|
||||
|
||||
export type ListEnvironmentName = 'itemize' | 'enumerate'
|
||||
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
|
||||
|
||||
export const listDepthForNode = (node: SyntaxNode) => {
|
||||
let depth = 0
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export const getListType = (
|
||||
state: EditorState,
|
||||
listEnvironmentNode: SyntaxNode
|
||||
) => {
|
||||
const beginEnvNameNode = listEnvironmentNode
|
||||
.getChild('BeginEnv')
|
||||
?.getChild('EnvNameGroup')
|
||||
?.getChild('ListEnvName')
|
||||
|
||||
const endEnvNameNode = listEnvironmentNode
|
||||
.getChild('EndEnv')
|
||||
?.getChild('EnvNameGroup')
|
||||
?.getChild('ListEnvName')
|
||||
|
||||
if (beginEnvNameNode && endEnvNameNode) {
|
||||
return state.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to).trim()
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
|
||||
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
|
||||
import SubscriptionDashboard from './subscription-dashboard'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
function Root() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
@ -10,9 +11,11 @@ function Root() {
|
|||
}
|
||||
|
||||
return (
|
||||
<SubscriptionDashboardProvider>
|
||||
<SubscriptionDashboard />
|
||||
</SubscriptionDashboardProvider>
|
||||
<SplitTestProvider>
|
||||
<SubscriptionDashboardProvider>
|
||||
<SubscriptionDashboard />
|
||||
</SubscriptionDashboardProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Col, Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trans } from 'react-i18next'
|
||||
import GroupInvitesItemFooter from './group-invites-item-footer'
|
||||
import type { TeamInvite } from '../../../../../../types/team-invite'
|
||||
|
||||
|
@ -10,17 +10,22 @@ type GroupInvitesItemProps = {
|
|||
export default function GroupInvitesItem({
|
||||
teamInvite,
|
||||
}: GroupInvitesItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Row className="row-spaced">
|
||||
<Col md={8} mdOffset={2} className="text-center">
|
||||
<div className="card">
|
||||
<div className="page-header">
|
||||
<h2>
|
||||
{t('invited_to_group', {
|
||||
inviterName: teamInvite.inviterName,
|
||||
})}
|
||||
<Trans
|
||||
i18nKey="invited_to_group"
|
||||
values={{ inviterName: teamInvite.inviterName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={
|
||||
/* eslint-disable-next-line react/jsx-key */
|
||||
[<span className="team-invite-name" />]
|
||||
}
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<GroupInvitesItemFooter teamInvite={teamInvite} />
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
|
||||
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
|
||||
import SuccessfulSubscription from './successful-subscription'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
function Root() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
@ -10,9 +11,11 @@ function Root() {
|
|||
}
|
||||
|
||||
return (
|
||||
<SubscriptionDashboardProvider>
|
||||
<SuccessfulSubscription />
|
||||
</SubscriptionDashboardProvider>
|
||||
<SplitTestProvider>
|
||||
<SubscriptionDashboardProvider>
|
||||
<SuccessfulSubscription />
|
||||
</SubscriptionDashboardProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
import { isRecurlyLoaded } from '../util/is-recurly-loaded'
|
||||
import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { getSplitTestVariant } from '@/utils/splitTestUtils'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import { formatCurrencyLocalized } from '@/shared/utils/currency'
|
||||
|
||||
type SubscriptionDashboardContextValue = {
|
||||
|
@ -80,11 +80,6 @@ export const SubscriptionDashboardContext = createContext<
|
|||
SubscriptionDashboardContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
const getFormatCurrencies = () =>
|
||||
getSplitTestVariant('local-ccy-format-v2') === 'enabled'
|
||||
? formatCurrencyLocalized
|
||||
: formatCurrencyDefault
|
||||
|
||||
export function SubscriptionDashboardProvider({
|
||||
children,
|
||||
}: {
|
||||
|
@ -150,6 +145,10 @@ export function SubscriptionDashboardProvider({
|
|||
memberGroupSubscriptions?.length > 0
|
||||
)
|
||||
|
||||
const formatCurrency = useFeatureFlag('local-ccy-format-v2')
|
||||
? formatCurrencyLocalized
|
||||
: formatCurrencyDefault
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecurlyLoaded()) {
|
||||
setRecurlyLoadError(true)
|
||||
|
@ -164,7 +163,6 @@ export function SubscriptionDashboardProvider({
|
|||
plansWithoutDisplayPrice &&
|
||||
personalSubscription?.recurly
|
||||
) {
|
||||
const formatCurrency = getFormatCurrencies()
|
||||
const { currency, taxRate } = personalSubscription.recurly
|
||||
const fetchPlansDisplayPrices = async () => {
|
||||
for (const plan of plansWithoutDisplayPrice) {
|
||||
|
@ -192,7 +190,12 @@ export function SubscriptionDashboardProvider({
|
|||
}
|
||||
fetchPlansDisplayPrices().catch(debugConsole.error)
|
||||
}
|
||||
}, [personalSubscription, plansWithoutDisplayPrice, i18n.language])
|
||||
}, [
|
||||
personalSubscription,
|
||||
plansWithoutDisplayPrice,
|
||||
i18n.language,
|
||||
formatCurrency,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
@ -209,7 +212,6 @@ export function SubscriptionDashboardProvider({
|
|||
setGroupPlanToChangeToPriceError(false)
|
||||
let priceData
|
||||
try {
|
||||
const formatCurrency = getFormatCurrencies()
|
||||
priceData = await loadGroupDisplayPriceWithTaxPromise(
|
||||
groupPlanToChangeToCode,
|
||||
currency,
|
||||
|
@ -233,6 +235,7 @@ export function SubscriptionDashboardProvider({
|
|||
groupPlanToChangeToSize,
|
||||
personalSubscription,
|
||||
groupPlanToChangeToCode,
|
||||
formatCurrency,
|
||||
i18n.language,
|
||||
])
|
||||
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const AccessAttemptScreen: FC<{
|
||||
loadingScreenBrandHeight: string
|
||||
inflight: boolean
|
||||
accessError: string | boolean
|
||||
}> = ({ loadingScreenBrandHeight, inflight, accessError }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-screen-brand-container">
|
||||
<div
|
||||
className="loading-screen-brand"
|
||||
style={{ height: loadingScreenBrandHeight }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="loading-screen-label text-center">
|
||||
{t('join_project')}
|
||||
{inflight && <LoadingScreenEllipses />}
|
||||
</h3>
|
||||
|
||||
{accessError && (
|
||||
<div className="global-alerts text-center">
|
||||
<div>
|
||||
<br />
|
||||
{accessError === 'not_found' ? (
|
||||
<div>
|
||||
<h4 aria-live="assertive">Project not found</h4>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="alert alert-danger" aria-live="assertive">
|
||||
{t('token_access_failure')}
|
||||
</div>
|
||||
<p>
|
||||
<a href="/">{t('home')}</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const LoadingScreenEllipses = () => (
|
||||
<span aria-hidden>
|
||||
<span className="loading-screen-ellip">.</span>
|
||||
<span className="loading-screen-ellip">.</span>
|
||||
<span className="loading-screen-ellip">.</span>
|
||||
</span>
|
||||
)
|
|
@ -0,0 +1,57 @@
|
|||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export type RequireAcceptData = {
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
export const RequireAcceptScreen: FC<{
|
||||
requireAcceptData: RequireAcceptData
|
||||
sendPostRequest: (confirmedByUser: boolean) => void
|
||||
}> = ({ requireAcceptData, sendPostRequest }) => {
|
||||
const { t } = useTranslation()
|
||||
const user = getMeta('ol-user')
|
||||
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-8 col-md-offset-2">
|
||||
<div className="card">
|
||||
<div className="page-header text-centered">
|
||||
<h1>
|
||||
{t('invited_to_join')}
|
||||
<br />
|
||||
<em>{requireAcceptData.projectName || 'This project'}</em>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="row text-center">
|
||||
<div className="col-md-12">
|
||||
<p>
|
||||
{t('accepting_invite_as')} <em>{user.email}</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row text-center">
|
||||
<div className="col-md-12">
|
||||
<button
|
||||
className="btn btn-lg btn-primary"
|
||||
type="submit"
|
||||
onClick={() => sendPostRequest(true)}
|
||||
>
|
||||
{t('join_project')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import {
|
||||
V1ImportData,
|
||||
V1ImportDataScreen,
|
||||
} from '@/features/token-access/components/v1-import-data-screen'
|
||||
import { AccessAttemptScreen } from '@/features/token-access/components/access-attempt-screen'
|
||||
import {
|
||||
RequireAcceptData,
|
||||
RequireAcceptScreen,
|
||||
} from '@/features/token-access/components/require-accept-screen'
|
||||
import Icon from '@/shared/components/icon'
|
||||
|
||||
type Mode = 'access-attempt' | 'v1Import' | 'requireAccept'
|
||||
|
||||
function TokenAccessRoot() {
|
||||
const [mode, setMode] = useState<Mode>('access-attempt')
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const [accessError, setAccessError] = useState<string | boolean>(false)
|
||||
const [v1ImportData, setV1ImportData] = useState<V1ImportData>()
|
||||
const [requireAcceptData, setRequireAcceptData] =
|
||||
useState<RequireAcceptData>()
|
||||
const [loadingScreenBrandHeight, setLoadingScreenBrandHeight] =
|
||||
useState('0px')
|
||||
const location = useLocation()
|
||||
|
||||
const sendPostRequest = useCallback(
|
||||
(confirmedByUser = false) => {
|
||||
setInflight(true)
|
||||
|
||||
postJSON(getMeta('ol-postUrl'), {
|
||||
body: {
|
||||
confirmedByUser,
|
||||
tokenHashPrefix: document.location.hash,
|
||||
},
|
||||
})
|
||||
.then(async data => {
|
||||
setAccessError(false)
|
||||
|
||||
if (data.redirect) {
|
||||
location.replace(data.redirect)
|
||||
} else if (data.v1Import) {
|
||||
setMode('v1Import')
|
||||
setV1ImportData(data.v1Import)
|
||||
} else if (data.requireAccept) {
|
||||
setMode('requireAccept')
|
||||
setRequireAcceptData(data.requireAccept)
|
||||
} else {
|
||||
debugConsole.warn(
|
||||
'invalid data from server in success response',
|
||||
data
|
||||
)
|
||||
setAccessError(true)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
debugConsole.warn('error response from server', error)
|
||||
setAccessError(error.response?.status === 404 ? 'not_found' : 'error')
|
||||
})
|
||||
.finally(() => {
|
||||
setInflight(false)
|
||||
})
|
||||
},
|
||||
[location]
|
||||
)
|
||||
|
||||
const postedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!postedRef.current) {
|
||||
postedRef.current = true
|
||||
sendPostRequest()
|
||||
setTimeout(() => {
|
||||
setLoadingScreenBrandHeight('20%')
|
||||
}, 500)
|
||||
}
|
||||
}, [sendPostRequest])
|
||||
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="full-size">
|
||||
<div>
|
||||
<a
|
||||
href="/project"
|
||||
// TODO: class name
|
||||
style={{ fontSize: '2rem', marginLeft: '1rem', color: '#ddd' }}
|
||||
>
|
||||
<Icon type="arrow-left" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{mode === 'access-attempt' && (
|
||||
<AccessAttemptScreen
|
||||
accessError={accessError}
|
||||
inflight={inflight}
|
||||
loadingScreenBrandHeight={loadingScreenBrandHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'v1Import' && v1ImportData && (
|
||||
<V1ImportDataScreen v1ImportData={v1ImportData} />
|
||||
)}
|
||||
|
||||
{mode === 'requireAccept' && requireAcceptData && (
|
||||
<RequireAcceptScreen
|
||||
requireAcceptData={requireAcceptData}
|
||||
sendPostRequest={sendPostRequest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(TokenAccessRoot, GenericErrorBoundaryFallback)
|
|
@ -0,0 +1,84 @@
|
|||
import { FC } from 'react'
|
||||
|
||||
export type V1ImportData = {
|
||||
name?: string
|
||||
status: string
|
||||
projectId: string
|
||||
}
|
||||
export const V1ImportDataScreen: FC<{ v1ImportData: V1ImportData }> = ({
|
||||
v1ImportData,
|
||||
}) => {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-sm-8 col-sm-offset-2">
|
||||
<h1 className="text-center">
|
||||
{v1ImportData.status === 'mustLogin'
|
||||
? 'Please log in'
|
||||
: 'Overleaf v1 Project'}
|
||||
</h1>
|
||||
|
||||
<img
|
||||
className="v2-import__img"
|
||||
src="/img/v1-import/v2-editor.png"
|
||||
alt="The new V2 editor."
|
||||
/>
|
||||
|
||||
{v1ImportData.status === 'cannotImport' && (
|
||||
<div>
|
||||
<h2 className="text-center">
|
||||
Cannot Access Overleaf v1 Project
|
||||
</h2>
|
||||
|
||||
<p className="text-center row-spaced-small">
|
||||
Please contact the project owner or{' '}
|
||||
<a href="/contact">contact support</a> for assistance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{v1ImportData.status === 'mustLogin' && (
|
||||
<div>
|
||||
<p className="text-center row-spaced-small">
|
||||
You will need to log in to access this project.
|
||||
</p>
|
||||
|
||||
<div className="row-spaced text-center">
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/login?redir=${encodeURIComponent(document.location.pathname)}`}
|
||||
>
|
||||
Log in to access project
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{v1ImportData.status === 'canDownloadZip' && (
|
||||
<div>
|
||||
<p className="text-center row-spaced-small">
|
||||
<strong>{v1ImportData.name || 'This project'}</strong> has not
|
||||
yet been moved into the new version of Overleaf. This project
|
||||
was created anonymously and therefore cannot be automatically
|
||||
imported. Please download a zip file of the project and upload
|
||||
that to continue editing it. If you would like to delete this
|
||||
project after you have made a copy, please contact support.
|
||||
</p>
|
||||
|
||||
<div className="row-spaced text-center">
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/overleaf/project/${v1ImportData.projectId}/download/zip`}
|
||||
>
|
||||
Download project zip file
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -3,49 +3,43 @@ import ruleset from './HumanReadableLogsRules'
|
|||
|
||||
export default {
|
||||
parse(rawLog, options) {
|
||||
let parsedLogEntries
|
||||
if (typeof rawLog === 'string') {
|
||||
const latexLogParser = new LatexLogParser(rawLog, options)
|
||||
parsedLogEntries = latexLogParser.parse()
|
||||
} else {
|
||||
parsedLogEntries = rawLog
|
||||
}
|
||||
|
||||
const _getRule = function (logMessage) {
|
||||
for (const rule of ruleset) {
|
||||
if (rule.regexToMatch.test(logMessage)) {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
}
|
||||
const parsedLogEntries =
|
||||
typeof rawLog === 'string'
|
||||
? new LatexLogParser(rawLog, options).parse()
|
||||
: rawLog
|
||||
|
||||
const seenErrorTypes = {} // keep track of types of errors seen
|
||||
|
||||
for (const entry of parsedLogEntries.all) {
|
||||
const ruleDetails = _getRule(entry.message)
|
||||
const ruleDetails = ruleset.find(rule =>
|
||||
rule.regexToMatch.test(entry.message)
|
||||
)
|
||||
|
||||
if (ruleDetails != null) {
|
||||
let type
|
||||
if (ruleDetails.ruleId != null) {
|
||||
if (ruleDetails) {
|
||||
if (ruleDetails.ruleId) {
|
||||
entry.ruleId = ruleDetails.ruleId
|
||||
}
|
||||
if (ruleDetails.newMessage != null) {
|
||||
|
||||
if (ruleDetails.newMessage) {
|
||||
entry.message = entry.message.replace(
|
||||
ruleDetails.regexToMatch,
|
||||
ruleDetails.newMessage
|
||||
)
|
||||
}
|
||||
|
||||
if (ruleDetails.contentRegex) {
|
||||
const match = entry.content.match(ruleDetails.contentRegex)
|
||||
if (match) {
|
||||
entry.contentDetails = match.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.contentDetails && ruleDetails.improvedTitle) {
|
||||
const message = ruleDetails.improvedTitle(
|
||||
entry.message,
|
||||
entry.contentDetails
|
||||
)
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
entry.message = message[0]
|
||||
// removing the messageComponent, as the markup possible in it was causing crashes when
|
||||
|
@ -56,17 +50,19 @@ export default {
|
|||
entry.message = message
|
||||
}
|
||||
}
|
||||
|
||||
// suppress any entries that are known to cascade from previous error types
|
||||
if (ruleDetails.cascadesFrom != null) {
|
||||
for (type of ruleDetails.cascadesFrom) {
|
||||
if (ruleDetails.cascadesFrom) {
|
||||
for (const type of ruleDetails.cascadesFrom) {
|
||||
if (seenErrorTypes[type]) {
|
||||
entry.suppressed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// record the types of errors seen
|
||||
if (ruleDetails.types != null) {
|
||||
for (type of ruleDetails.types) {
|
||||
if (ruleDetails.types) {
|
||||
for (const type of ruleDetails.types) {
|
||||
seenErrorTypes[type] = true
|
||||
}
|
||||
}
|
||||
|
@ -74,8 +70,7 @@ export default {
|
|||
}
|
||||
|
||||
// filter out the suppressed errors (from the array entries in parsedLogEntries)
|
||||
for (const key in parsedLogEntries) {
|
||||
const errors = parsedLogEntries[key]
|
||||
for (const [key, errors] of Object.entries(parsedLogEntries)) {
|
||||
if (typeof errors === 'object' && errors.length > 0) {
|
||||
parsedLogEntries[key] = Array.from(errors).filter(
|
||||
err => !err.suppressed
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,13 @@
|
|||
import 'jquery'
|
||||
import 'bootstrap'
|
||||
import './../utils/meta'
|
||||
import './../utils/webpack-public-path'
|
||||
import './../infrastructure/error-reporter'
|
||||
import './../i18n'
|
||||
import ReactDOM from 'react-dom'
|
||||
import TokenAccessRoot from '../features/token-access/components/token-access-root'
|
||||
|
||||
const element = document.getElementById('token-access-page')
|
||||
if (element) {
|
||||
ReactDOM.render(<TokenAccessRoot />, element)
|
||||
}
|
|
@ -5,6 +5,10 @@ export const location = {
|
|||
// eslint-disable-next-line no-restricted-syntax
|
||||
window.location.assign(url)
|
||||
},
|
||||
replace(url) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
window.location.replace(url)
|
||||
},
|
||||
reload() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
window.location.reload()
|
||||
|
|
|
@ -34,7 +34,7 @@ import { useUserContext } from './user-context'
|
|||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
type PdfFile = Record<string, any>
|
||||
|
||||
|
@ -342,7 +342,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
|
|||
}
|
||||
}, [compiling, isProjectOwner, hasShortCompileTimeout])
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const hasCompileLogsEvents = useFeatureFlag('compile-log-events')
|
||||
|
||||
// handle the data returned from a compile request
|
||||
// note: this should _only_ run when `data` changes,
|
||||
|
@ -401,7 +401,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
|
|||
)
|
||||
}
|
||||
|
||||
if (splitTestVariants['compile-log-events'] === 'enabled') {
|
||||
if (hasCompileLogsEvents) {
|
||||
sendMB('compile-log-entries', {
|
||||
status: data.status,
|
||||
stopOnFirstError: data.options.stopOnFirstError,
|
||||
|
@ -479,6 +479,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
|
|||
}, [
|
||||
data,
|
||||
ide,
|
||||
hasCompileLogsEvents,
|
||||
hasPremiumCompile,
|
||||
isProjectOwner,
|
||||
projectId,
|
||||
|
@ -487,7 +488,6 @@ export const LocalCompileProvider: FC = ({ children }) => {
|
|||
setLogEntries,
|
||||
setLogEntryAnnotations,
|
||||
setPdfFile,
|
||||
splitTestVariants,
|
||||
])
|
||||
|
||||
// switch to logs if there's an error
|
||||
|
|
|
@ -39,3 +39,8 @@ export function useSplitTestContext() {
|
|||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useFeatureFlag(name: string) {
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
return splitTestVariants[name] === 'enabled'
|
||||
}
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
|
||||
export const useTutorial = (
|
||||
tutorialKey: string,
|
||||
eventData: Record<string, any> = {}
|
||||
) => {
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
|
||||
const { deactivateTutorial, currentPopup, setCurrentPopup } =
|
||||
useEditorContext()
|
||||
|
||||
const completeTutorial = useCallback(
|
||||
async ({
|
||||
event = 'promo-click',
|
||||
action = 'complete',
|
||||
...rest
|
||||
}: {
|
||||
event: 'promo-click' | 'promo-dismiss'
|
||||
action: 'complete' | 'postpone'
|
||||
} & Record<string, any>) => {
|
||||
eventTracking.sendMB(event, { ...eventData, ...rest })
|
||||
try {
|
||||
await postJSON(`/tutorial/${tutorialKey}/${action}`)
|
||||
} catch (err) {
|
||||
debugConsole.error(err)
|
||||
}
|
||||
setShowPopup(false)
|
||||
deactivateTutorial(tutorialKey)
|
||||
},
|
||||
[deactivateTutorial, eventData, tutorialKey]
|
||||
)
|
||||
|
||||
const dismissTutorial = useCallback(async () => {
|
||||
await completeTutorial({
|
||||
event: 'promo-dismiss',
|
||||
action: 'complete',
|
||||
})
|
||||
}, [completeTutorial])
|
||||
|
||||
const maybeLater = useCallback(async () => {
|
||||
await completeTutorial({
|
||||
event: 'promo-click',
|
||||
action: 'postpone',
|
||||
button: 'maybe-later',
|
||||
})
|
||||
}, [completeTutorial])
|
||||
|
||||
// try to show the popup if we don't already have one showing, returns true if it can show, false if it can't
|
||||
const tryShowingPopup = useCallback(() => {
|
||||
if (currentPopup === null) {
|
||||
setCurrentPopup(tutorialKey)
|
||||
setShowPopup(true)
|
||||
eventTracking.sendMB('promo-prompt', eventData)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [currentPopup, setCurrentPopup, tutorialKey, eventData])
|
||||
|
||||
const clearPopup = useCallback(
|
||||
(force: boolean = false) => {
|
||||
// popups should only clear themselves, in cases they need to cleanup or shouldnt show anymore
|
||||
// allow forcing the clear if needed, eg: higher prio alert needs to show
|
||||
if (force || currentPopup === tutorialKey) {
|
||||
setCurrentPopup(null)
|
||||
setShowPopup(false)
|
||||
}
|
||||
},
|
||||
[setCurrentPopup, setShowPopup, currentPopup, tutorialKey]
|
||||
)
|
||||
|
||||
return {
|
||||
completeTutorial,
|
||||
dismissTutorial,
|
||||
maybeLater,
|
||||
tryShowingPopup,
|
||||
clearPopup,
|
||||
showPopup,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTutorial
|
|
@ -14,11 +14,20 @@ export const useLocation = () => {
|
|||
[isMounted]
|
||||
)
|
||||
|
||||
const replace = useCallback(
|
||||
url => {
|
||||
if (isMounted.current) {
|
||||
location.replace(url)
|
||||
}
|
||||
},
|
||||
[isMounted]
|
||||
)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
location.reload()
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
return useMemo(() => ({ assign, reload }), [assign, reload])
|
||||
return useMemo(() => ({ assign, replace, reload }), [assign, replace, reload])
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
.affiliations-table-cell-tabbed {
|
||||
margin: @margin-sm 0 0 @margin-md;
|
||||
padding-left: @margin-sm;
|
||||
border-left: 2px solid @table-border-color;
|
||||
border-left: 2px solid @table-border-color; // don't migrate this line of style
|
||||
}
|
||||
.affiliations-table-row--highlighted {
|
||||
background-color: tint(@content-alt-bg-color, 6%);
|
||||
|
@ -152,8 +152,9 @@ tbody > tr.affiliations-table-warning-row > td {
|
|||
}
|
||||
|
||||
.settings-widget-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
|
||||
|
@ -177,8 +178,7 @@ tbody > tr.affiliations-table-warning-row > td {
|
|||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
margin-bottom: 10px;
|
||||
|
||||
> h4 {
|
||||
|
@ -196,6 +196,12 @@ tbody > tr.affiliations-table-warning-row > td {
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
.settings-widget-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Should not be migrated to BS5
|
||||
.settings-reconfirm-info {
|
||||
display: flex;
|
||||
|
|
|
@ -22,12 +22,22 @@
|
|||
|
||||
.toolbar-right,
|
||||
.toolbar-left {
|
||||
button {
|
||||
button:not(.back-to-editor-btn) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right .back-to-editor-btn {
|
||||
margin-right: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.toolbar-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> a:focus,
|
||||
button:focus {
|
||||
outline: none;
|
||||
|
@ -38,7 +48,7 @@
|
|||
.toolbar-left > a:not(.btn),
|
||||
.toolbar-left > button,
|
||||
.toolbar-right > a:not(.btn),
|
||||
.toolbar-right > button {
|
||||
.toolbar-right > button:not(.back-to-editor-btn) {
|
||||
display: inline-block;
|
||||
color: @toolbar-icon-btn-color;
|
||||
background-color: transparent;
|
||||
|
|
|
@ -77,6 +77,12 @@
|
|||
margin: 3em 0;
|
||||
}
|
||||
|
||||
.team-invite {
|
||||
.team-invite-name {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.capitalised {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
|
|
@ -50,5 +50,5 @@
|
|||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ $prefix: bs-;
|
|||
$font-family-sans-serif: 'Noto Sans', sans-serif;
|
||||
$font-family-serif: 'Merriweather', serif;
|
||||
$font-family-monospace: 'DM Mono', monospace;
|
||||
|
||||
$font-size-base: 1rem;
|
||||
$font-size-sm: var(--font-size-02);
|
||||
$line-height-base: 1.5;
|
||||
|
@ -19,6 +18,7 @@ $btn-padding-y: $spacing-02;
|
|||
$btn-border-radius: $border-radius-full;
|
||||
$btn-border-radius-lg: $border-radius-full;
|
||||
$btn-border-radius-sm: $border-radius-full;
|
||||
$btn-white-space: nowrap;
|
||||
|
||||
// Colors
|
||||
$primary: $bg-accent-01;
|
||||
|
|
|
@ -40,5 +40,11 @@
|
|||
// Utilities
|
||||
@import 'bootstrap-5/scss/utilities/api';
|
||||
|
||||
// Mixins
|
||||
@import 'bootstrap-5/scss/mixins/breakpoints';
|
||||
|
||||
// Components custom style
|
||||
@import '../components/all';
|
||||
|
||||
// Pages custom style
|
||||
@import '../pages/all';
|
||||
|
|
|
@ -29,3 +29,7 @@ $footer-height: 50px;
|
|||
hr {
|
||||
opacity: unset;
|
||||
}
|
||||
|
||||
.horizontal-divider {
|
||||
border-top: 1px solid var(--border-divider);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
|
||||
.badge-close {
|
||||
@include reset-button();
|
||||
@include reset-button;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -20,6 +20,7 @@
|
|||
// a random number that would cause the close button to expand enough
|
||||
// so that it won't be affected by badge's padding
|
||||
$expand: 100px;
|
||||
|
||||
padding: $expand $spacing-01;
|
||||
margin: (-$expand) (-$spacing-02) (-$expand) $spacing-02;
|
||||
user-select: none;
|
||||
|
|
|
@ -83,6 +83,7 @@
|
|||
$color: var(--content-primary-dark),
|
||||
$background: var(--blue-70)
|
||||
);
|
||||
|
||||
background: var(--premium-gradient);
|
||||
}
|
||||
}
|
||||
|
@ -141,6 +142,7 @@
|
|||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
border-width: 0.2em;
|
||||
height: 24px;
|
||||
|
@ -157,6 +159,7 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-04); // Add gap between text and icons
|
||||
justify-content: center;
|
||||
|
||||
.icon-small {
|
||||
font-size: 20px;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
.dropdown-menu {
|
||||
@include shadow-sm;
|
||||
|
||||
border: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-02);
|
||||
|
@ -13,6 +14,7 @@
|
|||
|
||||
.dropdown-item {
|
||||
@include body-sm;
|
||||
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--neutral-90);
|
||||
display: grid;
|
||||
|
@ -22,16 +24,16 @@
|
|||
padding: var(--spacing-05) var(--spacing-04);
|
||||
position: relative;
|
||||
|
||||
&:hover:not(.active) {
|
||||
background-color: var(--bg-light-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--bg-accent-03);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background-color: var(--bg-light-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&[variant='danger'] {
|
||||
color: var(--content-danger);
|
||||
|
||||
|
@ -54,6 +56,7 @@
|
|||
|
||||
.dropdown-item-description {
|
||||
@include body-xs;
|
||||
|
||||
color: var(--content-secondary);
|
||||
margin-top: var(--spacing-01);
|
||||
}
|
||||
|
@ -97,6 +100,7 @@
|
|||
&.btn-danger {
|
||||
border-left: 1px solid rgb($neutral-90, 0.16);
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
border-left: 1px solid var(--neutral-60);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
// will be deprecated once notifications moved to use .notification (see below)
|
||||
flex-grow: 1;
|
||||
width: 90%;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
width: auto;
|
||||
}
|
||||
|
@ -13,6 +14,7 @@
|
|||
// will be deprecated once notifications moved to use .notification (see below)
|
||||
margin-top: calc($line-height-computed / 2); // match paragraph padding
|
||||
order: 1;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
margin-top: 0;
|
||||
order: 0;
|
||||
|
@ -38,7 +40,7 @@
|
|||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(var(--neutral-90), 0.08);
|
||||
background-color: rgb(var(--neutral-90) 0.08);
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
}
|
||||
|
@ -108,9 +110,6 @@
|
|||
height: $spacing-12;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.notification-close-btn {
|
||||
padding: 0 0 0 $spacing-06;
|
||||
|
||||
button {
|
||||
|
@ -125,7 +124,7 @@
|
|||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(var(--neutral-90), 0.08);
|
||||
background-color: rgb(var(--neutral-90) 0.08);
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
}
|
||||
|
@ -134,34 +133,43 @@
|
|||
&.notification-type-info {
|
||||
background-color: var(--bg-info-03);
|
||||
border: 1px solid var(--blue-20);
|
||||
|
||||
.notification-icon {
|
||||
color: var(--blue-50);
|
||||
}
|
||||
}
|
||||
|
||||
&.notification-type-success {
|
||||
background-color: var(--bg-accent-03);
|
||||
border: 1px solid var(--green-20);
|
||||
|
||||
.notification-icon {
|
||||
color: var(--green-50);
|
||||
}
|
||||
}
|
||||
|
||||
&.notification-type-warning {
|
||||
background-color: var(--bg-warning-03);
|
||||
border: 1px solid var(--yellow-20);
|
||||
|
||||
.notification-icon {
|
||||
color: var(--yellow-40);
|
||||
}
|
||||
}
|
||||
|
||||
&.notification-type-error {
|
||||
background-color: var(--bg-danger-03);
|
||||
border: 1px solid var(--red-20);
|
||||
|
||||
.notification-icon {
|
||||
color: var(--red-50);
|
||||
}
|
||||
}
|
||||
|
||||
&.notification-type-offer {
|
||||
background-color: var(--bg-light-primary);
|
||||
border: 1px solid var(--neutral-20);
|
||||
|
||||
.notification-icon {
|
||||
color: var(--neutral-50);
|
||||
}
|
||||
|
@ -203,9 +211,11 @@
|
|||
.reconfirm-notification {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.fa-warning {
|
||||
margin-right: $spacing-05;
|
||||
}
|
||||
|
||||
.btn-reconfirm {
|
||||
float: right;
|
||||
margin-left: $spacing-05;
|
||||
|
@ -238,6 +248,7 @@
|
|||
.reconfirm-notification {
|
||||
background-color: var(--neutral-10);
|
||||
border-radius: $border-radius-base;
|
||||
|
||||
.fa-warning {
|
||||
color: var(--yellow-40);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
.tooltip {
|
||||
line-height: 20px;
|
||||
|
||||
@include shadow-md;
|
||||
|
||||
&.#{$prefix}tooltip-top {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
/* ====== Sass color variables ====== */
|
||||
|
||||
/* Neutral */
|
||||
$white: #ffffff;
|
||||
$white: #fff;
|
||||
$neutral-10: #f4f5f6;
|
||||
$neutral-20: #e7e9ee;
|
||||
$neutral-30: #d0d5dd;
|
||||
|
@ -60,28 +60,22 @@ $bg-light-primary: $white;
|
|||
$bg-light-secondary: $neutral-10;
|
||||
$bg-light-tertiary: $neutral-20;
|
||||
$bg-light-disabled: $neutral-20;
|
||||
|
||||
$bg-dark-primary: $neutral-90;
|
||||
$bg-dark-secondary: $neutral-80;
|
||||
$bg-dark-tertiary: $neutral-70;
|
||||
$bg-dark-disabled: $neutral-70;
|
||||
|
||||
$bg-accent-01: $green-50;
|
||||
$bg-accent-02: $green-60;
|
||||
$bg-accent-03: $green-10;
|
||||
|
||||
$bg-danger-01: $red-50;
|
||||
$bg-danger-02: $red-60;
|
||||
$bg-danger-03: $red-10;
|
||||
|
||||
$bg-warning-01: $yellow-50;
|
||||
$bg-warning-02: $yellow-60;
|
||||
$bg-warning-03: $yellow-10;
|
||||
|
||||
$bg-info-01: $blue-50;
|
||||
$bg-info-02: $blue-60;
|
||||
$bg-info-03: $blue-10;
|
||||
|
||||
$content-primary: $neutral-90;
|
||||
$content-secondary: $neutral-70;
|
||||
$content-disabled: $neutral-40;
|
||||
|
@ -89,21 +83,18 @@ $content-placeholder: $neutral-60;
|
|||
$content-danger: $red-50;
|
||||
$content-warning: $yellow-50;
|
||||
$content-positive: $green-50;
|
||||
|
||||
$border-primary: $neutral-60;
|
||||
$border-hover: $neutral-70;
|
||||
$border-disabled: $neutral-20;
|
||||
$border-active: $blue-50;
|
||||
$border-danger: $red-50;
|
||||
$border-divider: $neutral-20;
|
||||
|
||||
$link-web: $green-60;
|
||||
$link-web-hover: $green-70;
|
||||
$link-web-visited: $green-70;
|
||||
$link-ui: $blue-50;
|
||||
$link-ui-hover: $blue-60;
|
||||
$link-ui-visited: $blue-60;
|
||||
|
||||
$content-primary-dark: $white;
|
||||
$content-secondary-dark: $neutral-20;
|
||||
$content-disabled-dark: $neutral-60;
|
||||
|
@ -111,14 +102,12 @@ $content-placeholder-dark: $neutral-50;
|
|||
$content-danger-dark: $red-40;
|
||||
$content-warning-dark: $yellow-40;
|
||||
$content-positive-dark: $green-40;
|
||||
|
||||
$border-primary-dark: $neutral-30;
|
||||
$border-hover-dark: $neutral-20;
|
||||
$border-disabled-dark: $neutral-80;
|
||||
$border-active-dark: $blue-30;
|
||||
$border-danger-dark: $red-40;
|
||||
$border-divider-dark: $neutral-80;
|
||||
|
||||
$link-web-dark: $green-30;
|
||||
$link-web-hover-dark: $green-40;
|
||||
$link-web-visited-dark: $green-40;
|
||||
|
@ -181,28 +170,22 @@ $link-ui-visited-dark: $blue-40;
|
|||
--bg-light-secondary: var(--neutral-10);
|
||||
--bg-light-tertiary: var(--neutral-20);
|
||||
--bg-light-disabled: var(--neutral-20);
|
||||
|
||||
--bg-dark-primary: var(--neutral-90);
|
||||
--bg-dark-secondary: var(--neutral-80);
|
||||
--bg-dark-tertiary: var(--neutral-70);
|
||||
--bg-dark-disabled: var(--neutral-70);
|
||||
|
||||
--bg-accent-01: var(--green-50);
|
||||
--bg-accent-02: var(--green-60);
|
||||
--bg-accent-03: var(--green-10);
|
||||
|
||||
--bg-danger-01: var(--red-50);
|
||||
--bg-danger-02: var(--red-60);
|
||||
--bg-danger-03: var(--red-10);
|
||||
|
||||
--bg-warning-01: var(--yellow-50);
|
||||
--bg-warning-02: var(--yellow-60);
|
||||
--bg-warning-03: var(--yellow-10);
|
||||
|
||||
--bg-info-01: var(--blue-50);
|
||||
--bg-info-02: var(--blue-60);
|
||||
--bg-info-03: var(--blue-10);
|
||||
|
||||
--content-primary: var(--neutral-90);
|
||||
--content-secondary: var(--neutral-70);
|
||||
--content-disabled: var(--neutral-40);
|
||||
|
@ -210,21 +193,18 @@ $link-ui-visited-dark: $blue-40;
|
|||
--content-danger: var(--red-50);
|
||||
--content-warning: var(--yellow-50);
|
||||
--content-positive: var(--green-50);
|
||||
|
||||
--border-primary: var(--neutral-60);
|
||||
--border-hover: var(--neutral-70);
|
||||
--border-disabled: var(--neutral-20);
|
||||
--border-active: var(--blue-50);
|
||||
--border-danger: var(--red-50);
|
||||
--border-divider: var(--neutral-20);
|
||||
|
||||
--link-web: var(--green-60);
|
||||
--link-web-hover: var(--green-70);
|
||||
--link-web-visited: var(--green-70);
|
||||
--link-ui: var(--blue-50);
|
||||
--link-ui-hover: var(--blue-60);
|
||||
--link-ui-visited: var(--blue-60);
|
||||
|
||||
--content-primary-dark: var(--white);
|
||||
--content-secondary-dark: var(--neutral-20);
|
||||
--content-disabled-dark: var(--neutral-60);
|
||||
|
@ -232,14 +212,12 @@ $link-ui-visited-dark: $blue-40;
|
|||
--content-danger-dark: var(--red-40);
|
||||
--content-warning-dark: var(--yellow-40);
|
||||
--content-positive-dark: var(--green-40);
|
||||
|
||||
--border-primary-dark: var(--neutral-30);
|
||||
--border-hover-dark: var(--neutral-20);
|
||||
--border-disabled-dark: var(--neutral-80);
|
||||
--border-active-dark: var(--blue-30);
|
||||
--border-danger-dark: var(--red-40);
|
||||
--border-divider-dark: var(--neutral-80);
|
||||
|
||||
--link-web-dark: var(--green-30);
|
||||
--link-web-hover-dark: var(--green-40);
|
||||
--link-web-visited-dark: var(--green-40);
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// Cards
|
||||
@mixin shadow-sm {
|
||||
box-shadow: 0px 2px 8px rgba(30, 37, 48, 0.08);
|
||||
box-shadow: 0 2px 8px rgb(30 37 48 / 8%);
|
||||
}
|
||||
|
||||
// Tooltips, Callouts, Dropdowns, etc.
|
||||
@mixin shadow-md {
|
||||
box-shadow:
|
||||
0px 4px 24px rgba(30, 37, 48, 0.12),
|
||||
0px 1px 4px rgba(30, 37, 48, 0.08);
|
||||
0 4px 24px rgb(30 37 48 / 12%),
|
||||
0 1px 4px rgb(30 37 48 / 8%);
|
||||
}
|
||||
|
||||
// Modals, drawers
|
||||
@mixin shadow-lg {
|
||||
box-shadow:
|
||||
0px 8px 24px rgba(30, 37, 48, 0.16),
|
||||
0px 4px 8px rgba(30, 37, 48, 0.16);
|
||||
0 8px 24px rgb(30 37 48 / 16%),
|
||||
0 4px 8px rgb(30 37 48 / 16%);
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
--font-size-11: 3.75rem; // 60px
|
||||
--font-size-12: 4.5rem; // 72px
|
||||
--font-size-13: 6em; // 96px
|
||||
|
||||
--line-height-01: 1rem; // 16px
|
||||
--line-height-02: 1.25rem; // 20px
|
||||
--line-height-03: 1.5rem; // 24px
|
||||
|
@ -40,6 +39,7 @@
|
|||
font-size: var(--font-size-12);
|
||||
line-height: var(--line-height-11);
|
||||
}
|
||||
|
||||
@mixin display-sm {
|
||||
font-size: var(--font-size-11);
|
||||
line-height: var(--line-height-10);
|
||||
|
|
|
@ -0,0 +1,208 @@
|
|||
.affiliations-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.affiliations-table-cell {
|
||||
padding: var(--spacing-04);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.affiliations-table-cell-tabbed {
|
||||
margin: var(--spacing-04) 0 0 var(--spacing-07);
|
||||
padding-left: var(--spacing-04);
|
||||
}
|
||||
|
||||
.affiliations-table-row--highlighted {
|
||||
background-color: var(--bg-light-secondary);
|
||||
}
|
||||
|
||||
.affiliations-table-inline-action {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.affiliation-change-container {
|
||||
margin-top: var(--spacing-04);
|
||||
}
|
||||
|
||||
.affiliations-table-label {
|
||||
padding-top: var(--spacing-02);
|
||||
}
|
||||
|
||||
.btn-link-accounts {
|
||||
margin-bottom: var(--spacing-03);
|
||||
}
|
||||
|
||||
.settings-widget-status-icon,
|
||||
.dropbox-sync-icon {
|
||||
position: relative;
|
||||
font-size: 1.3em;
|
||||
line-height: 1.3em;
|
||||
vertical-align: top;
|
||||
&.status-error,
|
||||
&.dropbox-sync-icon-error {
|
||||
color: var(--bg-danger-01);
|
||||
}
|
||||
&.status-success,
|
||||
&.dropbox-sync-icon-success {
|
||||
color: var(--content-positive);
|
||||
}
|
||||
&.status-pending,
|
||||
&.dropbox-sync-icon-updating {
|
||||
color: var(--bg-info-01);
|
||||
&::after {
|
||||
content: '\f021';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
margin-left: -20%;
|
||||
font-size: 60%;
|
||||
color: #fff;
|
||||
animation: fa-spin 2s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-widgets-container {
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
hr {
|
||||
margin: 0 var(--spacing-05);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-widget-container {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr auto;
|
||||
gap: var(--spacing-07);
|
||||
align-items: center;
|
||||
padding: var(--spacing-05);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: var(--spacing-07);
|
||||
|
||||
&:last-child {
|
||||
padding-right: var(--spacing-00);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.description-container {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
margin: var(--spacing-00);
|
||||
margin-bottom: var(--spacing-05);
|
||||
|
||||
> h4 {
|
||||
margin: 0;
|
||||
margin-right: var(--spacing-05);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--spacing-05);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: var(--spacing-00);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.settings-widget-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevents icon from large account linking sections, such as the git bridge,
|
||||
// from rendering in the center of the widget, anchoring it to the top
|
||||
.linking-icon-fixed-position {
|
||||
align-self: start;
|
||||
padding-top: var(--spacing-05);
|
||||
}
|
||||
|
||||
// overrides the default `Col` padding, as the inner `affiliations-table-cell` has its own padding, and
|
||||
// the content length of the git-bridge token table is pretty much fixed (tokens and dates)
|
||||
.linking-git-bridge-table-cell {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.linking-git-bridge-revoke-button {
|
||||
padding: var(--spacing-01) var(--spacing-02);
|
||||
}
|
||||
|
||||
.enrollment-alert {
|
||||
width: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-05);
|
||||
background-color: var(--bg-info-03);
|
||||
border: 1px solid var(--border-divider);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.icon {
|
||||
flex: 1 1 auto;
|
||||
padding: 0 var(--spacing-06) 0 var(--spacing-02);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.security-row {
|
||||
.line-header > b {
|
||||
color: var(--content-primary);
|
||||
}
|
||||
|
||||
color: var(--content-secondary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: var(--spacing-03) 0;
|
||||
|
||||
.icon {
|
||||
color: var(--content-primary);
|
||||
display: flex;
|
||||
flex: 1 1 7%;
|
||||
padding: 0 var(--spacing-06);
|
||||
margin-top: var(--spacing-06);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1 1 93%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: var(--spacing-06);
|
||||
}
|
||||
|
||||
.button-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
@include body-sm;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-01) var(--spacing-02);
|
||||
margin-top: var(--spacing-02);
|
||||
margin-left: var(--spacing-04);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.status-label-configured {
|
||||
background-color: var(--bg-accent-01);
|
||||
color: var(--content-secondary-dark);
|
||||
}
|
||||
|
||||
&.status-label-ready {
|
||||
background-color: var(--bg-light-tertiary);
|
||||
color: var(--content-primary);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import 'account-settings';
|
|
@ -1,3 +1,4 @@
|
|||
// migrated to layout.scss
|
||||
.horizontal-divider {
|
||||
border-top: 1px solid @hr-border;
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ h3.group-settings-title {
|
|||
color: @gray-dark;
|
||||
font-size: @font-size-small;
|
||||
padding-left: 0;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
|
|
@ -5,7 +5,7 @@ const typescript = require('typescript')
|
|||
module.exports = {
|
||||
input: [
|
||||
'frontend/js/**/*.{js,jsx,ts,tsx}',
|
||||
'modules/**/*.{js,jsx,ts,tsx}',
|
||||
'modules/*/frontend/js/**/*.{js,jsx,ts,tsx}',
|
||||
'!frontend/js/vendor/**',
|
||||
],
|
||||
output: './',
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
"already_have_sl_account": "Máte už účet v __appName__?",
|
||||
"and": "a",
|
||||
"annual": "Roční",
|
||||
"annual_billing_enabled": "Roční vyúčtování povoleno",
|
||||
"anonymous": "Anonymní",
|
||||
"auto_complete": "Automatické dokončování",
|
||||
"back_to_your_projects": "Zpět k vašim projektům",
|
||||
|
@ -35,7 +34,6 @@
|
|||
"change_password": "Změnit heslo",
|
||||
"change_plan": "Změnit tarif",
|
||||
"change_project_owner": "Změnit majitele projektu",
|
||||
"change_to_annual_billing_and_save": "Získejte <0>__percentage__</0> z ročního vyúčtování. Pokud provedete změnu hned, ušetříte <1>__yearlySaving__</1> za rok.",
|
||||
"change_to_this_plan": "Změnit na tento tarif",
|
||||
"chat": "Chat",
|
||||
"checking_dropbox_status": "kontroluji stav Dropboxu",
|
||||
|
@ -158,7 +156,6 @@
|
|||
"month": "měsíc",
|
||||
"monthly": "Měsíční",
|
||||
"more": "Více",
|
||||
"move_to_annual_billing": "Přesun k ročnímu vyúčtování",
|
||||
"must_be_email_address": "Musíte zadat emailovou adresu",
|
||||
"name": "Jméno",
|
||||
"native": "Výchozí",
|
||||
|
|
|
@ -88,7 +88,6 @@
|
|||
"an_error_occurred_when_verifying_the_coupon_code": "En fejl opstod under valideringen af rabatkoden",
|
||||
"and": "og",
|
||||
"annual": "Årlig",
|
||||
"annual_billing_enabled": "Årlig betaling aktiveret",
|
||||
"anonymous": "Anonym",
|
||||
"anyone_with_link_can_edit": "Alle med dette link kan redigere dette projekt",
|
||||
"anyone_with_link_can_view": "Alle med dette link kan se dette projekt",
|
||||
|
@ -123,6 +122,7 @@
|
|||
"automatic_user_registration": "Automatisk brugerregistrering",
|
||||
"back": "Tilbage",
|
||||
"back_to_account_settings": "Tilbage til kontoindstillinger",
|
||||
"back_to_editor": "Tilbage til skrivevinduet",
|
||||
"back_to_log_in": "Tilbage til login",
|
||||
"back_to_subscription": "Tilbage til abonnement",
|
||||
"back_to_your_projects": "Tilbage til dine projekter",
|
||||
|
@ -185,7 +185,6 @@
|
|||
"change_plan": "Ændre abonnement",
|
||||
"change_primary_email_address_instructions": "For at ændre din primære e-mailadresse, tilføj først din nye primære e-mailadresse (ved at klikke <0>Tilføj endnu en e-mailadesse</0>) og bekræft den. Klik derefter på <0>Gør til primær</0>. <1>Lær mere</1> omkring håndtering af dine __appName__ e-mailadresser",
|
||||
"change_project_owner": "Skift projektejer",
|
||||
"change_to_annual_billing_and_save": "Spar <0>__percentage__</0> ved årlig betaling. Hvis du skifter nu, sparer du <1>__yearlySaving__</1> per år.",
|
||||
"change_to_group_plan": "Skift til gruppeabonnement",
|
||||
"change_to_this_plan": "Ændring til dette abonnement",
|
||||
"changing_the_position_of_your_figure": "Ændr positionen af din figur",
|
||||
|
@ -717,7 +716,7 @@
|
|||
"invite_not_accepted": "Invitationen er endnu ikke accepteret",
|
||||
"invite_not_valid": "Dette er ikke en gyldig projekt invitation",
|
||||
"invite_not_valid_description": "Invitationen kan være udløbet. Kontakt venligst projektets ejer",
|
||||
"invited_to_group": "__inviterName__ har inviteret dig til at tilslutte dig et gruppeabonnement på __appName__",
|
||||
"invited_to_group": "<0>__inviterName__</0> har inviteret dig til at tilslutte dig et gruppeabonnement på __appName__",
|
||||
"invited_to_join": "Du er blevet inviteret til at deltage",
|
||||
"ip_address": "IP adresse",
|
||||
"is_email_affiliated": "Er din e-mailaddresse tilknyttet en institution?",
|
||||
|
@ -890,7 +889,6 @@
|
|||
"more_project_collaborators": "<0>Flere</0> <0>samarbejdspartnere</0> i projekter",
|
||||
"more_than_one_kind_of_snippet_was_requested": "Linket til at åbne dette indhold i Overleaf havde nogle ugyldige parametre. Hvis du bliver ved med at opleve det her med links fra en bestemt side, bliver du næsten nødt til at fortælle dem om det.",
|
||||
"most_popular": "Mest populære",
|
||||
"move_to_annual_billing": "Skift til årlig betaling",
|
||||
"must_be_email_address": "Skal være en e-mailaddresse",
|
||||
"n_items": "__count__ enhed",
|
||||
"n_items_plural": "__count__ enheder",
|
||||
|
@ -909,7 +907,6 @@
|
|||
"need_to_add_new_primary_before_remove": "Du bliver nødt til at tilføje en ny primær e-mailaddresse før du kan slette denne.",
|
||||
"need_to_leave": "Nød til at gå?",
|
||||
"need_to_upgrade_for_more_collabs": "Du bliver nød til at opgradere din konto for at tilføje flere samarbejdspartnere",
|
||||
"need_to_upgrade_for_more_collabs_variant": "Du har nået det maksimale antal samarbejdspartnere. Opgradér din konto for at tilføje flere.",
|
||||
"new_file": "Ny fil",
|
||||
"new_folder": "Ny mappe",
|
||||
"new_name": "Nyt navn",
|
||||
|
|
|
@ -88,7 +88,6 @@
|
|||
"an_error_occurred_when_verifying_the_coupon_code": "Beim Überprüfen des Gutscheincodes ist ein Fehler aufgetreten",
|
||||
"and": "und",
|
||||
"annual": "Jährlich",
|
||||
"annual_billing_enabled": "Jährliche Abrechnung aktiviert",
|
||||
"anonymous": "Anonym",
|
||||
"anyone_with_link_can_edit": "Jeder mit diesem Link kann dieses Projekt bearbeiten",
|
||||
"anyone_with_link_can_view": "Jeder mit diesem Link kann dieses Projekt anzeigen",
|
||||
|
@ -123,6 +122,7 @@
|
|||
"automatic_user_registration": "Automatische Nutzerregistrierung",
|
||||
"back": "Zurück",
|
||||
"back_to_account_settings": "Zurück zu den Kontoeinstellungen",
|
||||
"back_to_editor": "Zurück zum Editor",
|
||||
"back_to_log_in": "Zurück zur Anmeldung",
|
||||
"back_to_subscription": "Zurück zum Abonnement",
|
||||
"back_to_your_projects": "Zurück zu deinen Projekten",
|
||||
|
@ -185,7 +185,6 @@
|
|||
"change_plan": "Abonnement ändern",
|
||||
"change_primary_email_address_instructions": "Um deine primäre E-Mail-Adresse zu ändern, füge bitte zuerst deine neue primäre E-Mail-Adresse hinzu (indem du auf <0>„E-Mail-Adresse hinzufügen“</0> klickst) und bestätige diese. Klicke dann auf die Schaltfläche <0>Als primär festlegen</0>. <1>Erfahre mehr</1> über das Verwalten deiner __appName__ E-Mails.",
|
||||
"change_project_owner": "Projektinhaber ändern",
|
||||
"change_to_annual_billing_and_save": "Spare <0>__percentage__</0> durch jährliche Abrechnung. Wenn du jetzt wechselst, sparst du <1>__yearlySaving__</1> pro Jahr.",
|
||||
"change_to_group_plan": "Wechsle zu einem Gruppen-Abonnement",
|
||||
"change_to_this_plan": "Auf dieses Abonnement wechseln",
|
||||
"changing_the_position_of_your_figure": "Position der Abbildung verändern",
|
||||
|
@ -724,7 +723,7 @@
|
|||
"invite_not_accepted": "Einladung noch nicht angenommen",
|
||||
"invite_not_valid": "Dies ist keine gültige Projekteinladung",
|
||||
"invite_not_valid_description": "Die Einladung ist wahrscheinlich abgelaufen. Bitte kontaktiere den Projektbesitzer",
|
||||
"invited_to_group": "__inviterName__ hat dich eingeladen, einem Team auf __appName__ beizutreten",
|
||||
"invited_to_group": "<0>__inviterName__</0> hat dich eingeladen, einem Team auf __appName__ beizutreten",
|
||||
"invited_to_group_login": "Um diese Einladung anzunehmen, melde dich als __emailAddress__ an.",
|
||||
"invited_to_group_login_benefits": "Als Mitglied dieser Gruppe hast Du Zugriff auf __appName__-Premiumfunktionen wie zusätzliche Mitarbeiter, ein höheres Zeitlimit beim Kompilieren und die Nachverfolgung von Änderungen in Echtzeit.",
|
||||
"invited_to_group_register": "Um die Einladung von __inviterName__ anzunehmen, erstelle zunächst ein Konto.",
|
||||
|
@ -889,7 +888,6 @@
|
|||
"more_info": "Mehr Infos",
|
||||
"more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.",
|
||||
"most_popular": "am beliebtesten",
|
||||
"move_to_annual_billing": "Zur jährlichen Abrechnung wechseln",
|
||||
"must_be_email_address": "Es muss eine E-Mail-Adresse sein!",
|
||||
"n_items": "__count__ Artikel",
|
||||
"n_items_plural": "__count__ Artikel",
|
||||
|
@ -903,7 +901,6 @@
|
|||
"need_to_add_new_primary_before_remove": "Du musst eine neue primäre E-Mail-Adresse hinzufügen, bevor du diese entfernen kannst.",
|
||||
"need_to_leave": "Du musst gehen?",
|
||||
"need_to_upgrade_for_more_collabs": "Du musst dein Konto upgraden um mehr Mitarbeiter hinzuzufügen",
|
||||
"need_to_upgrade_for_more_collabs_variant": "Du hast die maximale Anzahl an Mitbearbeitern erreicht. Führe ein Upgrade für dein Konto aus, um weitere hinzuzufügen.",
|
||||
"new_file": "Neue Datei",
|
||||
"new_folder": "Neuer Ordner",
|
||||
"new_name": "Neuer Name",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue