first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import type { Nullable } from '../../../../../types/utils'
import type { FileDiff } from '../services/types/file'
import type { FileOperation } from '../services/types/file-operation'
import type { LoadedUpdate, Version } from '../services/types/update'
import type { Selection } from '../services/types/selection'
import { fileFinalPathname, isFileEditable } from './file-diff'
type FileWithOps = {
pathname: FileDiff['pathname']
editable: boolean
operation: FileOperation
}
function getFilesWithOps(
files: FileDiff[],
toV: Version,
comparing: boolean,
updateForToV: LoadedUpdate | undefined
): FileWithOps[] {
if (toV && !comparing) {
const filesWithOps: FileWithOps[] = []
if (updateForToV) {
const filesByPathname = new Map<string, FileDiff>()
for (const file of files) {
const pathname = fileFinalPathname(file)
filesByPathname.set(pathname, file)
}
const isEditable = (pathname: string) => {
const fileDiff = filesByPathname.get(pathname)
if (!fileDiff) {
return false
}
return isFileEditable(fileDiff)
}
for (const pathname of updateForToV.pathnames) {
filesWithOps.push({
pathname,
editable: isEditable(pathname),
operation: 'edited',
})
}
for (const op of updateForToV.project_ops) {
let pathAndOp: Nullable<Pick<FileWithOps, 'pathname' | 'operation'>> =
null
if (op.add) {
pathAndOp = {
pathname: op.add.pathname,
operation: 'added',
}
} else if (op.remove) {
pathAndOp = {
pathname: op.remove.pathname,
operation: 'removed',
}
} else if (op.rename) {
pathAndOp = {
pathname: op.rename.newPathname,
operation: 'renamed',
}
}
if (pathAndOp !== null) {
filesWithOps.push({
editable: isEditable(pathAndOp.pathname),
...pathAndOp,
})
}
}
}
return filesWithOps
} else {
const filesWithOps = files.reduce(
(curFilesWithOps, file) => {
if ('operation' in file) {
curFilesWithOps.push({
pathname: file.pathname,
editable: isFileEditable(file),
operation: file.operation,
})
}
return curFilesWithOps
},
<FileWithOps[]>[]
)
return filesWithOps
}
}
const orderedOpTypes: FileOperation[] = [
'edited',
'added',
'renamed',
'removed',
]
export function autoSelectFile(
files: FileDiff[],
toV: Version,
comparing: boolean,
updateForToV: LoadedUpdate | undefined,
previouslySelectedPathname: Selection['previouslySelectedPathname']
): FileDiff {
const filesWithOps = getFilesWithOps(files, toV, comparing, updateForToV)
const previouslySelectedFile = files.find(file => {
return file.pathname === previouslySelectedPathname
})
const previouslySelectedFileHasOp = filesWithOps.some(file => {
return file.pathname === previouslySelectedPathname
})
if (previouslySelectedFile && previouslySelectedFileHasOp) {
return previouslySelectedFile
}
for (const opType of orderedOpTypes) {
const fileWithMatchingOpType = filesWithOps.find(
file => file.operation === opType && file.editable
)
if (fileWithMatchingOpType) {
const fileToSelect = files.find(
file => fileFinalPathname(file) === fileWithMatchingOpType.pathname
)
if (fileToSelect) {
return fileToSelect
}
}
}
return (
previouslySelectedFile ||
files.find(file => /main\.tex$/.test(file.pathname)) ||
files.find(file => /\.tex$/.test(file.pathname)) ||
files[0]
)
}

View File

@@ -0,0 +1,22 @@
import { User } from '@/features/history/services/types/shared'
import getMeta from '@/utils/meta'
import { formatUserName } from '@/features/history/utils/history-details'
export default function displayNameForUser(
user:
| (User & {
name?: string
})
| null
) {
if (user == null) {
return 'Anonymous'
}
if (user.id === getMeta('ol-user').id) {
return 'you'
}
if (user.name != null) {
return user.name
}
return formatUserName(user)
}

View File

