Merge pull request #19817 from overleaf/jpa-types

[overleaf-editor-core] stronger type checking via web

GitOrigin-RevId: 427019f40e2905f2e0ec11dc09f5fccdbb1f905b
This commit is contained in:
Jakob Ackermann 2024-08-07 16:51:44 +02:00 committed by Copybot
parent bd87e1b41b
commit 3836323724
29 changed files with 470 additions and 84 deletions

View file

@ -149,6 +149,7 @@ class Comment {
* @returns {CommentRawData}
*/
toRaw() {
/** @type CommentRawData */
const raw = {
id: this.id,
ranges: this.ranges.map(range => range.toRaw()),

View file

@ -13,6 +13,8 @@ const StringFileData = require('./file_data/string_file_data')
* @typedef {import("./blob")} Blob
* @typedef {import("./types").BlobStore} BlobStore
* @typedef {import("./types").ReadonlyBlobStore} ReadonlyBlobStore
* @typedef {import("./types").RawFileData} RawFileData
* @typedef {import("./types").RawFile} RawFile
* @typedef {import("./types").StringFileRawData} StringFileRawData
* @typedef {import("./types").CommentRawData} CommentRawData
* @typedef {import("./file_data/comment_list")} CommentList
@ -62,9 +64,14 @@ class File {
assert.instance(data, FileData, 'File: bad data')
this.data = data
this.metadata = {}
this.setMetadata(metadata || {})
}
/**
* @param {RawFile} raw
* @return {File|null}
*/
static fromRaw(raw) {
if (!raw) return null
return new File(FileData.fromRaw(raw), raw.metadata)
@ -90,8 +97,8 @@ class File {
}
/**
* @param {number} [byteLength]
* @param {number} [stringLength]
* @param {number} byteLength
* @param {number?} stringLength
* @param {Object} [metadata]
* @return {File}
*/
@ -109,7 +116,11 @@ class File {
return new File(FileData.createLazyFromBlobs(blob, rangesBlob), metadata)
}
/**
* @returns {RawFile}
*/
toRaw() {
/** @type RawFile */
const rawFileData = this.data.toRaw()
storeRawMetadata(this.metadata, rawFileData)
return rawFileData
@ -249,15 +260,20 @@ class File {
* the hash.
*
* @param {BlobStore} blobStore
* @return {Promise<Object>} a raw HashFile
* @return {Promise<RawFile>} a raw HashFile
*/
async store(blobStore) {
/** @type RawFile */
const raw = await this.data.store(blobStore)
storeRawMetadata(this.metadata, raw)
return raw
}
}
/**
* @param {Object} metadata
* @param {RawFile} raw
*/
function storeRawMetadata(metadata, raw) {
if (!_.isEmpty(metadata)) {
raw.metadata = _.cloneDeep(metadata)

View file

@ -5,6 +5,10 @@ const assert = require('check-types').assert
const Blob = require('../blob')
const FileData = require('./')
/**
* @typedef {import('../types').RawBinaryFileData} RawBinaryFileData
*/
class BinaryFileData extends FileData {
/**
* @param {string} hash
@ -21,11 +25,18 @@ class BinaryFileData extends FileData {
this.byteLength = byteLength
}
/**
* @param {RawBinaryFileData} raw
* @returns {BinaryFileData}
*/
static fromRaw(raw) {
return new BinaryFileData(raw.hash, raw.byteLength)
}
/** @inheritdoc */
/**
* @inheritdoc
* @returns {RawBinaryFileData}
*/
toRaw() {
return { hash: this.hash, byteLength: this.byteLength }
}
@ -60,7 +71,9 @@ class BinaryFileData extends FileData {
return FileData.createHollow(this.byteLength, null)
}
/** @inheritdoc */
/** @inheritdoc
* @return {Promise<RawFileData>}
*/
async store() {
return { hash: this.hash }
}

View file

@ -10,6 +10,7 @@ const FileData = require('./')
* @typedef {import('./lazy_string_file_data')} LazyStringFileData
* @typedef {import('./hollow_string_file_data')} HollowStringFileData
* @typedef {import('../types').BlobStore} BlobStore
* @typedef {import('../types').RawHashFileData} RawHashFileData
*/
class HashFileData extends FileData {
@ -35,8 +36,7 @@ class HashFileData extends FileData {
/**
*
* @param {{hash: string, rangesHash?: string}} raw
* @returns
* @param {RawHashFileData} raw
*/
static fromRaw(raw) {
return new HashFileData(raw.hash, raw.rangesHash)
@ -44,9 +44,10 @@ class HashFileData extends FileData {
/**
* @inheritdoc
* @returns {{hash: string, rangesHash?: string}}
* @returns {RawHashFileData}
*/
toRaw() {
/** @type RawHashFileData */
const raw = { hash: this.hash }
if (this.rangesHash) {
raw.rangesHash = this.rangesHash
@ -97,6 +98,8 @@ class HashFileData extends FileData {
throw new Error('Failed to look up rangesHash in blobStore')
}
if (!blob) throw new Error('blob not found: ' + this.hash)
// TODO(das7pad): inline 2nd path of FileData.createLazyFromBlobs?
// @ts-ignore
return FileData.createLazyFromBlobs(blob, rangesBlob)
}
@ -110,14 +113,17 @@ class HashFileData extends FileData {
if (!blob) {
throw new Error('Failed to look up hash in blobStore')
}
// TODO(das7pad): inline 2nd path of FileData.createHollow?
// @ts-ignore
return FileData.createHollow(blob.getByteLength(), blob.getStringLength())
}
/**
* @inheritdoc
* @returns {Promise<{hash: string, rangesHash?: string}>}
* @returns {Promise<RawHashFileData>}
*/
async store() {
/** @type RawHashFileData */
const raw = { hash: this.hash }
if (this.rangesHash) {
raw.rangesHash = this.rangesHash

View file

@ -4,6 +4,10 @@ const assert = require('check-types').assert
const FileData = require('./')
/**
* @typedef {import('../types').RawHollowBinaryFileData} RawHollowBinaryFileData
*/
class HollowBinaryFileData extends FileData {
/**
* @param {number} byteLength
@ -16,11 +20,18 @@ class HollowBinaryFileData extends FileData {
this.byteLength = byteLength
}
/**
* @param {RawHollowBinaryFileData} raw
* @returns {HollowBinaryFileData}
*/
static fromRaw(raw) {
return new HollowBinaryFileData(raw.byteLength)
}
/** @inheritdoc */
/**
* @inheritdoc
* @returns {RawHollowBinaryFileData}
*/
toRaw() {
return { byteLength: this.byteLength }
}

View file

@ -8,6 +8,10 @@ const assert = require('check-types').assert
const FileData = require('./')
/**
* @typedef {import('../types').RawHollowStringFileData} RawHollowStringFileData
*/
class HollowStringFileData extends FileData {
/**
* @param {number} stringLength
@ -24,11 +28,18 @@ class HollowStringFileData extends FileData {
this.stringLength = stringLength
}
/**
* @param {RawHollowStringFileData} raw
* @returns {HollowStringFileData}
*/
static fromRaw(raw) {
return new HollowStringFileData(raw.stringLength)
}
/** @inheritdoc */
/**
* @inheritdoc
* @returns {RawHollowStringFileData}
*/
toRaw() {
return { stringLength: this.stringLength }
}

View file

@ -6,18 +6,10 @@ const assert = require('check-types').assert
const Blob = require('../blob')
// Dependencies are loaded at the bottom of the file to mitigate circular
// dependency
let BinaryFileData = null
let HashFileData = null
let HollowBinaryFileData = null
let HollowStringFileData = null
let LazyStringFileData = null
let StringFileData = null
/**
* @typedef {import("../types").BlobStore} BlobStore
* @typedef {import("../types").ReadonlyBlobStore} ReadonlyBlobStore
* @typedef {import("../types").RawFileData} RawFileData
* @typedef {import("../operation/edit_operation")} EditOperation
* @typedef {import("../file_data/comment_list")} CommentList
* @typedef {import("../types").CommentRawData} CommentRawData
@ -29,25 +21,37 @@ let StringFileData = null
* should be used only through {@link File}.
*/
class FileData {
/** @see File.fromRaw */
/** @see File.fromRaw
* @param {RawFileData} raw
*/
static fromRaw(raw) {
// TODO(das7pad): can we teach typescript to understand our polymorphism?
if (Object.prototype.hasOwnProperty.call(raw, 'hash')) {
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
// @ts-ignore
return BinaryFileData.fromRaw(raw)
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
// @ts-ignore
return LazyStringFileData.fromRaw(raw)
// @ts-ignore
return HashFileData.fromRaw(raw)
}
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
// @ts-ignore
return HollowBinaryFileData.fromRaw(raw)
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
// @ts-ignore
return HollowStringFileData.fromRaw(raw)
if (Object.prototype.hasOwnProperty.call(raw, 'content'))
// @ts-ignore
return StringFileData.fromRaw(raw)
throw new Error('FileData: bad raw object ' + JSON.stringify(raw))
}
/** @see File.createHollow */
/** @see File.createHollow
* @param {number} byteLength
* @param {number|null} stringLength
*/
static createHollow(byteLength, stringLength) {
if (stringLength == null) {
return new HollowBinaryFileData(byteLength)
@ -63,15 +67,25 @@ class FileData {
static createLazyFromBlobs(blob, rangesBlob) {
assert.instance(blob, Blob, 'FileData: bad blob')
if (blob.getStringLength() == null) {
return new BinaryFileData(blob.getHash(), blob.getByteLength())
return new BinaryFileData(
// TODO(das7pad): see call-sites
// @ts-ignore
blob.getHash(),
blob.getByteLength()
)
}
return new LazyStringFileData(
// TODO(das7pad): see call-sites
// @ts-ignore
blob.getHash(),
rangesBlob?.getHash(),
blob.getStringLength()
)
}
/**
* @returns {RawFileData}
*/
toRaw() {
throw new Error('FileData: toRaw not implemented')
}
@ -184,7 +198,7 @@ class FileData {
* @see File#store
* @function
* @param {BlobStore} blobStore
* @return {Promise<Object>} a raw HashFile
* @return {Promise<RawFileData>} a raw HashFile
* @abstract
*/
async store(blobStore) {
@ -216,9 +230,9 @@ class FileData {
module.exports = FileData
BinaryFileData = require('./binary_file_data')
HashFileData = require('./hash_file_data')
HollowBinaryFileData = require('./hollow_binary_file_data')
HollowStringFileData = require('./hollow_string_file_data')
LazyStringFileData = require('./lazy_string_file_data')
StringFileData = require('./string_file_data')
const BinaryFileData = require('./binary_file_data')
const HashFileData = require('./hash_file_data')
const HollowBinaryFileData = require('./hollow_binary_file_data')
const HollowStringFileData = require('./hollow_string_file_data')
const LazyStringFileData = require('./lazy_string_file_data')
const StringFileData = require('./string_file_data')

View file

@ -14,6 +14,8 @@ const EditOperationBuilder = require('../operation/edit_operation_builder')
* @typedef {import('../types').BlobStore} BlobStore
* @typedef {import('../types').ReadonlyBlobStore} ReadonlyBlobStore
* @typedef {import('../types').RangesBlob} RangesBlob
* @typedef {import('../types').RawFileData} RawFileData
* @typedef {import('../types').RawLazyStringFileData} RawLazyStringFileData
*/
class LazyStringFileData extends FileData {
@ -39,6 +41,10 @@ class LazyStringFileData extends FileData {
this.operations = operations || []
}
/**
* @param {RawLazyStringFileData} raw
* @returns {LazyStringFileData}
*/
static fromRaw(raw) {
return new LazyStringFileData(
raw.hash,
@ -48,9 +54,16 @@ class LazyStringFileData extends FileData {
)
}
/** @inheritdoc */
/**
* @inheritdoc
* @returns {RawLazyStringFileData}
*/
toRaw() {
const raw = { hash: this.hash, stringLength: this.stringLength }
/** @type RawLazyStringFileData */
const raw = {
hash: this.hash,
stringLength: this.stringLength,
}
if (this.rangesHash) {
raw.rangesHash = this.rangesHash
}
@ -135,18 +148,26 @@ class LazyStringFileData extends FileData {
/** @inheritdoc */
async toHollow() {
// TODO(das7pad): inline 2nd path of FileData.createLazyFromBlobs?
// @ts-ignore
return FileData.createHollow(null, this.stringLength)
}
/** @inheritdoc */
/** @inheritdoc
* @param {EditOperation} operation
*/
edit(operation) {
this.stringLength = operation.applyToLength(this.stringLength)
this.operations.push(operation)
}
/** @inheritdoc */
/** @inheritdoc
* @param {BlobStore} blobStore
* @return {Promise<RawFileData>}
*/
async store(blobStore) {
if (this.operations.length === 0) {
/** @type RawFileData */
const raw = { hash: this.hash }
if (this.rangesHash) {
raw.rangesHash = this.rangesHash
@ -155,6 +176,7 @@ class LazyStringFileData extends FileData {
}
const eager = await this.toEager(blobStore)
this.operations.length = 0
/** @type RawFileData */
return await eager.store(blobStore)
}
}

View file

@ -9,6 +9,7 @@ const TrackedChangeList = require('./tracked_change_list')
/**
* @typedef {import("../types").StringFileRawData} StringFileRawData
* @typedef {import("../types").RawFileData} RawFileData
* @typedef {import("../operation/edit_operation")} EditOperation
* @typedef {import("../types").BlobStore} BlobStore
* @typedef {import("../types").CommentRawData} CommentRawData
@ -47,6 +48,7 @@ class StringFileData extends FileData {
* @returns {StringFileRawData}
*/
toRaw() {
/** @type StringFileRawData */
const raw = { content: this.content }
if (this.comments.length) {
@ -133,6 +135,7 @@ class StringFileData extends FileData {
/**
* @inheritdoc
* @param {BlobStore} blobStore
* @return {Promise<RawFileData>}
*/
async store(blobStore) {
const blob = await blobStore.putString(this.content)
@ -143,8 +146,12 @@ class StringFileData extends FileData {
trackedChanges: this.trackedChanges.toRaw(),
}
const rangesBlob = await blobStore.putObject(ranges)
// TODO(das7pad): Provide interface that guarantees hash exists?
// @ts-ignore
return { hash: blob.getHash(), rangesHash: rangesBlob.getHash() }
}
// TODO(das7pad): Provide interface that guarantees hash exists?
// @ts-ignore
return { hash: blob.getHash() }
}
}

View file

@ -1,6 +1,7 @@
// @ts-check
/**
* @typedef {import("../types").TrackingPropsRawData} TrackingPropsRawData
* @typedef {import("../types").TrackingDirective} TrackingDirective
*/
class TrackingProps {
@ -48,6 +49,10 @@ class TrackingProps {
}
}
/**
* @param {TrackingDirective} [other]
* @returns {boolean}
*/
equals(other) {
if (!(other instanceof TrackingProps)) {
return false

View file

@ -9,9 +9,18 @@ const pMap = require('p-map')
const File = require('./file')
const safePathname = require('./safe_pathname')
/**
* @typedef {import('./types').RawFile} RawFile
* @typedef {import('./types').RawFileMap} RawFileMap
* @typedef {Record<String, File | null>} FileMapData
*/
class PathnameError extends OError {}
class NonUniquePathnameError extends PathnameError {
/**
* @param {string[]} pathnames
*/
constructor(pathnames) {
super('pathnames are not unique: ' + pathnames, { pathnames })
this.pathnames = pathnames
@ -19,6 +28,9 @@ class NonUniquePathnameError extends PathnameError {
}
class BadPathnameError extends PathnameError {
/**
* @param {string} pathname
*/
constructor(pathname) {
super(pathname + ' is not a valid pathname', { pathname })
this.pathname = pathname
@ -26,6 +38,9 @@ class BadPathnameError extends PathnameError {
}
class PathnameConflictError extends PathnameError {
/**
* @param {string} pathname
*/
constructor(pathname) {
super(`pathname '${pathname}' conflicts with another file`, { pathname })
this.pathname = pathname
@ -33,6 +48,9 @@ class PathnameConflictError extends PathnameError {
}
class FileNotFoundError extends PathnameError {
/**
* @param {string} pathname
*/
constructor(pathname) {
super(`file ${pathname} does not exist`, { pathname })
this.pathname = pathname
@ -70,13 +88,17 @@ class FileMap {
constructor(files) {
// create bare object for use as Map
// http://ryanmorr.com/true-hash-maps-in-javascript/
/** @type {Record<String, File | null>} */
/** @type FileMapData */
this.files = Object.create(null)
_.assign(this.files, files)
checkPathnamesAreUnique(this.files)
checkPathnamesDoNotConflict(this)
}
/**
* @param {RawFileMap} raw
* @returns {FileMap}
*/
static fromRaw(raw) {
assert.object(raw, 'bad raw files')
return new FileMap(_.mapValues(raw, File.fromRaw))
@ -85,12 +107,18 @@ class FileMap {
/**
* Convert to raw object for serialization.
*
* @return {Object}
* @return {RawFileMap}
*/
toRaw() {
/**
* @param {File} file
* @return {RawFile}
*/
function fileToRaw(file) {
return file.toRaw()
}
// TODO(das7pad): refine types to enforce no nulls in FileMapData
// @ts-ignore
return _.mapValues(this.files, fileToRaw)
}
@ -103,6 +131,8 @@ class FileMap {
addFile(pathname, file) {
checkPathname(pathname)
assert.object(file, 'bad file')
// TODO(das7pad): make ignoredPathname argument fully optional
// @ts-ignore
checkNewPathnameDoesNotConflict(this, pathname)
addFile(this.files, pathname, file)
}
@ -163,7 +193,7 @@ class FileMap {
*/
getFile(pathname) {
const key = findPathnameKey(this.files, pathname)
return key && this.files[key]
if (key) return this.files[key]
}
/**
@ -177,7 +207,7 @@ class FileMap {
* and MoveFile overwrite existing files.)
*
* @param {string} pathname
* @param {string} [ignoredPathname] pretend this pathname does not exist
* @param {string?} ignoredPathname pretend this pathname does not exist
*/
wouldConflict(pathname, ignoredPathname) {
checkPathname(pathname)
@ -224,7 +254,7 @@ class FileMap {
/**
* Map the files in this map to new values.
* @template T
* @param {(file: File | null) => T} iteratee
* @param {(file: File | null, path: string) => T} iteratee
* @return {Record<String, T>}
*/
map(iteratee) {
@ -234,9 +264,10 @@ class FileMap {
/**
* Map the files in this map to new values asynchronously, with an optional
* limit on concurrency.
* @param {function} iteratee like for _.mapValues
* @template T
* @param {(file: File | null | undefined, path: string, pathnames: string[]) => T} iteratee
* @param {number} [concurrency]
* @return {Promise<Object>}
* @return {Promise<Record<String, T>>}
*/
async mapAsync(iteratee, concurrency) {
assert.maybe.number(concurrency, 'bad concurrency')
@ -253,32 +284,55 @@ class FileMap {
}
}
/**
* @param {string} pathname0
* @param {string?} pathname1
* @returns {boolean}
*/
function pathnamesEqual(pathname0, pathname1) {
return pathname0 === pathname1
}
/**
* @param {FileMapData} files
* @returns {boolean}
*/
function pathnamesAreUnique(files) {
const keys = _.keys(files)
return _.uniqWith(keys, pathnamesEqual).length === keys.length
}
/**
* @param {FileMapData} files
*/
function checkPathnamesAreUnique(files) {
if (pathnamesAreUnique(files)) return
throw new FileMap.NonUniquePathnameError(_.keys(files))
}
/**
* @param {string} pathname
*/
function checkPathname(pathname) {
assert.nonEmptyString(pathname, 'bad pathname')
if (safePathname.isClean(pathname)) return
throw new FileMap.BadPathnameError(pathname)
}
/**
* @param {FileMap} fileMap
* @param {string} pathname
* @param {string?} ignoredPathname
*/
function checkNewPathnameDoesNotConflict(fileMap, pathname, ignoredPathname) {
if (fileMap.wouldConflict(pathname, ignoredPathname)) {
throw new FileMap.PathnameConflictError(pathname)
}
}
/**
* @param {FileMap} fileMap
*/
function checkPathnamesDoNotConflict(fileMap) {
const pathnames = fileMap.getPathnames()
// check pathnames for validity first
@ -299,18 +353,29 @@ function checkPathnamesDoNotConflict(fileMap) {
}
}
//
// This function is somewhat vestigial: it was used when this map used
// case-insensitive pathname comparison. We could probably simplify some of the
// logic in the callers, but in the hope that we will one day return to
// case-insensitive semantics, we've just left things as-is for now.
//
/**
* This function is somewhat vestigial: it was used when this map used
* case-insensitive pathname comparison. We could probably simplify some of the
* logic in the callers, but in the hope that we will one day return to
* case-insensitive semantics, we've just left things as-is for now.
*
* TODO(das7pad): In a followup, inline this function and make types stricter.
*
* @param {FileMapData} files
* @param {string} pathname
* @returns {string | undefined}
*/
function findPathnameKey(files, pathname) {
// we can check for the key without worrying about properties
// in the prototype because we are now using a bare object/
if (pathname in files) return pathname
}
/**
* @param {FileMapData} files
* @param {string} pathname
* @param {File?} file
*/
function addFile(files, pathname, file) {
const key = findPathnameKey(files, pathname)
if (key) delete files[key]

View file

@ -1,7 +1,12 @@
// @ts-check
'use strict'
const assert = require('check-types').assert
/**
* @typedef {import('./types').RawLabel} RawLabel
*/
/**
* @classdesc
* A user-configurable label that can be attached to a specific change. Labels
@ -13,6 +18,9 @@ class Label {
/**
* @constructor
* @param {string} text
* @param {number?} authorId
* @param {Date} timestamp
* @param {number} version
*/
constructor(text, authorId, timestamp, version) {
assert.string(text, 'bad text')
@ -29,7 +37,7 @@ class Label {
/**
* Create a Label from its raw form.
*
* @param {Object} raw
* @param {RawLabel} raw
* @return {Label}
*/
static fromRaw(raw) {
@ -44,7 +52,7 @@ class Label {
/**
* Convert the Label to raw form for transmission.
*
* @return {Object}
* @return {RawLabel}
*/
toRaw() {
return {
@ -81,7 +89,7 @@ class Label {
}
/**
* @return {number | undefined}
* @return {number}
*/
getVersion() {
return this.version

View file

@ -44,6 +44,7 @@ class AddCommentOperation extends EditOperation {
* @returns {RawAddCommentOperation}
*/
toJSON() {
/** @type RawAddCommentOperation */
const raw = {
commentId: this.commentId,
ranges: this.ranges.map(range => range.toRaw()),

View file

@ -1,6 +1,10 @@
// @ts-check
'use strict'
/** @typedef {import('./edit_operation')} EditOperation */
/**
* @typedef {import('./edit_operation')} EditOperation
* @typedef {import('../types').RawEditFileOperation} RawEditFileOperation
* @typedef {import("../snapshot")} Snapshot
*/
const Operation = require('./')
const EditOperationBuilder = require('./edit_operation_builder')
@ -32,7 +36,7 @@ class EditFileOperation extends Operation {
/**
* Deserialize an EditFileOperation.
*
* @param {Object} raw
* @param {RawEditFileOperation} raw
* @return {EditFileOperation}
*/
static fromRaw(raw) {
@ -52,13 +56,18 @@ class EditFileOperation extends Operation {
/**
* @inheritdoc
* @param {Snapshot} snapshot
*/
applyTo(snapshot) {
// TODO(das7pad): can we teach typescript our polymorphism?
// @ts-ignore
snapshot.editFile(this.pathname, this.operation)
}
/**
* @inheritdoc
* @param {Operation} other
* @return {boolean}
*/
canBeComposedWithForUndo(other) {
return (
@ -69,6 +78,8 @@ class EditFileOperation extends Operation {
/**
* @inheritdoc
* @param {Operation} other
* @return {other is EditFileOperation}
*/
canBeComposedWith(other) {
// Ensure that other operation is an edit file operation
@ -81,6 +92,7 @@ class EditFileOperation extends Operation {
/**
* @inheritdoc
* @param {EditFileOperation} other
*/
compose(other) {
return new EditFileOperation(

View file

@ -1,5 +1,9 @@
const EditOperation = require('./edit_operation')
/**
* @typedef {import('../types').RawEditNoOperation} RawEditNoOperation
*/
class EditNoOperation extends EditOperation {
/**
* @inheritdoc
@ -9,7 +13,7 @@ class EditNoOperation extends EditOperation {
/**
* @inheritdoc
* @returns {object}
* @returns {RawEditNoOperation}
*/
toJSON() {
return {

View file

@ -1,6 +1,7 @@
// @ts-check
/**
* @typedef {import('../file_data')} FileData
* @typedef {import('../types').RawEditOperation} RawEditOperation
*/
class EditOperation {
@ -12,7 +13,7 @@ class EditOperation {
/**
* Converts operation into a JSON value.
* @returns {object}
* @returns {RawEditOperation}
*/
toJSON() {
throw new Error('Abstract method not implemented')

View file

@ -142,7 +142,9 @@ class InsertOp extends ScanOp {
return current
}
/** @inheritdoc */
/** @inheritdoc
* @param {ScanOp} other
*/
equals(other) {
if (!(other instanceof InsertOp)) {
return false
@ -167,6 +169,10 @@ class InsertOp extends ScanOp {
return !other.commentIds
}
/**
* @param {ScanOp} other
* @return {other is InsertOp}
*/
canMergeWith(other) {
if (!(other instanceof InsertOp)) {
return false
@ -187,6 +193,9 @@ class InsertOp extends ScanOp {
return !other.commentIds
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')
@ -202,6 +211,7 @@ class InsertOp extends ScanOp {
if (!this.tracking && !this.commentIds) {
return this.insertion
}
/** @type RawInsertOp */
const obj = { i: this.insertion }
if (this.tracking) {
obj.tracking = this.tracking.toRaw()
@ -274,7 +284,9 @@ class RetainOp extends ScanOp {
return new RetainOp(op.r)
}
/** @inheritdoc */
/** @inheritdoc
* @param {ScanOp} other
*/
equals(other) {
if (!(other instanceof RetainOp)) {
return false
@ -288,6 +300,10 @@ class RetainOp extends ScanOp {
return !other.tracking
}
/**
* @param {ScanOp} other
* @return {other is RetainOp}
*/
canMergeWith(other) {
if (!(other instanceof RetainOp)) {
return false
@ -298,6 +314,9 @@ class RetainOp extends ScanOp {
return !other.tracking
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')
@ -321,6 +340,9 @@ class RetainOp extends ScanOp {
}
class RemoveOp extends ScanOp {
/**
* @param {number} length
*/
constructor(length) {
super()
if (length < 0) {
@ -352,7 +374,11 @@ class RemoveOp extends ScanOp {
return new RemoveOp(-op)
}
/** @inheritdoc */
/**
* @inheritdoc
* @param {ScanOp} other
* @return {boolean}
*/
equals(other) {
if (!(other instanceof RemoveOp)) {
return false
@ -360,10 +386,17 @@ class RemoveOp extends ScanOp {
return this.length === other.length
}
/**
* @param {ScanOp} other
* @return {other is RemoveOp}
*/
canMergeWith(other) {
return other instanceof RemoveOp
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')

View file

@ -48,7 +48,7 @@ class SetCommentStateOperation extends EditOperation {
}
/**
*
* @param {StringFileData} previousState
* @returns {SetCommentStateOperation | EditNoOperation}
*/
invert(previousState) {
@ -77,7 +77,7 @@ class SetCommentStateOperation extends EditOperation {
/**
* @inheritdoc
* @param {EditOperation} other
* @returns {EditOperation}
* @returns {SetCommentStateOperation | core.DeleteCommentOperation}
*/
compose(other) {
if (

View file

@ -35,6 +35,7 @@ const TrackingProps = require('../file_data/tracking_props')
* @typedef {import('../operation/scan_op').ScanOp} ScanOp
* @typedef {import('../file_data/tracked_change_list')} TrackedChangeList
* @typedef {import('../types').TrackingDirective} TrackingDirective
* @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
*/
/**
@ -69,6 +70,10 @@ class TextOperation extends EditOperation {
this.targetLength = 0
}
/**
* @param {TextOperation} other
* @return {boolean}
*/
equals(other) {
if (this.baseLength !== other.baseLength) {
return false
@ -129,7 +134,7 @@ class TextOperation extends EditOperation {
/**
* Insert a string at the current position.
* @param {string | {i: string}} insertValue
* @param {{tracking?: TrackingProps, commentIds?: string[]}} opts
* @param {InsertOptions} opts
* @returns {TextOperation}
*/
insert(insertValue, opts = {}) {
@ -328,6 +333,8 @@ class TextOperation extends EditOperation {
/**
* @inheritdoc
* @param {number} length of the original string; non-negative
* @return {number} length of the new string; non-negative
*/
applyToLength(length) {
const operation = this
@ -573,6 +580,7 @@ class TextOperation extends EditOperation {
op1 = ops1[i1++]
}
} else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
/** @type InsertOptions */
const opts = {
commentIds: op1.commentIds,
}
@ -807,6 +815,10 @@ function getSimpleOp(operation) {
return null
}
/**
* @param {TextOperation} operation
* @return {number}
*/
function getStartIndex(operation) {
if (operation.ops[0] instanceof RetainOp) {
return operation.ops[0].length
@ -843,7 +855,10 @@ function calculateTrackingCommentSegments(
const breaks = new Set()
const opStart = cursor
const opEnd = cursor + length
// Utility function to limit breaks to the boundary set by the operation range
/**
* Utility function to limit breaks to the boundary set by the operation range
* @param {number} rangeBoundary
*/
function addBreak(rangeBoundary) {
if (rangeBoundary < opStart || rangeBoundary > opEnd) {
return

View file

@ -2,6 +2,10 @@
const OError = require('@overleaf/o-error')
/**
* @typedef {import('./types').RawRange} RawRange
*/
class Range {
/**
* @param {number} pos
@ -17,10 +21,16 @@ class Range {
this.length = length
}
/**
* @return {number}
*/
get start() {
return this.pos
}
/**
* @return {number}
*/
get end() {
return this.pos + this.length
}
@ -193,6 +203,10 @@ class Range {
}
}
/**
* @param {RawRange} raw
* @return {Range}
*/
static fromRaw(raw) {
return new Range(raw.pos, raw.length)
}

View file

@ -1,4 +1,4 @@
/** @module */
// @ts-check
'use strict'
const path = require('path-browserify')
@ -39,6 +39,7 @@ const MAX_PATH = 1024
/**
* Replace invalid characters and filename patterns in a filename with
* underscores.
* @param {string} filename
*/
function cleanPart(filename) {
filename = filename.replace(BAD_CHAR_RX, '_')

View file

@ -1,3 +1,4 @@
// @ts-check
'use strict'
const assert = require('check-types').assert
@ -10,9 +11,11 @@ const FILE_LOAD_CONCURRENCY = 50
/**
* @typedef {import("./types").BlobStore} BlobStore
* @typedef {import("./types").RawSnapshot} RawSnapshot
* @typedef {import("./types").ReadonlyBlobStore} ReadonlyBlobStore
* @typedef {import("./change")} Change
* @typedef {import("./operation/text_operation")} TextOperation
* @typedef {import("./file")} File
*/
class EditMissingFileError extends OError {}
@ -26,6 +29,10 @@ class Snapshot {
static PROJECT_VERSION_RX = new RegExp(Snapshot.PROJECT_VERSION_RX_STRING)
static EditMissingFileError = EditMissingFileError
/**
* @param {RawSnapshot} raw
* @return {Snapshot}
*/
static fromRaw(raw) {
assert.object(raw.files, 'bad raw.files')
return new Snapshot(
@ -36,6 +43,7 @@ class Snapshot {
}
toRaw() {
/** @type RawSnapshot */
const raw = {
files: this.fileMap.toRaw(),
}
@ -64,6 +72,9 @@ class Snapshot {
return this.projectVersion
}
/**
* @param {string} projectVersion
*/
setProjectVersion(projectVersion) {
assert.maybe.match(
projectVersion,
@ -80,6 +91,9 @@ class Snapshot {
return this.v2DocVersions
}
/**
* @param {V2DocVersions} v2DocVersions
*/
setV2DocVersions(v2DocVersions) {
assert.maybe.instance(
v2DocVersions,
@ -89,6 +103,9 @@ class Snapshot {
this.v2DocVersions = v2DocVersions
}
/**
* @param {V2DocVersions} v2DocVersions
*/
updateV2DocVersions(v2DocVersions) {
// merge new v2DocVersions into this.v2DocVersions
v2DocVersions.applyTo(this)
@ -114,6 +131,7 @@ class Snapshot {
/**
* Get a File by its pathname.
* @see FileMap#getFile
* @param {string} pathname
*/
getFile(pathname) {
return this.fileMap.getFile(pathname)
@ -122,6 +140,8 @@ class Snapshot {
/**
* Add the given file to the snapshot.
* @see FileMap#addFile
* @param {string} pathname
* @param {File} file
*/
addFile(pathname, file) {
this.fileMap.addFile(pathname, file)
@ -130,6 +150,8 @@ class Snapshot {
/**
* Move or remove a file.
* @see FileMap#moveFile
* @param {string} pathname
* @param {string} newPathname
*/
moveFile(pathname, newPathname) {
this.fileMap.moveFile(pathname, newPathname)
@ -185,13 +207,18 @@ class Snapshot {
* @param {Set.<String>} blobHashes
*/
findBlobHashes(blobHashes) {
// eslint-disable-next-line array-callback-return
this.fileMap.map(file => {
/**
* @param {File} file
*/
function find(file) {
const hash = file.getHash()
const rangeHash = file.getRangesHash()
if (hash) blobHashes.add(hash)
if (rangeHash) blobHashes.add(rangeHash)
})
}
// TODO(das7pad): refine types to enforce no nulls in FileMapData
// @ts-ignore
this.fileMap.map(find)
}
/**
@ -203,10 +230,15 @@ class Snapshot {
* values are the files in the snapshot
*/
async loadFiles(kind, blobStore) {
return await this.fileMap.mapAsync(
file => file.load(kind, blobStore),
FILE_LOAD_CONCURRENCY
)
/**
* @param {File} file
*/
function load(file) {
return file.load(kind, blobStore)
}
// TODO(das7pad): refine types to enforce no nulls in FileMapData
// @ts-ignore
return await this.fileMap.mapAsync(load, FILE_LOAD_CONCURRENCY)
}
/**
@ -224,10 +256,16 @@ class Snapshot {
const rawV2DocVersions = this.v2DocVersions
? this.v2DocVersions.toRaw()
: undefined
const rawFiles = await this.fileMap.mapAsync(
file => file.store(blobStore),
concurrency
)
/**
* @param {File} file
*/
function store(file) {
return file.store(blobStore)
}
// TODO(das7pad): refine types to enforce no nulls in FileMapData
// @ts-ignore
const rawFiles = await this.fileMap.mapAsync(store, concurrency)
return {
files: rawFiles,
projectVersion,

View file

@ -17,19 +17,19 @@ export type RangesBlob = {
trackedChanges: TrackedChangeRawData[]
}
type Range = {
export type RawRange = {
pos: number
length: number
}
export type CommentRawData = {
id: string
ranges: Range[]
ranges: RawRange[]
resolved?: boolean
}
export type TrackedChangeRawData = {
range: Range
range: RawRange
tracking: TrackingPropsRawData
}
@ -51,6 +51,35 @@ export type StringFileRawData = {
trackedChanges?: TrackedChangeRawData[]
}
export type RawSnapshot = {
files: RawFileMap
projectVersion?: string
v2DocVersions?: RawV2DocVersions | null
}
export type RawFileMap = Record<string, RawFile>
export type RawFile = { metadata?: Object } & RawFileData
export type RawFileData =
| RawBinaryFileData
| RawHashFileData
| RawHollowBinaryFileData
| RawHollowStringFileData
| RawLazyStringFileData
| StringFileRawData
export type RawHashFileData = { hash: string; rangesHash?: string }
export type RawBinaryFileData = { hash: string; byteLength: number }
export type RawLazyStringFileData = {
hash: string
stringLength: number
rangesHash?: string
operations?: RawEditOperation[]
}
export type RawHollowBinaryFileData = { byteLength: number }
export type RawHollowStringFileData = { stringLength: number }
export type RawV2DocVersions = Record<string, { pathname: string; v: number }>
export type RawInsertOp =
@ -78,7 +107,7 @@ export type RawTextOperation = {
export type RawAddCommentOperation = {
commentId: string
ranges: Range[]
ranges: RawRange[]
resolved?: boolean
}
@ -89,14 +118,28 @@ export type RawSetCommentStateOperation = {
resolved: boolean
}
export type RawEditNoOperation = {
noOp: true
}
export type RawEditFileOperation = RawEditOperation & { pathname: string }
export type RawEditOperation =
| RawTextOperation
| RawAddCommentOperation
| RawDeleteCommentOperation
| RawSetCommentStateOperation
| RawEditNoOperation
export type LinkedFileData = {
importedAt: string
provider: string
[other: string]: any
}
export type RawLabel = {
text: string
authorId: number | null
timestamp: string
version: number
}

View file

@ -1,9 +1,11 @@
// @ts-check
'use strict'
const _ = require('lodash')
/**
* @typedef {import("./file")} File
* @typedef {import("./snapshot")} Snapshot
* @typedef {import("./types").RawV2DocVersions} RawV2DocVersions
*/
@ -15,13 +17,17 @@ class V2DocVersions {
this.data = data || {}
}
/**
* @param {RawV2DocVersions?} [raw]
* @return {V2DocVersions|undefined}
*/
static fromRaw(raw) {
if (!raw) return undefined
return new V2DocVersions(raw)
}
/**
* @abstract
* @return {?RawV2DocVersions}
*/
toRaw() {
if (!this.data) return null
@ -32,12 +38,15 @@ class V2DocVersions {
/**
* Clone this object.
*
* @return {V2DocVersions} a new object of the same type
* @return {V2DocVersions|undefined} a new object of the same type
*/
clone() {
return V2DocVersions.fromRaw(this.toRaw())
}
/**
* @param {Snapshot} snapshot
*/
applyTo(snapshot) {
// Only update the snapshot versions if we have new versions
if (!_.size(this.data)) return
@ -54,6 +63,8 @@ class V2DocVersions {
/**
* Move or remove a doc.
* Must be called after FileMap#moveFile, which validates the paths.
* @param {string} pathname
* @param {string} newPathname
*/
moveFile(pathname, newPathname) {
for (const [id, v] of Object.entries(this.data)) {

View file

@ -5,10 +5,10 @@
"main": "index.js",
"scripts": {
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"format": "prettier --list-different $PWD/'**/*.{js,cjs}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs}'",
"lint": "eslint --ext .js --ext .cjs --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .js --ext .cjs .",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
"test:ci": "npm run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
@ -17,6 +17,8 @@
"license": "Proprietary",
"private": true,
"devDependencies": {
"@types/check-types": "^7.3.7",
"@types/path-browserify": "^1.0.2",
"chai": "^3.3.0",
"mocha": "^10.2.0",
"sinon": "^9.2.4",

30
package-lock.json generated
View file

@ -397,6 +397,8 @@
"path-browserify": "^1.0.1"
},
"devDependencies": {
"@types/check-types": "^7.3.7",
"@types/path-browserify": "^1.0.2",
"chai": "^3.3.0",
"mocha": "^10.2.0",
"sinon": "^9.2.4",
@ -12532,6 +12534,12 @@
"@types/chai": "*"
}
},
"node_modules/@types/check-types": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/@types/check-types/-/check-types-7.3.7.tgz",
"integrity": "sha512-ZNAGaVc/joAV3lAuRwPdsQY/caU1RvKoa+U7i/TkYIlOStdYq4vyArFnA1zItfEDkHpXNWApWIqqbp5fsHAiRg==",
"dev": true
},
"node_modules/@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@ -13015,6 +13023,12 @@
"@types/passport": "*"
}
},
"node_modules/@types/path-browserify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.2.tgz",
"integrity": "sha512-ZkC5IUqqIFPXx3ASTTybTzmQdwHwe2C0u3eL75ldQ6T9E9IWFJodn6hIfbZGab73DfyiHN4Xw15gNxUq2FbvBA==",
"dev": true
},
"node_modules/@types/pg": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
@ -44847,6 +44861,7 @@
"mock-fs": "^5.1.2",
"node-fetch": "^2.6.7",
"nvd3": "^1.8.6",
"overleaf-editor-core": "*",
"pdfjs-dist213": "npm:pdfjs-dist@2.13.216",
"pdfjs-dist401": "npm:pdfjs-dist@4.5.136",
"pirates": "^4.0.1",
@ -53363,6 +53378,7 @@
"nvd3": "^1.8.6",
"on-headers": "^1.0.2",
"otplib": "^12.0.1",
"overleaf-editor-core": "*",
"p-limit": "^2.3.0",
"p-props": "4.0.0",
"parse-data-url": "^2.0.0",
@ -57422,6 +57438,12 @@
"@types/chai": "*"
}
},
"@types/check-types": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/@types/check-types/-/check-types-7.3.7.tgz",
"integrity": "sha512-ZNAGaVc/joAV3lAuRwPdsQY/caU1RvKoa+U7i/TkYIlOStdYq4vyArFnA1zItfEDkHpXNWApWIqqbp5fsHAiRg==",
"dev": true
},
"@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@ -57904,6 +57926,12 @@
"@types/passport": "*"
}
},
"@types/path-browserify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.2.tgz",
"integrity": "sha512-ZkC5IUqqIFPXx3ASTTybTzmQdwHwe2C0u3eL75ldQ6T9E9IWFJodn6hIfbZGab73DfyiHN4Xw15gNxUq2FbvBA==",
"dev": true
},
"@types/pg": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
@ -72798,6 +72826,8 @@
"version": "file:libraries/overleaf-editor-core",
"requires": {
"@overleaf/o-error": "*",
"@types/check-types": "^7.3.7",
"@types/path-browserify": "^1.0.2",
"chai": "^3.3.0",
"check-types": "^5.1.0",
"lodash": "^4.17.19",

View file

@ -296,7 +296,7 @@ class BlobStore {
* Read a blob metadata record by hexadecimal hash.
*
* @param {string} hash hexadecimal SHA-1 hash
* @return {Promise.<core.Blob?>}
* @return {Promise<core.Blob | null>}
*/
async getBlob(hash) {
assert.blobHash(hash, 'bad hash')

View file

@ -0,0 +1 @@
import 'overleaf-editor-core'

View file

@ -320,6 +320,7 @@
"mock-fs": "^5.1.2",
"node-fetch": "^2.6.7",
"nvd3": "^1.8.6",
"overleaf-editor-core": "*",
"pdfjs-dist213": "npm:pdfjs-dist@2.13.216",
"pdfjs-dist401": "npm:pdfjs-dist@4.5.136",
"pirates": "^4.0.1",