Files
overleaf-cep/libraries/overleaf-editor-core/lib/schemas.js
T
Jakob AckermannandClaude Opus 5 7ae8db6b12 [editor-core] extract shared pieces for history-ot in editor (#36828)
* [editor-core] share the pieces the editor, real-time and history-v1 all need

Project-level history-OT has the browser, real-time and history-v1 all reason
about the same changes, so the rules they reason with move into the shared
library rather than being reimplemented on each side.

- `origin/editor_origin.js` plus `EDITOR_ORIGIN_KIND` and its schema entry: the
  origin an editor client's change carries, holding the editorId that scopes a
  change's timestamp.
- `change_identity.js`: how a submission is recognised when it comes back, so a
  resend of a change that already landed is deduplicated rather than applied
  twice.
- `rebase.js`: transforming a change onto concurrent ones, in the same order on
  both sides, which is what lets the server acknowledge a change with only the
  version it landed at.
- `text_file_defaults.js`: the doc-vs-file extension lists, now read from here
  by web's settings instead of being spelled out again.
- `blob_string_length.js`: the string length of a blob, used by history-v1's
  blob store and by web when it writes an uploaded file into history.
- `file_tree_diff.js`: file-tree operations to a diff, used by
  project-history's FileTreeDiffGenerator.
- `Blob.EMPTY_HASH`, so callers can answer for the empty blob without storing
  it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [project-history] build the file-tree diff with the shared helper

FileTreeDiffGenerator's hand-rolled walk over add/edit/move/delete operations
becomes a call to `buildFileTreeDiff` from overleaf-editor-core, so the editor
and project-history read a file-tree change the same way. Adds the unit tests
the decaffeinated original never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [web] read the editable-file lists from overleaf-editor-core

web's settings spelled out the text extensions and editable filenames that
decide whether a file becomes a doc, and every other service that has to make
that call carried its own copy. The lists now live in overleaf-editor-core and
web builds settings.textExtensions and settings.editableFilenames from them,
keeping the ADDITIONAL_TEXT_EXTENSIONS override for Server CE/Pro on top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [history-v1] read a blob's string length from overleaf-editor-core

Two copies of getStringLengthOfFile lived here, the one in recover_zip.js
carrying a TODO asking for exactly this. Both now import the shared
implementation, which the blob store keeps re-exporting for backupArchiver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
GitOrigin-RevId: 31948332f416f56848e3b0dfb5142a4c9f93f508
2026-09-17 12:06:17 +00:00

359 lines
9.6 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(),
// written by the linked-file agents until 2018 (removed in ebe828aa625)
// and never since; still present in the history of projects that linked a
// file before then, so it has to be tolerated when replaying changes.
source_project_display_name: z.string().optional(),
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(),
compileGroup: zz.compileGroup().optional(),
clsiServerId: zz.clsiServerId().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(),
}),
// legacy v1 linked file
z.strictObject({
agent: z.string(),
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(),
}),
// An editor change that still carries the id of the editor instance it came
// from. Once a chunk is written the id is dropped from all but that editor's
// latest change, leaving a bare {kind: 'editor'} that the catch-all below
// accepts.
z.strictObject({
kind: z.literal('editor'),
editorId: z.uuid(),
}),
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,
}