Files
overleaf-cep/libraries/overleaf-editor-core/lib/schemas.js
T
Jakob Ackermann 95e3444a6e [editor-core] refine zod schemas for rawTextOperation and rawFileMetadata (#36253)
* [editor-core] gracefully handle no-op TextOperation

* [editor-core] add support for legacy url imports

* [editor-core] reorder rawFileMetadata entries

GitOrigin-RevId: f5c3158def00183d4a60a5b934abd921716e2339
2026-09-17 11:48:47 +00:00

346 lines
9.0 KiB
JavaScript

// @ts-check
'use strict'
const { z, zz } = require('@overleaf/validation-tools')
const rawTrackingProps = z.strictObject({
type: z.enum(['insert', 'delete']),
userId: zz.objectId(),
ts: z.iso.datetime(),
})
const rawClearTrackingProps = z.strictObject({
type: z.literal('none'),
})
const rawInsertOp = z.union([
z.strictObject({
i: z.string(),
commentIds: z.array(zz.objectId()).optional(),
tracking: rawTrackingProps.optional(),
}),
z.string(),
])
const rawRemoveOp = z.number().int().max(-1)
const rawRetainOp = z.union([
z.strictObject({
r: z.number().int().min(1),
commentIds: z.array(zz.objectId()).optional(),
tracking: z
.discriminatedUnion('type', [rawTrackingProps, rawClearTrackingProps])
.optional(),
}),
z.number().int().min(1),
])
const rawScanOp = z.union([rawInsertOp, rawRemoveOp, rawRetainOp])
const rawTextOperation = z.strictObject({
textOperation: z.array(rawScanOp),
contentHash: z.string().optional(),
})
const rawRange = z.strictObject({
pos: z.number().int().min(0),
length: z.number().int().min(0),
})
const rawAddCommentOperation = z.strictObject({
commentId: zz.objectId(),
ranges: z.array(rawRange),
resolved: z.boolean().optional(),
})
const rawSetCommentStateOperation = z.strictObject({
commentId: zz.objectId(),
resolved: z.boolean(),
})
const rawDeleteCommentOperation = z.strictObject({
deleteComment: zz.objectId(),
})
const rawEditNoOperation = z.strictObject({
noOp: z.literal(true),
})
const rawEditOperation = z.union([
rawTextOperation,
rawAddCommentOperation,
rawDeleteCommentOperation,
rawSetCommentStateOperation,
rawEditNoOperation,
])
const rawComment = z.strictObject({
id: zz.objectId(),
ranges: z.array(rawRange),
resolved: z.boolean().optional(),
})
const rawTrackedChange = z.strictObject({
range: rawRange,
tracking: rawTrackingProps,
})
const rawStringFileData = z.strictObject({
content: z.string(),
comments: z.array(rawComment).optional(),
trackedChanges: z.array(rawTrackedChange).optional(),
})
// linkedFileData as written by the web linked-file agents (all-scalar; see
// the per-provider _sanitizeData implementations in web). importedAt is
// always written on create/refresh but may be absent on very old documents.
const rawLinkedFileData = z.discriminatedUnion('provider', [
z.strictObject({
provider: z.literal('url'),
url: z.string(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('project_file'),
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.number().optional(),
source_entity_path: z.string(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('project_output_file'),
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.number().optional(),
source_output_file_path: z.string(),
build_id: zz.buildId().optional(),
clsiServerId: z.string().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('mendeley'),
// null, not just absent, for an import from a personal library: the web
// agents always build the data object with a group_id key, so no group
// means `group_id: undefined`, and mongoose keeps that key when $set-ing
// the Mixed linkedFileData (upserting over an existing file), where the
// driver serializes it as null. Same for the zotero/papers branches.
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('zotero'),
// absent on files imported before the `format` field existed; agents
// default this to 'bibtex' (ZoteroAgent `_getFormat`)
format: z.enum(['bibtex', 'biblatex']).optional(),
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('papers'),
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
])
const rawBlobHash = z.string().regex(/^[0-9a-f]{40}$/, {
message: 'invalid blob hash',
})
const rawFileMetadata = z.union([
// doc/clear metadata
z.strictObject({}),
// regular file v2
z.strictObject({
importedAt: z.iso.datetime(),
}),
// linked-file v2
rawLinkedFileData,
// main-file v1
z.strictObject({
main: z.boolean(),
importedAt: z.iso.datetime().optional(),
}),
// url import v1
z.strictObject({
agent: z.literal('url'),
agentDataId: z.number(),
importedAt: z.iso.datetime().optional(),
}),
])
const rawHashFileData = z.strictObject({
hash: rawBlobHash,
rangesHash: rawBlobHash.optional(),
})
const rawBinaryFileData = z.strictObject({
hash: rawBlobHash,
byteLength: z.number().int().min(0),
})
const rawLazyStringFileData = z.strictObject({
hash: rawBlobHash,
stringLength: z.number().int().min(0),
rangesHash: rawBlobHash.optional(),
operations: z.array(rawEditOperation).optional(),
})
const rawHollowBinaryFileData = z.strictObject({
byteLength: z.number().int().min(0),
})
const rawHollowStringFileData = z.strictObject({
stringLength: z.number().int().min(0),
})
const rawFileData = z.union([
rawBinaryFileData,
rawHashFileData,
rawHollowBinaryFileData,
rawHollowStringFileData,
rawLazyStringFileData,
rawStringFileData,
])
// the strict variants disambiguate the union by their key sets
const rawFile = z.union([
rawBinaryFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHashFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHollowBinaryFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHollowStringFileData.extend({ metadata: rawFileMetadata.optional() }),
rawLazyStringFileData.extend({ metadata: rawFileMetadata.optional() }),
rawStringFileData.extend({ metadata: rawFileMetadata.optional() }),
])
const rawFileMap = z.record(z.string(), rawFile)
const rawV2DocVersions = z.record(
z.string(),
z.strictObject({
pathname: z.string(),
v: z.number().int(),
})
)
const rawSnapshot = z.strictObject({
files: rawFileMap,
projectVersion: z.string().optional(),
v2DocVersions: rawV2DocVersions.nullish(),
timestamp: z.iso.datetime().optional(),
})
// mirrors Origin.fromRaw: three restore variants plus a generic kind
const rawOrigin = z.union([
z.strictObject({
kind: z.literal('restore'),
version: z.number().int(),
timestamp: z.iso.datetime(),
}),
z.strictObject({
kind: z.literal('file-restore'),
version: z.number().int(),
path: z.string(),
timestamp: z.iso.datetime(),
}),
z.strictObject({
kind: z.literal('project-restore'),
version: z.number().int(),
timestamp: z.iso.datetime(),
}),
z.strictObject({
kind: z.string(),
}),
])
// mirrors Operation.fromRaw: add file / edit file / move (or delete) file /
// set file metadata / no-op
const rawAddFileOperation = z.strictObject({
pathname: z.string(),
file: rawFile,
})
const rawMoveFileOperation = z.strictObject({
pathname: z.string(),
newPathname: z.string(),
})
const rawEditFileOperation = z.union([
rawTextOperation.extend({ pathname: z.string() }),
rawAddCommentOperation.extend({ pathname: z.string() }),
rawDeleteCommentOperation.extend({ pathname: z.string() }),
rawSetCommentStateOperation.extend({ pathname: z.string() }),
rawEditNoOperation.extend({ pathname: z.string() }),
])
const rawSetFileMetadataOperation = z.strictObject({
pathname: z.string(),
metadata: rawFileMetadata,
})
const rawNoOperation = z.strictObject({})
const rawOperation = z.union([
rawAddFileOperation,
rawEditFileOperation,
rawMoveFileOperation,
rawSetFileMetadataOperation,
rawNoOperation,
])
const rawChange = z.strictObject({
operations: z.array(rawOperation),
timestamp: z.iso.datetime(),
// null represents an anonymous author
authors: z.array(z.number().int().nullable()).optional(),
v2Authors: z.array(zz.objectId().nullable()).optional(),
origin: rawOrigin.optional(),
projectVersion: z.string().optional(),
v2DocVersions: rawV2DocVersions.optional(),
})
module.exports = {
rawTrackingProps,
rawClearTrackingProps,
rawInsertOp,
rawRemoveOp,
rawRetainOp,
rawScanOp,
rawTextOperation,
rawRange,
rawAddCommentOperation,
rawSetCommentStateOperation,
rawDeleteCommentOperation,
rawEditNoOperation,
rawEditOperation,
rawComment,
rawTrackedChange,
rawStringFileData,
rawLinkedFileData,
rawBlobHash,
rawFileMetadata,
rawHashFileData,
rawBinaryFileData,
rawLazyStringFileData,
rawHollowBinaryFileData,
rawHollowStringFileData,
rawFileData,
rawFile,
rawFileMap,
rawV2DocVersions,
rawSnapshot,
rawOrigin,
rawAddFileOperation,
rawMoveFileOperation,
rawEditFileOperation,
rawSetFileMetadataOperation,
rawNoOperation,
rawOperation,
rawChange,
}