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,4 @@
import { postJSON } from '../../../infrastructure/fetch-json'
export const refreshProjectMetadata = (projectId: string, entityId: string) =>
postJSON(`/project/${projectId}/doc/${entityId}/metadata`)

View File

@@ -0,0 +1,65 @@
import getMeta from '@/utils/meta'
import { Folder } from '../../../../../types/folder'
type FileCountStatus = 'success' | 'warning' | 'error'
type FileCount = {
value: number
status: FileCountStatus
limit: number
}
export function countFiles(fileTreeData: Folder | undefined): 0 | FileCount {
if (!fileTreeData) {
return 0
}
const value = _countElements(fileTreeData)
const limit = getMeta('ol-ExposedSettings').maxEntitiesPerProject
const status = fileCountStatus(value, limit, Math.ceil(limit / 20))
return { value, status, limit }
}
function fileCountStatus(
value: number,
limit: number,
range: number
): FileCountStatus {
if (value >= limit) {
return 'error'
}
if (value >= limit - range) {
return 'warning'
}
return 'success'
}
// Copied and adapted from ProjectEntityMongoUpdateHandler
function _countElements(rootFolder: Folder): number {
function countFolder(folder: Folder) {
if (folder == null) {
return 0
}
let total = 0
if (folder.folders) {
total += folder.folders.length
for (const subfolder of folder.folders) {
total += countFolder(subfolder)
}
}
if (folder.docs) {
total += folder.docs.length
}
if (folder.fileRefs) {
total += folder.fileRefs.length
}
return total
}
return countFolder(rootFolder)
}

View File

