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,38 @@
import { useCallback, useEffect } from 'react'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import ErrorMessage from '../error-message'
export default function FileTreeCreateNewDoc() {
const { name, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingDoc, inFlight } = useFileTreeActionable()
// form validation: name is valid
useEffect(() => {
setValid(validName)
}, [setValid, validName])
// form submission: create an empty doc with this name
const handleSubmit = useCallback(
event => {
event.preventDefault()
finishCreatingDoc({ name })
eventTracking.sendMB('new-file-created', {
method: 'doc',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
},
[finishCreatingDoc, name]
)
return (
<form noValidate id="create-file" onSubmit={handleSubmit}>
<FileTreeCreateNameInput focusName error={error} inFlight={inFlight} />
{error && <ErrorMessage error={error} />}
</form>
)
}

View File

@@ -0,0 +1,373 @@
import {
useState,
useCallback,
useEffect,
useMemo,
FormEventHandler,
} from 'react'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useTranslation } from 'react-i18next'
import { useUserProjects } from '../../../hooks/use-user-projects'
import { Entity, useProjectEntities } from '../../../hooks/use-project-entities'
import { useProjectOutputFiles } from '../../../hooks/use-project-output-files'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import { useProjectContext } from '../../../../../shared/context/project-context'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import { File } from '@/features/source-editor/utils/file'
import { Project } from '../../../../../../../types/project'
import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { Spinner } from 'react-bootstrap-5'
export default function FileTreeImportFromProject() {
const { t } = useTranslation()
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
getMeta('ol-ExposedSettings')
const canSwitchOutputFilesMode =
hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingLinkedFile, inFlight } = useFileTreeActionable()
const [selectedProject, setSelectedProject] = useState<Project>()
const [selectedProjectEntity, setSelectedProjectEntity] = useState<Entity>()
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState<
File & { build: string; clsiServerId: string }
>()
const [isOutputFilesMode, setOutputFilesMode] = useState(
// default to project file mode, unless the feature is not enabled
!hasLinkedProjectFileFeature
)
// use the basename of a path as the file name
const setNameFromPath = useCallback(
path => {
const filename = path.split('/').pop()
if (filename) {
setName(filename)
}
},
[setName]
)
// update the name when an output file is selected
useEffect(() => {
if (selectedProjectOutputFile) {
if (
selectedProjectOutputFile.path === 'output.pdf' &&
selectedProject!.name
) {
// if the output PDF is selected, use the project's name as the filename
setName(`${selectedProject!.name}.pdf`)
} else {
setNameFromPath(selectedProjectOutputFile.path)
}
}
}, [selectedProject, selectedProjectOutputFile, setName, setNameFromPath])
// update the name when an entity is selected
useEffect(() => {
if (selectedProjectEntity) {
setNameFromPath(selectedProjectEntity.path)
}
}, [selectedProjectEntity, setNameFromPath])
// form validation: name is valid and entity or output file is selected
useEffect(() => {
const hasSelectedEntity = Boolean(
isOutputFilesMode ? selectedProjectOutputFile : selectedProjectEntity
)
setValid(validName && hasSelectedEntity)
}, [
setValid,
validName,
isOutputFilesMode,
selectedProjectEntity,
selectedProjectOutputFile,
])
// form submission: create a linked file with this name, from this entity or output file
const handleSubmit: FormEventHandler = event => {
event.preventDefault()
eventTracking.sendMB('new-file-created', {
method: 'project',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
if (isOutputFilesMode) {
finishCreatingLinkedFile({
name,
provider: 'project_output_file',
data: {
source_project_id: selectedProject!._id,
source_output_file_path: selectedProjectOutputFile!.path,
build_id: selectedProjectOutputFile!.build,
clsiServerId: selectedProjectOutputFile!.clsiServerId,
},
})
} else {
finishCreatingLinkedFile({
name,
provider: 'project_file',
data: {
source_project_id: selectedProject!._id,
source_entity_path: selectedProjectEntity!.path,
},
})
}
}
return (
<OLForm id="create-file" onSubmit={handleSubmit}>
<SelectProject
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
/>
{isOutputFilesMode ? (
<SelectProjectOutputFile
selectedProjectId={selectedProject?._id}
selectedProjectOutputFile={selectedProjectOutputFile}
setSelectedProjectOutputFile={setSelectedProjectOutputFile}
/>
) : (
<SelectProjectEntity
selectedProjectId={selectedProject?._id}
selectedProjectEntity={selectedProjectEntity}
setSelectedProjectEntity={setSelectedProjectEntity}
/>
)}
{canSwitchOutputFilesMode && (
<div className="toggle-file-type-button">
or&nbsp;
<OLButton
variant="link"
type="button"
onClick={() => setOutputFilesMode(value => !value)}
>
<span>
{isOutputFilesMode
? t('select_from_source_files')
: t('select_from_output_files')}
</span>
</OLButton>
</div>
)}
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
classes={{
formGroup: 'form-controls row-spaced-small',
}}
placeholder="example.tex"
error={error}
inFlight={inFlight}
/>
{error && <ErrorMessage error={error} />}
</OLForm>
)
}
type SelectProjectProps = {
selectedProject?: any
setSelectedProject(project: any): void
}
function SelectProject({
selectedProject,
setSelectedProject,
}: SelectProjectProps) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { data, error, loading } = useUserProjects()
const filteredData = useMemo(() => {
if (!data) {
return null
}
return data.filter(item => item._id !== projectId)
}, [data, projectId])
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup controlId="project-select">
<OLFormLabel>{t('select_a_project')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProject ? selectedProject._id : ''}
onChange={event => {
const projectId = (event.target as HTMLSelectElement).value
const project = data!.find(item => item._id === projectId)
setSelectedProject(project)
}}
>
<option disabled value="">
- {t('please_select_a_project')}
</option>
{filteredData &&
filteredData.map(project => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</OLFormSelect>
{filteredData && !filteredData.length && (
<small>{t('no_other_projects_found')}</small>
)}
</OLFormGroup>
)
}
type SelectProjectOutputFileProps = {
selectedProjectId?: string
selectedProjectOutputFile?: any
setSelectedProjectOutputFile(file: any): void
}
function SelectProjectOutputFile({
selectedProjectId,
selectedProjectOutputFile,
setSelectedProjectOutputFile,
}: SelectProjectOutputFileProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectOutputFiles(selectedProjectId)
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup
className="row-spaced-small"
controlId="project-output-file-select"
>
<OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProjectOutputFile?.path || ''}
onChange={event => {
const path = (event.target as unknown as HTMLSelectElement).value
const file = data?.find(item => item.path === path)
setSelectedProjectOutputFile(file)
}}
>
<option disabled value="">
- {t('please_select_an_output_file')}
</option>
{data &&
data.map(file => (
<option key={file.path} value={file.path}>
{file.path}
</option>
))}
</OLFormSelect>
</OLFormGroup>
)
}
type SelectProjectEntityProps = {
selectedProjectId?: string
selectedProjectEntity?: any
setSelectedProjectEntity(entity: any): void
}
function SelectProjectEntity({
selectedProjectId,
selectedProjectEntity,
setSelectedProjectEntity,
}: SelectProjectEntityProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectEntities(selectedProjectId)
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup className="row-spaced-small" controlId="project-entity-select">
<OLFormLabel>{t('select_a_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProjectEntity?.path || ''}
onChange={event => {
const path = (event.target as HTMLSelectElement).value
const entity = data!.find(item => item.path === path)
setSelectedProjectEntity(entity)
}}
>
<option disabled value="">
- {t('please_select_a_file')}
</option>
{data &&
data.map(entity => (
<option key={entity.path} value={entity.path}>
{entity.path.slice(1)}
</option>
))}
</OLFormSelect>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
export default function FileTreeImportFromUrl() {
const { t } = useTranslation()
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
const [url, setUrl] = useState('')
const handleChange = useCallback(event => {
setUrl(event.target.value)
}, [])
// set the name when the URL changes
useEffect(() => {
if (url) {
const matches = url.match(/^\s*https?:\/\/.+\/([^/]+\.(\w+))\s*$/)
setName(matches ? matches[1] : '')
}
}, [setName, url])
// form validation: URL is set and name is valid
useEffect(() => {
setValid(validName && !!url)
}, [setValid, validName, url])
// form submission: create a linked file with this name, from this URL
const handleSubmit = event => {
event.preventDefault()
eventTracking.sendMB('new-file-created', {
method: 'url',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
finishCreatingLinkedFile({
name,
provider: 'url',
data: { url: url.trim() },
})
}
return (
<form
className="form-controls"
id="create-file"
noValidate
onSubmit={handleSubmit}
>
<OLFormGroup controlId="import-from-url">
<OLFormLabel>{t('url_to_fetch_the_file_from')}</OLFormLabel>
<OLFormControl
type="url"
placeholder="https://example.com/my-file.png"
required
value={url}
onChange={handleChange}
/>
</OLFormGroup>
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
placeholder="my_file"
error={error}
inFlight={inFlight}
/>
{error && <ErrorMessage error={error} />}
</form>
)
}

View File

@@ -0,0 +1,339 @@
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Uppy from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { Dashboard } from '@uppy/react'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useProjectContext } from '../../../../../shared/context/project-context'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import { refreshProjectMetadata } from '../../../util/api'
import ErrorMessage from '../error-message'
import { debugConsole } from '@/utils/debugging'
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
import {
findFileByNameInFolder,
findFolderByNameInFolder,
} from '@/features/file-tree/util/is-name-unique-in-folder'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import {
Conflict,
FileUploadConflicts,
FolderUploadConflicts,
} from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts'
import getMeta from '@/utils/meta'
export default function FileTreeUploadDoc() {
const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
useFileTreeActionable()
const { fileTreeData } = useFileTreeData()
const { _id: projectId } = useProjectContext()
const [error, setError] = useState<string>()
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [folderConflicts, setFolderConflicts] = useState<Conflict[]>([])
const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 180
const maxFileSize = getMeta('ol-ExposedSettings').maxUploadSize
// calculate conflicts
const buildConflicts = (files: Record<string, any>) => {
const conflicts: Conflict[] = []
for (const file of Object.values(files)) {
const { name, relativePath } = file.meta
if (!relativePath) {
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateFile = findFileByNameInFolder(
fileTreeData,
targetFolderId,
name
)
if (duplicateFile) {
conflicts.push({
entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
name
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
}
}
}
return conflicts
}
const buildFolderConflicts = (files: Record<string, any>) => {
const conflicts: Conflict[] = []
for (const file of Object.values(files)) {
const { relativePath } = file.meta
if (relativePath) {
const [rootName] = relativePath.replace(/^\//, '').split('/')
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateFile = findFileByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFile) {
conflicts.push({
entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
}
}
}
return conflicts
}
const buildEndpoint = (projectId: string, targetFolderId: string) => {
let endpoint = `/project/${projectId}/upload`
if (targetFolderId) {
endpoint += `?folder_id=${targetFolderId}`
}
return endpoint
}
// initialise the Uppy object
const [uppy] = useState(() => {
const endpoint = buildEndpoint(projectId, parentFolderId)
return (
new Uppy<{ relativePath?: string; targetFolderId: string }>({
// logger: Uppy.debugLogger,
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles,
maxFileSize: maxFileSize || null,
},
onBeforeFileAdded(file) {
if (
!isAcceptableFile(
file.name,
file.meta.relativePath as string | undefined
)
) {
return false
}
},
onBeforeUpload(files) {
const conflicts = buildConflicts(files)
const folderConflicts = buildFolderConflicts(files)
setConflicts(conflicts)
setFolderConflicts(folderConflicts)
return conflicts.length === 0 && folderConflicts.length === 0
},
autoProceed: true,
locale: {
strings: {
youCanOnlyUploadX:
'You can only upload %{smart_count} files at a time',
},
},
})
// use the basic XHR uploader
.use(XHRUpload, {
endpoint,
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
})
// close the modal when all the uploads completed successfully
.on('complete', result => {
if (!result.failed.length) {
// $scope.$emit('done', { name: name })
cancel()
}
})
// broadcast doc metadata after each successful upload
.on('upload-success', (file, response) => {
eventTracking.sendMB('new-file-created', {
method: 'upload',
extension:
file?.name && file?.name.split('.').length > 1
? file?.name.split('.').pop()
: '',
})
if (response.body.entity_type === 'doc') {
window.setTimeout(() => {
refreshProjectMetadata(projectId, response.body.entity_id)
}, 250)
}
})
// handle upload errors
.on('upload-error', (file, error, response) => {
switch (response?.status) {
case 429:
setError('rate-limit-hit')
break
case 403:
setError('not-logged-in')
break
default:
debugConsole.error(error)
setError(response?.body?.error || 'generic_something_went_wrong')
break
}
})
)
})
useEffect(() => {
if (uppy && droppedFiles) {
uppy.setOptions({
autoProceed: false,
})
for (const file of droppedFiles.files) {
const fileId = uppy.addFile({
name: file.name,
type: file.type,
data: file,
source: 'Local',
isRemote: false,
meta: {
relativePath: (file as any).relativePath,
targetFolderId: droppedFiles.targetFolderId,
},
})
const uppyFile = uppy.getFile(fileId)
uppy.setFileState(fileId, {
xhrUpload: {
...(uppyFile as any).xhrUpload,
endpoint: buildEndpoint(projectId, droppedFiles.targetFolderId),
},
})
}
}
return () => {
setDroppedFiles(null)
}
}, [uppy, droppedFiles, setDroppedFiles, projectId])
// handle forced overwriting of conflicting files
const handleOverwrite = useCallback(() => {
setOverwrite(true)
uppy.setOptions({
onBeforeUpload() {
// don't check for file conflicts
return true
},
})
uppy.upload()
}, [uppy])
const showFolderUploadConflicts = !overwrite && folderConflicts.length > 0
const showFileUploadConfilcts =
!overwrite && !showFolderUploadConflicts && conflicts.length > 0
const showDashboard = !showFileUploadConfilcts && !showFolderUploadConflicts
return (
<>
{error && (
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
)}
{showFolderUploadConflicts && (
<FolderUploadConflicts
cancel={cancel}
conflicts={folderConflicts}
handleOverwrite={handleOverwrite}
setError={setError}
/>
)}
{showFileUploadConfilcts && (
<FileUploadConflicts
cancel={cancel}
conflicts={conflicts}
handleOverwrite={handleOverwrite}
/>
)}
{showDashboard && (
<Dashboard
uppy={uppy}
showProgressDetails
// note={`Up to ${maxNumberOfFiles} files, up to ${maxFileSize / (1024 * 1024)}MB`}
height={400}
width="100%"
showLinkToFileUploadResult={false}
proudlyDisplayPoweredByUppy={false}
// allow files or folders to be selected
fileManagerSelectionType="both"
locale={{
strings: {
// Text to show on the droppable area.
// `%{browse}` is replaced with a link that opens the system file selection dialog.
// TODO: 'drag_here' or 'drop_files_here_to_upload'?
// dropHereOr: `${t('drag_here')} ${t('or')} %{browse}`,
dropPasteBoth: `Drop or paste your files, folder, or images here. %{browseFiles} or %{browseFolders} from your computer.`,
// Used as the label for the link that opens the system file selection dialog.
// browseFiles: t('select_from_your_computer')
browseFiles: 'Select files',
browseFolders: 'select a folder',
},
}}
/>
)}
</>
)
}
function UploadErrorMessage({
error,
maxNumberOfFiles,
}: {
error: string
maxNumberOfFiles: number
}) {
const { t } = useTranslation()
switch (error) {
case 'too-many-files':
return (
<>
{t('maximum_files_uploaded_together', {
max: maxNumberOfFiles,
})}
</>
)
default:
return <ErrorMessage error={error} />
}
}