@@ -0,0 +1,30 @@
import type {
FileDiff,
FileRemoved,
FileRenamed,
FileWithEditable,
} from '../services/types/file'
export function isFileRenamed(fileDiff: FileDiff): fileDiff is FileRenamed {
return (fileDiff as FileRenamed).operation === 'renamed'
}
export function isFileRemoved(fileDiff: FileDiff): fileDiff is FileRemoved {
return (fileDiff as FileRemoved).operation === 'removed'
}
function isFileWithEditable(fileDiff: FileDiff): fileDiff is FileWithEditable {
return 'editable' in (fileDiff as FileWithEditable)
}
export function isFileEditable(fileDiff: FileDiff) {
return isFileWithEditable(fileDiff)
? fileDiff.editable
: fileDiff.operation === 'edited'
}
export function fileFinalPathname(fileDiff: FileDiff) {
return (
(isFileRenamed(fileDiff) ? fileDiff.newPathname : null) || fileDiff.pathname
)
}

View File

@@ -0,0 +1,116 @@
import _ from 'lodash'
import type { FileDiff, FileRenamed } from '../services/types/file'
import { isFileEditable, isFileRemoved } from './file-diff'
export type FileTreeEntity = {
name?: string
type?: 'file' | 'folder'
children?: FileTreeEntity[]
} & FileDiff
export function reducePathsToTree(
currentFileTree: FileTreeEntity[],
fileObject: FileTreeEntity
) {
const filePathParts = fileObject?.pathname?.split('/') ?? ''
let currentFileTreeLocation = currentFileTree
for (let index = 0; index < filePathParts.length; index++) {
const pathPart = filePathParts[index]
const isFile = index === filePathParts.length - 1
if (isFile) {
const fileTreeEntity: FileTreeEntity = _.clone(fileObject)
fileTreeEntity.name = pathPart
fileTreeEntity.type = 'file'
currentFileTreeLocation.push(fileTreeEntity)
} else {
let fileTreeEntity: FileTreeEntity | undefined = _.find(
currentFileTreeLocation,
entity => entity.name === pathPart
)
if (fileTreeEntity === undefined) {
fileTreeEntity = {
name: pathPart,
type: 'folder',
children: <FileTreeEntity[]>[],
pathname: pathPart,
editable: false,
}
currentFileTreeLocation.push(fileTreeEntity)
}
currentFileTreeLocation = fileTreeEntity.children ?? []
}
}
return currentFileTree
}
export type HistoryDoc = {
name: string
} & FileDiff
export type HistoryFileTree = {
docs?: HistoryDoc[]
folders: HistoryFileTree[]
name: string
}
export function fileTreeDiffToFileTreeData(
fileTreeDiff: FileTreeEntity[],
currentFolderName = 'rootFolder' // default value from angular version
): HistoryFileTree {
const folders: HistoryFileTree[] = []
const docs: HistoryDoc[] = []
for (const file of fileTreeDiff) {
if (file.type === 'file') {
const deletedAtV = isFileRemoved(file) ? file.deletedAtV : undefined
const newDoc: HistoryDoc = {
pathname: file.pathname ?? '',
name: file.name ?? '',
deletedAtV,
editable: isFileEditable(file),
operation: 'operation' in file ? file.operation : undefined,
}
docs.push(newDoc)
} else if (file.type === 'folder') {
if (file.children) {
const folder = fileTreeDiffToFileTreeData(file.children, file.name)
folders.push(folder)
}
}
}
return {
docs,
folders,
name: currentFolderName,
}
}
// TODO: refactor the oldPathname/newPathname data
// It's an artifact from the angular version.
// Our API returns `pathname` and `newPathname` for `renamed` operation
// In the angular version, we change the key of the data:
// 1. `pathname` -> `oldPathname`
// 2. `newPathname` -> `pathname`
// 3. Delete the `newPathname` key from the object
// This is because the angular version wants to generalize the API usage
// In the operation other than the `renamed` operation, the diff API (/project/:id/diff) consumes the `pathname`
// But the `renamed` operation consumes the `newPathname` instead of the `pathname` data
//
// This behaviour can be refactored by introducing a conditional when calling the API
// i.e if `renamed` -> use `newPathname`, else -> use `pathname`
export function renamePathnameKey(file: FileRenamed): FileRenamed {
return {
oldPathname: file.pathname,
pathname: file.newPathname as string,
operation: file.operation,
editable: file.editable,
}
}

View File

