Files
overleaf-cep/libraries/overleaf-editor-core/lib/change_identity.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

109 lines
3.8 KiB
JavaScript

// @ts-check
'use strict'
const { EDITOR_ORIGIN_KIND } = require('./origin')
/**
* @import { RawChange } from "./types"
*/
/**
* Identifying a change an editor client submitted.
*
* history-v1 uses this to recognise a change a client is resending, so as not to
* apply the same operations twice; the client uses it to recognise its own change
* coming back on the change stream, so as not to apply it a second time locally.
* Both sides have to agree exactly — if one of them compares a field the other
* does not, they disagree about what is a duplicate, and a change is either
* applied twice or discarded. Neither reports an error. So the key lives here
* rather than being spelled out in each service.
*
* The fields are the ones a rebase leaves alone: transforming a change rewrites
* its operations, so those cannot identify it, while the origin, the author and
* the timestamp travel through history untouched.
*
* Each rules out a different false match:
*
* - the origin kind keeps other writers out, so a Dropbox sync or a resync is
* never mistaken for a client's change.
* - `editorId` separates editors that share an author. Nothing coordinates
* timestamps between two tabs of one user, or between anonymous editors, who
* share an empty author — without it, two clients colliding on a millisecond
* look identical.
* - the author makes a forged `editorId` unusable: it arrives from the client, but
* a change belonging to another account can never match.
* - the timestamp separates successive changes from one editor, which the client
* keeps distinct and stable across resends.
*
* @typedef {{editorId: string, author: (string|null), timestamp: number}} EditorChangeIdentity
*/
/**
* The identity of a raw change, or null if it is not an identifiable editor
* change — another writer's, or one whose `editorId` was dropped when its chunk
* was written.
*
* The timestamp is compared as a point in time rather than as a string, so that
* two spellings of the same instant cannot read as different changes.
*
* @param {RawChange} [raw]
* @return {EditorChangeIdentity | null}
*/
function editorChangeIdentity(raw) {
const origin = raw?.origin
if (!origin || origin.kind !== EDITOR_ORIGIN_KIND) return null
// The origin union's catch-all variant is `{kind: string}`, which also admits
// this kind, so checking the kind cannot narrow to the variant carrying the id.
const { editorId } = /** @type {{editorId?: string}} */ (origin)
if (!editorId) return null
// real-time stamps exactly one author on every change it forwards, so anything
// else did not come from this path.
const authors = raw.v2Authors
if (!Array.isArray(authors) || authors.length !== 1) return null
const timestamp = new Date(raw.timestamp).getTime()
if (Number.isNaN(timestamp)) return null
return { editorId, author: authors[0] ?? null, timestamp }
}
/**
* Build an identity from a client's own record of a change it submitted, for
* comparing against what comes back from history.
*
* @param {Object} params
* @param {string} params.editorId
* @param {string | null} params.author
* @param {Date} params.timestamp
* @return {EditorChangeIdentity}
*/
function editorChangeIdentityOf({ editorId, author, timestamp }) {
return { editorId, author: author ?? null, timestamp: timestamp.getTime() }
}
/**
* Whether two identities name the same change. A null identity matches nothing,
* including another null.
*
* @param {EditorChangeIdentity | null} [a]
* @param {EditorChangeIdentity | null} [b]
* @return {boolean}
*/
function isSameEditorChange(a, b) {
if (!a || !b) return false
return (
a.editorId === b.editorId &&
a.author === b.author &&
a.timestamp === b.timestamp
)
}
module.exports = {
editorChangeIdentity,
editorChangeIdentityOf,
isSameEditorChange,
}