@@ -0,0 +1,31 @@
import { Folder } from '../../../../../types/folder'
import { DocId, MainDocument } from '../../../../../types/project-settings'
function findAllDocsInFolder(folder: Folder, path = '') {
const docs = folder.docs.map<MainDocument>(doc => ({
doc: { id: doc._id as DocId, name: doc.name },
path: path + doc.name,
}))
for (const subFolder of folder.folders) {
docs.push(...findAllDocsInFolder(subFolder, `${path}${subFolder.name}/`))
}
return docs
}
export function docsInFolder(folder: Folder) {
const docsInTree = findAllDocsInFolder(folder)
docsInTree.sort(function (a, b) {
const aDepth = (a.path.match(/\//g) || []).length
const bDepth = (b.path.match(/\//g) || []).length
if (aDepth - bDepth !== 0) {
return -(aDepth - bDepth) // Deeper path == folder first
} else if (a.path < b.path) {
return -1
} else if (a.path > b.path) {
return 1
} else {
return 0
}
})
return docsInTree
}

View File

@@ -0,0 +1,11 @@
// The collator used to sort files docs and folders in the tree.
// Uses English as base language for consistency.
// Options used:
// numeric: true so 10 comes after 2
// sensitivity: 'variant' so case and accent are not equal
// caseFirst: 'upper' so upper-case letters come first
export const fileCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'variant',
caseFirst: 'upper',
})

View File

@@ -0,0 +1,95 @@
import OError from '@overleaf/o-error'
import { Folder } from '../../../../../types/folder'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
export function findInTreeOrThrow(tree: Folder, id: string) {
const found = findInTree(tree, id)
if (found) return found
throw new OError('Entity not found in tree', { entityId: id })
}
export function findAllInTreeOrThrow(
tree: Folder,
ids: Set<string>
): Set<FileTreeFindResult> {
const list: Set<FileTreeFindResult> = new Set()
ids.forEach(id => {
list.add(findInTreeOrThrow(tree, id))
})
return list
}
export function findAllFolderIdsInFolder(folder: Folder): Set<string> {
const list = new Set([folder._id])
for (const index in folder.folders) {
const subFolder = folder.folders[index]
findAllFolderIdsInFolder(subFolder).forEach(subFolderId => {
list.add(subFolderId)
})
}
return list
}
export function findAllFolderIdsInFolders(folders: Set<Folder>): Set<string> {
const list: Set<string> = new Set()
folders.forEach(folder => {
findAllFolderIdsInFolder(folder).forEach(folderId => {
list.add(folderId)
})
})
return list
}
export function findInTree(
tree: Folder,
id: string,
path?: string[]
): FileTreeFindResult | null {
if (!path) {
path = [tree._id]
}
for (const index in tree.docs) {
const doc = tree.docs[index]
if (doc._id === id) {
return {
entity: doc,
type: 'doc',
parent: tree.docs,
parentFolderId: tree._id,
path,
index: Number(index),
}
}
}
for (const index in tree.fileRefs) {
const file = tree.fileRefs[index]
if (file._id === id) {
return {
entity: file,
type: 'fileRef',
parent: tree.fileRefs,
parentFolderId: tree._id,
path,
index: Number(index),
}
}
}
for (const index in tree.folders) {
const folder = tree.folders[index]
if (folder._id === id) {
return {
entity: folder,
type: 'folder',
parent: tree.folders,
parentFolderId: tree._id,
path,
index: Number(index),
}
}
const found = findInTree(folder, id, path.concat(folder._id))
if (found) return found
}
return null
}

View File

@@ -0,0 +1,37 @@
import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
// TODO ide-redesign-cleanup: Make this the default export and remove the legacy version
export const newEditorIconTypeFromName = (
name: string
): AvailableUnfilledIcon => {
let ext = name.split('.').pop()
ext = ext ? ext.toLowerCase() : ext
if (ext && ['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
return 'image'
} else if (ext && ['csv', 'xls', 'xlsx'].includes(ext)) {
return 'table_chart'
} else if (ext && ['py', 'r'].includes(ext)) {
return 'code'
} else if (ext && ['bib'].includes(ext)) {
return 'book_5'
}
return 'description'
}
export default function iconTypeFromName(name: string): string {
let ext = name.split('.').pop()
ext = ext ? ext.toLowerCase() : ext
if (ext && ['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
return 'image'
} else if (ext && ['csv', 'xls', 'xlsx'].includes(ext)) {
return 'table_chart'
} else if (ext && ['py', 'r'].includes(ext)) {
return 'code'
} else if (ext && ['bib'].includes(ext)) {
return 'menu_book'
} else {
return 'description'
}
}

View File

@@ -0,0 +1,26 @@
import { Minimatch } from 'minimatch'
import getMeta from '@/utils/meta'
let fileIgnoreMatcher: Minimatch
export const isAcceptableFile = (name?: string, relativePath?: string) => {
if (!fileIgnoreMatcher) {
fileIgnoreMatcher = new Minimatch(
getMeta('ol-ExposedSettings').fileIgnorePattern,
{ nocase: true, dot: true }
)
}
if (!name) {
// the file must have a name, of course
return false
}
if (!relativePath) {
// uploading an individual file, so allow anything
return true
}
// uploading a file in a folder, so exclude unwanted file paths
return !fileIgnoreMatcher.match(relativePath + '/' + name)
}

View File

@@ -0,0 +1,42 @@
import { findInTreeOrThrow } from '../util/find-in-tree'
import { Folder } from '../../../../../types/folder'
import { Doc } from '../../../../../types/doc'
import { FileRef } from '../../../../../types/file-ref'
export function isNameUniqueInFolder(
tree: Folder,
parentFolderId: string,
name: string
): boolean {
return !(
findFileByNameInFolder(tree, parentFolderId, name) ||
findFolderByNameInFolder(tree, parentFolderId, name)
)
}
export function findFileByNameInFolder(
tree: Folder,
parentFolderId: string,
name: string
): Doc | FileRef | undefined {
if (tree._id !== parentFolderId) {
tree = findInTreeOrThrow(tree, parentFolderId).entity as Folder
}
return (
tree.docs.find(entity => entity.name === name) ||
tree.fileRefs.find(entity => entity.name === name)
)
}
export function findFolderByNameInFolder(
tree: Folder,
parentFolderId: string,
name: string
): Folder | undefined {
if (tree._id !== parentFolderId) {
tree = findInTreeOrThrow(tree, parentFolderId).entity as Folder
}
return tree.folders.find(entity => entity.name === name)
}

View File

@@ -0,0 +1,74 @@
import { findInTreeOrThrow } from './find-in-tree'
export function renameInTree(tree, id, { newName }) {
return mutateInTree(tree, id, (parent, entity, index) => {
const newParent = Object.assign([], parent)
const newEntity = {
...entity,
name: newName,
}
newParent[index] = newEntity
return newParent
})
}
export function deleteInTree(tree, id) {
return mutateInTree(tree, id, (parent, entity, index) => {
return [...parent.slice(0, index), ...parent.slice(index + 1)]
})
}
export function moveInTree(tree, entityId, toFolderId) {
const found = findInTreeOrThrow(tree, entityId)
if (found.parentFolderId === toFolderId) {
// nothing to do (the entity was probably already moved)
return tree
}
const newFileTreeData = deleteInTree(tree, entityId)
return createEntityInTree(newFileTreeData, toFolderId, {
...found.entity,
type: found.type,
})
}
export function createEntityInTree(tree, parentFolderId, newEntityData) {
const { type, ...newEntity } = newEntityData
if (!type) throw new Error('Entity has no type')
const entityType = `${type}s`
return mutateInTree(tree, parentFolderId, (parent, folder, index) => {
parent[index] = {
...folder,
[entityType]: [...folder[entityType], newEntity],
}
return parent
})
}
function mutateInTree(tree, id, mutationFunction) {
if (!id || tree._id === id) {
// covers the root folder case: it has no parent so in order to use
// mutationFunction we pass an empty array as the parent and return the
// mutated tree directly
const [newTree] = mutationFunction([], tree, 0)
return newTree
}
for (const entityType of ['docs', 'fileRefs', 'folders']) {
for (let index = 0; index < tree[entityType].length; index++) {
const entity = tree[entityType][index]
if (entity._id === id) {
return {
...tree,
[entityType]: mutationFunction(tree[entityType], entity, index),
}
}
}
}
const newFolders = tree.folders.map(folder =>
mutateInTree(folder, id, mutationFunction)
)
return { ...tree, folders: newFolders }
}

View File

@@ -0,0 +1,139 @@
import { Folder } from '../../../../../types/folder'
import { FileTreeEntity } from '../../../../../types/file-tree-entity'
import { Doc } from '../../../../../types/doc'
import { FileRef } from '../../../../../types/file-ref'
import { PreviewPath } from '../../../../../types/preview-path'
import { fileUrl } from '../../utils/fileUrl'
type DocFindResult = {
entity: Doc
type: 'doc'
}
type FolderFindResult = {
entity: Folder
type: 'folder'
}
type FileRefFindResult = {
entity: FileRef
type: 'fileRef'
}
export type FindResult = DocFindResult | FolderFindResult | FileRefFindResult
// Finds the entity with a given ID in the tree represented by `folder` and
// returns a path to that entity, represented by an array of folders starting at
// the root plus the entity itself
function pathComponentsInFolder(
folder: Folder,
id: string,
ancestors: FileTreeEntity[] = []
): FileTreeEntity[] | null {
const docOrFileRef =
folder.docs.find(doc => doc._id === id) ||
folder.fileRefs.find(fileRef => fileRef._id === id)
if (docOrFileRef) {
return ancestors.concat([docOrFileRef])
}
for (const subfolder of folder.folders) {
if (subfolder._id === id) {
return ancestors.concat([subfolder])
} else {
const path = pathComponentsInFolder(
subfolder,
id,
ancestors.concat([subfolder])
)
if (path !== null) {
return path
}
}
}
return null
}
// Finds the entity with a given ID in the tree represented by `folder` and
// returns a path to that entity as a string
export function pathInFolder(folder: Folder, id: string): string | null {
return (
pathComponentsInFolder(folder, id)
?.map(entity => entity.name)
.join('/') || null
)
}
export function findEntityByPath(
folder: Folder,
path: string
): FindResult | null {
if (path === '') {
return { entity: folder, type: 'folder' }
}
const parts = path.split('/')
const name = parts.shift()
const rest = parts.join('/')
if (name === '.') {
return findEntityByPath(folder, rest)
}
const doc = folder.docs.find(doc => doc.name === name)
if (doc) {
return { entity: doc, type: 'doc' }
}
const fileRef = folder.fileRefs.find(fileRef => fileRef.name === name)
if (fileRef) {
return { entity: fileRef, type: 'fileRef' }
}
for (const subfolder of folder.folders) {
if (subfolder.name === name) {
if (rest === '') {
return { entity: subfolder, type: 'folder' }
} else {
return findEntityByPath(subfolder, rest)
}
}
}
return null
}
export function previewByPath(
folder: Folder,
projectId: string,
path: string
): PreviewPath | null {
for (const suffix of [
'',
'.png',
'.jpg',
'.jpeg',
'.pdf',
'.PNG',
'.JPG',
'.JPEG',
'.PDF',
]) {
const result = findEntityByPath(folder, path + suffix)
if (result?.type === 'fileRef') {
const { name, _id: id, hash } = result.entity
return {
url: fileUrl(projectId, id, hash),
extension: name.slice(name.lastIndexOf('.') + 1),
}
}
}
return null
}
export function dirname(fileTreeData: Folder, id: string) {
const path = pathInFolder(fileTreeData, id)
return path?.split('/').slice(0, -1).join('/') || null
}

View File

@@ -0,0 +1,110 @@
// This file is shared between the frontend and server code of web, so that
// filename validation is the same in both implementations.
// The logic in all copies must be kept in sync:
// app/src/Features/Project/SafePath.js
// frontend/js/ide/directives/SafePath.js
// frontend/js/features/file-tree/util/safe-path.js
// eslint-disable-next-line prefer-regex-literals
const BADCHAR_RX = new RegExp(
`\
[\
\\/\
\\\\\
\\*\
\\u0000-\\u001F\
\\u007F\
\\u0080-\\u009F\
\\uD800-\\uDFFF\
]\
`,
'g'
)
// eslint-disable-next-line prefer-regex-literals
const BADFILE_RX = new RegExp(
`\
(^\\.$)\
|(^\\.\\.$)\
|(^\\s+)\
|(\\s+$)\
`,
'g'
)
// Put a block on filenames which match javascript property names, as they
// can cause exceptions where the code puts filenames into a hash. This is a
// temporary workaround until the code in other places is made safe against
// property names.
//
// The list of property names is taken from
// ['prototype'].concat(Object.getOwnPropertyNames(Object.prototype))
// eslint-disable-next-line prefer-regex-literals
const BLOCKEDFILE_RX = new RegExp(`\
^(\
prototype\
|constructor\
|toString\
|toLocaleString\
|valueOf\
|hasOwnProperty\
|isPrototypeOf\
|propertyIsEnumerable\
|__defineGetter__\
|__lookupGetter__\
|__defineSetter__\
|__lookupSetter__\
|__proto__\
)$\
`)
const MAX_PATH = 1024 // Maximum path length, in characters. This is fairly arbitrary.
export function clean(filename: string): string {
filename = filename.replace(BADCHAR_RX, '_')
// for BADFILE_RX replace any matches with an equal number of underscores
filename = filename.replace(BADFILE_RX, match =>
new Array(match.length + 1).join('_')
)
// replace blocked filenames 'prototype' with '@prototype'
filename = filename.replace(BLOCKEDFILE_RX, '@$1')
return filename
}
export function isCleanFilename(filename: string): boolean {
return (
isAllowedLength(filename) &&
!filename.match(BADCHAR_RX) &&
!filename.match(BADFILE_RX)
)
}
export function isBlockedFilename(filename: string): boolean {
return BLOCKEDFILE_RX.test(filename)
}
// returns whether a full path is 'clean' - e.g. is a full or relative path
// that points to a file, and each element passes the rules in 'isCleanFilename'
export function isCleanPath(path: string): boolean {
const elements = path.split('/')
const lastElementIsEmpty = elements[elements.length - 1].length === 0
if (lastElementIsEmpty) {
return false
}
for (const element of Array.from(elements)) {
if (element.length > 0 && !isCleanFilename(element)) {
return false
}
}
// check for a top-level reserved name
if (BLOCKEDFILE_RX.test(path.replace(/^\/?/, ''))) {
return false
} // remove leading slash if present
return true
}
export function isAllowedLength(pathname: string): boolean {
return pathname.length > 0 && pathname.length <= MAX_PATH
}

View File

@@ -0,0 +1,64 @@
import { postJSON, deleteJSON } from '../../../infrastructure/fetch-json'
export function syncRename(
projectId: string,
entityType: string,
entityId: string,
newName: string
) {
return postJSON(
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/rename`,
{
body: {
name: newName,
},
}
)
}
export function syncDelete(
projectId: string,
entityType: string,
entityId: string
) {
return deleteJSON(
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}`
)
}
export function syncMove(
projectId: string,
entityType: string,
entityId: string,
toFolderId: string
) {
return postJSON(
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/move`,
{
body: {
folder_id: toFolderId,
},
}
)
}
export function syncCreateEntity(
projectId: string,
parentFolderId: string,
newEntityData: {
endpoint: 'doc' | 'folder' | 'linked-file'
[key: string]: unknown
}
) {
const { endpoint, ...newEntity } = newEntityData
return postJSON(`/project/${projectId}/${endpoint}`, {
body: {
parent_folder_id: parentFolderId,
...newEntity,
},
})
}
function getEntityPathName(entityType: string) {
return entityType === 'fileRef' ? 'file' : entityType
}