@@ -0,0 +1,55 @@
import moment from 'moment/moment'
import { DocDiffChunk, Highlight } from '../services/types/doc'
import { TFunction } from 'i18next'
import displayNameForUser from './display-name-for-user'
import { getHueForUserId } from '@/shared/utils/colors'
export function highlightsFromDiffResponse(
chunks: DocDiffChunk[],
t: TFunction<'translation'> // Must be called `t` for i18next-scanner to find calls to it
) {
let pos = 0
const highlights: Highlight[] = []
let doc = ''
for (const entry of chunks) {
const content = entry.u || entry.i || entry.d || ''
doc += content
const from = pos
const to = doc.length
pos = to
const range = { from, to }
const isInsertion = typeof entry.i === 'string'
const isDeletion = typeof entry.d === 'string'
if (isInsertion || isDeletion) {
const meta = entry.meta
if (!meta) {
throw new Error('No meta found')
}
const user = meta.users?.[0]
const name = displayNameForUser(user)
const date = moment(meta.end_ts).format('Do MMM YYYY, h:mm a')
if (isInsertion) {
highlights.push({
type: 'addition',
// There doesn't seem to be a convenient way to make this translatable
label: t('added_by_on', { name, date }),
range,
hue: getHueForUserId(user?.id),
})
} else if (isDeletion) {
highlights.push({
type: 'deletion',
// There doesn't seem to be a convenient way to make this translatable
label: t('deleted_by_on', { name, date }),
range,
hue: getHueForUserId(user?.id),
})
}
}
}
return { doc, highlights }
}

View File

@@ -0,0 +1,120 @@
import { User } from '../services/types/shared'
import { LoadedUpdate, ProjectOp, Version } from '../services/types/update'
import { Selection } from '../services/types/selection'
export const formatUserName = (user: User) => {
let name = [user.first_name, user.last_name]
.filter(n => n != null)
.join(' ')
.trim()
if (name === '') {
name = user.email.split('@')[0]
}
if (name == null || name === '') {
return '?'
}
return name
}
export const getProjectOpDoc = (projectOp: ProjectOp) => {
if (projectOp.rename) {
return `${projectOp.rename.pathname}${projectOp.rename.newPathname}`
}
if (projectOp.add) {
return `${projectOp.add.pathname}`
}
if (projectOp.remove) {
return `${projectOp.remove.pathname}`
}
return ''
}
export type ItemSelectionState =
| 'upperSelected'
| 'lowerSelected'
| 'withinSelected'
| 'aboveSelected'
| 'belowSelected'
| 'selected'
| null
export function isVersionSelected(
selection: Selection,
version: Version
): ItemSelectionState
// eslint-disable-next-line no-redeclare
export function isVersionSelected(
selection: Selection,
fromV: Version,
toV: Version
): ItemSelectionState
// eslint-disable-next-line no-redeclare
export function isVersionSelected(
selection: Selection,
...args: [Version] | [Version, Version]
): ItemSelectionState {
if (selection.updateRange) {
let [fromV, toV] = args
toV = toV ?? fromV
if (selection.comparing) {
if (
fromV > selection.updateRange.fromV &&
toV < selection.updateRange.toV
) {
return 'withinSelected'
}
// Condition for selectedEdge when the comparing versions are from labels list
if (fromV === toV) {
if (fromV === selection.updateRange.toV) {
return 'upperSelected'
}
if (toV === selection.updateRange.fromV) {
return 'lowerSelected'
}
}
// Comparing mode above selected condition
if (fromV >= selection.updateRange.toV) {
return 'aboveSelected'
}
// Comparing mode below selected condition
if (toV <= selection.updateRange.fromV) {
return 'belowSelected'
}
if (toV === selection.updateRange.toV) {
return 'upperSelected'
}
if (fromV === selection.updateRange.fromV) {
return 'lowerSelected'
}
} else if (toV === selection.updateRange.toV) {
// single version mode
return 'selected'
} else if (fromV >= selection.updateRange.toV) {
// Non-Comparing mode above selected condition
return 'aboveSelected'
} else if (toV <= selection.updateRange.fromV) {
// Non-Comparing mode below selected condition
return 'belowSelected'
}
}
return null
}
export const getUpdateForVersion = (version: number, updates: LoadedUpdate[]) =>
updates.find(update => update.toV === version)
export const updateRangeForUpdate = (update: LoadedUpdate) => {
const { fromV, toV, meta } = update
const fromVTimestamp = meta.end_ts
return {
fromV,
toV,
fromVTimestamp,
toVTimestamp: fromVTimestamp,
}
}

View File

@@ -0,0 +1,107 @@
import { orderBy, groupBy } from 'lodash'
import {
LoadedLabel,
Label,
PseudoCurrentStateLabel,
} from '../services/types/label'
import { Nullable } from '../../../../../types/utils'
import { Selection } from '../services/types/selection'
import { Update } from '../services/types/update'
export const isPseudoLabel = (
label: LoadedLabel
): label is PseudoCurrentStateLabel => {
return (label as PseudoCurrentStateLabel).isPseudoCurrentStateLabel === true
}
export const isLabel = (label: LoadedLabel): label is Label => {
return !isPseudoLabel(label)
}
const sortLabelsByVersionAndDate = (labels: LoadedLabel[]) => {
return orderBy(
labels,
['isPseudoCurrentStateLabel', 'version', 'created_at'],
['asc', 'desc', 'desc']
)
}
const deletePseudoCurrentStateLabelIfExistent = (labels: LoadedLabel[]) => {
if (labels.length && isPseudoLabel(labels[0])) {
const [, ...rest] = labels
return rest
}
return labels
}
const addPseudoCurrentStateLabelIfNeeded = (
labels: LoadedLabel[],
mostRecentVersion: Nullable<number>
) => {
if (!labels.length || labels[0].version !== mostRecentVersion) {
const pseudoCurrentStateLabel: PseudoCurrentStateLabel = {
id: '1',
isPseudoCurrentStateLabel: true,
version: mostRecentVersion,
created_at: new Date().toISOString(),
lastUpdatedTimestamp: null,
}
return [pseudoCurrentStateLabel, ...labels]
}
return labels
}
const addLastUpdatedTimestamp = (labels: LoadedLabel[], updates: Update[]) => {
return labels.map(label => {
const lastUpdatedTimestamp = updates.find(update =>
update.labels.find(l => l.id === label.id)
)?.meta.end_ts
return {
...label,
lastUpdatedTimestamp: lastUpdatedTimestamp || null,
}
})
}
export const loadLabels = (labels: Label[], updates: Update[]) => {
const lastUpdateToV = updates.length ? updates[0].toV : null
const sortedLabels = sortLabelsByVersionAndDate(labels)
const labelsWithoutPseudoLabel =
deletePseudoCurrentStateLabelIfExistent(sortedLabels)
const labelsWithPseudoLabelIfNeeded = addPseudoCurrentStateLabelIfNeeded(
labelsWithoutPseudoLabel,
lastUpdateToV
)
const labelsWithLastUpdatedTimestamp = addLastUpdatedTimestamp(
labelsWithPseudoLabelIfNeeded,
updates
)
return labelsWithLastUpdatedTimestamp
}
export const getVersionWithLabels = (labels: Nullable<LoadedLabel[]>) => {
let versionWithLabels: { version: number; labels: LoadedLabel[] }[] = []
if (labels) {
const groupedLabelsHash = groupBy(labels, 'version')
versionWithLabels = Object.keys(groupedLabelsHash).map(key => ({
version: parseInt(key, 10),
labels: groupedLabelsHash[key],
}))
versionWithLabels = orderBy(versionWithLabels, ['version'], ['desc'])
}
return versionWithLabels
}
export const isAnyVersionMatchingSelection = (
labels: Nullable<LoadedLabel[]>,
selection: Selection
) => {
// build an Array<number> of available versions
const versions = getVersionWithLabels(labels).map(v => v.version)
const selectedVersion = selection.updateRange?.toV
return selectedVersion && !versions.includes(selectedVersion)
}

View File

@@ -0,0 +1,19 @@
import { UpdateRange } from '../services/types/update'
export const updateRangeUnion = (
updateRange1: UpdateRange,
updateRange2: UpdateRange
) => {
return {
fromV: Math.min(updateRange1.fromV, updateRange2.fromV),
toV: Math.max(updateRange1.toV, updateRange2.toV),
fromVTimestamp: Math.min(
updateRange1.fromVTimestamp,
updateRange2.fromVTimestamp
),
toVTimestamp: Math.max(
updateRange1.toVTimestamp,
updateRange2.toVTimestamp
),
}
}

View File

@@ -0,0 +1,35 @@
import { debugConsole } from '@/utils/debugging'
export function waitFor<T>(
testFunction: () => T,
timeout: number,
pollInterval = 500
): Promise<T> {
const iterationLimit = Math.floor(timeout / pollInterval)
let iterations = 0
return new Promise<T>((resolve, reject) => {
const tryIteration = () => {
if (iterations > iterationLimit) {
const err = new Error(
`waiting too long, ${JSON.stringify({ timeout, pollInterval })}`
)
debugConsole.error(err)
reject(err)
return
}
iterations += 1
const result = testFunction()
if (result) {
resolve(result)
return
}
setTimeout(tryIteration, pollInterval)
}
tryIteration()
})
}