[FEAT] Document picker folders for organization (#902)

* implement alternating color rows for file picker

* implement alternating colors for workspace directory

* remove unneeded props/variables

* remove unused border classes

* WIP new folder UI

* remove unneeded expanded prop from filerow component

* folder creation UI and files object manipulation WIP

* folder creation & moving files complete

* add developer API support for creating folders and moving files

* update alternating row css for file picker

* remove unneeded props from merge

* normalize paths for folders
priority to custom docs folder
silently fail on duplicate folders

* update folder icon to custom svg

* linting and move FolderIcon to JSX

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-03-20 16:10:30 -07:00 committed by GitHub
parent fd626b14b2
commit 4f268dfeb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 724 additions and 120 deletions

View File

@ -4,44 +4,12 @@ import {
getFileExtension,
middleTruncate,
} from "@/utils/directories";
import { File, Trash } from "@phosphor-icons/react";
import System from "@/models/system";
import { File } from "@phosphor-icons/react";
import debounce from "lodash.debounce";
export default function FileRow({
item,
folderName,
selected,
toggleSelection,
fetchKeys,
setLoading,
setLoadingMessage,
}) {
export default function FileRow({ item, selected, toggleSelection }) {
const [showTooltip, setShowTooltip] = useState(false);
const onTrashClick = async (event) => {
event.stopPropagation();
if (
!window.confirm(
"Are you sure you want to delete this document?\nThis will require you to re-upload and re-embed it.\nThis document will be removed from any workspace that is currently referencing it.\nThis action is not reversible."
)
) {
return false;
}
try {
setLoading(true);
setLoadingMessage("This may take a while for large documents");
await System.deleteDocument(`${folderName}/${item.name}`);
await fetchKeys(true);
} catch (error) {
console.error("Failed to delete the document:", error);
}
if (selected) toggleSelection(item);
setLoading(false);
};
const handleShowTooltip = () => {
setShowTooltip(true);
};
@ -60,7 +28,7 @@ export default function FileRow({
selected ? "selected" : ""
}`}
>
<div className="pl-2 col-span-5 flex gap-x-[4px] items-center">
<div className="pl-2 col-span-6 flex gap-x-[4px] items-center">
<div
className="shrink-0 w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
role="checkbox"
@ -94,16 +62,12 @@ export default function FileRow({
<p className="col-span-2 pl-2 uppercase overflow-x-hidden">
{getFileExtension(item.url)}
</p>
<div className="col-span-2 flex justify-end items-center">
<div className="-col-span-2 flex justify-end items-center">
{item?.cached && (
<div className="bg-white/10 rounded-3xl">
<p className="text-xs px-2 py-0.5">Cached</p>
</div>
)}
<Trash
onClick={onTrashClick}
className="text-base font-bold w-4 h-4 ml-2 flex-shrink-0 cursor-pointer"
/>
</div>
</tr>
);

View File

@ -1,8 +1,7 @@
import { useState } from "react";
import FileRow from "../FileRow";
import { CaretDown, FolderNotch, Trash } from "@phosphor-icons/react";
import { CaretDown, FolderNotch } from "@phosphor-icons/react";
import { middleTruncate } from "@/utils/directories";
import System from "@/models/system";
export default function FolderRow({
item,
@ -10,36 +9,10 @@ export default function FolderRow({
onRowClick,
toggleSelection,
isSelected,
fetchKeys,
setLoading,
setLoadingMessage,
autoExpanded = false,
}) {
const [expanded, setExpanded] = useState(autoExpanded);
const onTrashClick = async (event) => {
event.stopPropagation();
if (
!window.confirm(
"Are you sure you want to delete this folder?\nThis will require you to re-upload and re-embed it.\nAny documents in this folder will be removed from any workspace that is currently referencing it.\nThis action is not reversible."
)
) {
return false;
}
try {
setLoading(true);
setLoadingMessage("This may take a while for large folders");
await System.deleteFolder(item.name);
await fetchKeys(true);
} catch (error) {
console.error("Failed to delete the document:", error);
}
if (selected) toggleSelection(item);
setLoading(false);
};
const handleExpandClick = (event) => {
event.stopPropagation();
setExpanded(!expanded);
@ -49,7 +22,7 @@ export default function FolderRow({
<>
<tr
onClick={onRowClick}
className={`transition-all duration-200 text-white/80 text-xs grid grid-cols-12 py-2 pl-3.5 pr-8 bg-[#2C2C2C] hover:bg-sky-500/20 cursor-pointer w-full file-row:0 ${
className={`transition-all duration-200 text-white/80 text-xs grid grid-cols-12 py-2 pl-3.5 pr-8 bg-[#1C1E21] hover:bg-sky-500/20 cursor-pointer w-full ${
selected ? "selected" : ""
}`}
>
@ -59,6 +32,10 @@ export default function FolderRow({
role="checkbox"
aria-checked={selected}
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
toggleSelection(item);
}}
>
{selected && <div className="w-2 h-2 bg-white rounded-[2px]" />}
</div>
@ -75,19 +52,11 @@ export default function FolderRow({
weight="fill"
/>
<p className="whitespace-nowrap overflow-show">
{middleTruncate(item.name, 40)}
{middleTruncate(item.name, 35)}
</p>
</div>
<p className="col-span-2 pl-3.5" />
<p className="col-span-2 pl-2" />
<div className="col-span-2 flex justify-end items-center">
{item.name !== "custom-documents" && (
<Trash
onClick={onTrashClick}
className="text-base font-bold w-4 h-4 ml-2 flex-shrink-0 cursor-pointer"
/>
)}
</div>
</tr>
{expanded && (
<div className="col-span-full">
@ -95,12 +64,8 @@ export default function FolderRow({
<FileRow
key={fileItem.id}
item={fileItem}
folderName={item.name}
selected={isSelected(fileItem.id)}
toggleSelection={toggleSelection}
fetchKeys={fetchKeys}
setLoading={setLoading}
setLoadingMessage={setLoadingMessage}
/>
))}
</div>

View File

@ -0,0 +1,24 @@
import { middleTruncate } from "@/utils/directories";
export default function FolderSelectionPopup({ folders, onSelect, onClose }) {
const handleFolderSelect = (folder) => {
onSelect(folder);
onClose();
};
return (
<div className="absolute bottom-full left-0 mb-2 bg-white rounded-lg shadow-lg">
<ul>
{folders.map((folder) => (
<li
key={folder.name}
onClick={() => handleFolderSelect(folder)}
className="px-4 py-2 text-xs text-gray-700 hover:bg-gray-200 rounded-lg cursor-pointer whitespace-nowrap"
>
{middleTruncate(folder.name, 25)}
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,44 @@
export default function MoveToFolderIcon({
className,
width = 18,
height = 18,
}) {
return (
<svg
width={width}
height={height}
viewBox="0 0 17 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M1.46092 17.9754L3.5703 12.7019C3.61238 12.5979 3.68461 12.5088 3.7777 12.4462C3.8708 12.3836 3.98051 12.3502 4.09272 12.3504H7.47897C7.59001 12.3502 7.69855 12.3174 7.79116 12.2562L9.19741 11.3196C9.29001 11.2583 9.39855 11.2256 9.50959 11.2254H15.5234C15.6126 11.2254 15.7004 11.2465 15.7798 11.2872C15.8591 11.3278 15.9277 11.3867 15.9798 11.459C16.0319 11.5313 16.0661 11.6149 16.0795 11.703C16.093 11.7912 16.0853 11.8812 16.0571 11.9658L14.0532 17.9754H1.46092Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.25331 6.53891H2.02342C1.67533 6.53891 1.34149 6.67719 1.09534 6.92333C0.849204 7.16947 0.710922 7.50331 0.710922 7.85141V17.9764C0.710922 18.3906 1.04671 18.7264 1.46092 18.7264C1.87514 18.7264 2.21092 18.3906 2.21092 17.9764V8.03891H2.25331V6.53891ZM13.0859 9.98714V11.2264C13.0859 11.6406 13.4217 11.9764 13.8359 11.9764C14.2501 11.9764 14.5859 11.6406 14.5859 11.2264V9.53891C14.5859 9.19081 14.4476 8.85698 14.2015 8.61083C13.9554 8.36469 13.6215 8.22641 13.2734 8.22641H13.0863V9.98714H13.0859Z"
fill="currentColor"
/>
<path
d="M7.53416 1.62906L7.53416 7.70406"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.6411 5.21854L7.53456 7.70376L4.42803 5.21854"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -2,11 +2,16 @@ import UploadFile from "../UploadFile";
import PreLoader from "@/components/Preloader";
import { memo, useEffect, useState } from "react";
import FolderRow from "./FolderRow";
import pluralize from "pluralize";
import System from "@/models/system";
import { Plus, Trash } from "@phosphor-icons/react";
import Document from "@/models/document";
import showToast from "@/utils/toast";
import FolderSelectionPopup from "./FolderSelectionPopup";
import MoveToFolderIcon from "./MoveToFolderIcon";
function Directory({
files,
setFiles,
loading,
setLoading,
workspace,
@ -19,12 +24,19 @@ function Directory({
loadingMessage,
}) {
const [amountSelected, setAmountSelected] = useState(0);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [showFolderSelection, setShowFolderSelection] = useState(false);
useEffect(() => {
setAmountSelected(Object.keys(selectedItems).length);
}, [selectedItems]);
const deleteFiles = async (event) => {
event.stopPropagation();
if (
!window.confirm(
"Are you sure you want to delete these files?\nThis will remove the files from the system and remove them from any existing workspaces automatically.\nThis action is not reversible."
"Are you sure you want to delete these files and folders?\nThis will remove the files from the system and remove them from any existing workspaces automatically.\nThis action is not reversible."
)
) {
return false;
@ -32,6 +44,8 @@ function Directory({
try {
const toRemove = [];
const foldersToRemove = [];
for (const itemId of Object.keys(selectedItems)) {
for (const folder of files.items) {
const foundItem = folder.items.find((file) => file.id === itemId);
@ -41,13 +55,29 @@ function Directory({
}
}
}
for (const folder of files.items) {
if (folder.name === "custom-documents") {
continue;
}
if (isSelected(folder.id, folder)) {
foldersToRemove.push(folder.name);
}
}
setLoading(true);
setLoadingMessage(`Removing ${toRemove.length} documents. Please wait.`);
setLoadingMessage(
`Removing ${toRemove.length} documents and ${foldersToRemove.length} folders. Please wait.`
);
await System.deleteDocuments(toRemove);
for (const folderName of foldersToRemove) {
await System.deleteFolder(folderName);
}
await fetchKeys(true);
setSelectedItems({});
} catch (error) {
console.error("Failed to delete the document:", error);
console.error("Failed to delete files and folders:", error);
} finally {
setLoading(false);
setSelectedItems({});
@ -59,10 +89,11 @@ function Directory({
const newSelectedItems = { ...prevSelectedItems };
if (item.type === "folder") {
const isCurrentlySelected = isFolderCompletelySelected(item);
if (isCurrentlySelected) {
if (newSelectedItems[item.name]) {
delete newSelectedItems[item.name];
item.items.forEach((file) => delete newSelectedItems[file.id]);
} else {
newSelectedItems[item.name] = true;
item.items.forEach((file) => (newSelectedItems[file.id] = true));
}
} else {
@ -78,7 +109,7 @@ function Directory({
};
const isFolderCompletelySelected = (folder) => {
if (folder.items.length === 0) {
if (!selectedItems[folder.name]) {
return false;
}
return folder.items.every((file) => selectedItems[file.id]);
@ -92,23 +123,108 @@ function Directory({
return !!selectedItems[id];
};
useEffect(() => {
setAmountSelected(Object.keys(selectedItems).length);
}, [selectedItems]);
const createNewFolder = () => {
setShowNewFolderInput(true);
};
const confirmNewFolder = async () => {
if (newFolderName.trim() !== "") {
const newFolder = {
name: newFolderName,
type: "folder",
items: [],
};
// If folder failed to create - silently fail.
const { success } = await Document.createFolder(newFolderName);
if (success) {
setFiles({
...files,
items: [...files.items, newFolder],
});
}
setNewFolderName("");
setShowNewFolderInput(false);
}
};
const moveToFolder = async (folder) => {
const toMove = [];
for (const itemId of Object.keys(selectedItems)) {
for (const currentFolder of files.items) {
const foundItem = currentFolder.items.find(
(file) => file.id === itemId
);
if (foundItem) {
toMove.push({ ...foundItem, folderName: currentFolder.name });
break;
}
}
}
setLoading(true);
setLoadingMessage(`Moving ${toMove.length} documents. Please wait.`);
const { success, message } = await Document.moveToFolder(
toMove,
folder.name
);
if (!success) {
showToast(`Error moving files: ${message}`, "error");
setLoading(false);
return;
}
if (success && message) {
showToast(message, "info");
} else {
showToast(`Successfully moved ${toMove.length} documents.`, "success");
}
await fetchKeys(true);
setSelectedItems({});
setLoading(false);
};
return (
<div className="px-8 pb-8">
<div className="flex flex-col gap-y-6">
<div className="flex items-center justify-between w-[560px] px-5">
<div className="flex items-center justify-between w-[560px] px-5 relative">
<h3 className="text-white text-base font-bold">My Documents</h3>
{showNewFolderInput ? (
<div className="flex items-center gap-x-2 z-50">
<input
type="text"
placeholder="Folder name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-[150px] h-[32px]"
/>
<div className="flex gap-x-2">
<button
onClick={confirmNewFolder}
className="text-sky-400 rounded-md text-sm font-bold hover:text-sky-500"
>
Create
</button>
</div>
</div>
) : (
<button
className="flex items-center gap-x-2 cursor-pointer z-50 px-[14px] py-[7px] -mr-[14px] rounded-lg hover:bg-[#222628]/60"
onClick={createNewFolder}
>
<Plus size={18} weight="bold" color="#D3D4D4" />
<div className="text-[#D3D4D4] text-xs font-bold leading-[18px]">
New Folder
</div>
</button>
)}
</div>
<div className="relative w-[560px] h-[310px] bg-zinc-900 rounded-2xl">
<div className="rounded-t-2xl text-white/80 text-xs grid grid-cols-12 py-2 px-8 border-b border-white/20 shadow-lg bg-zinc-900 sticky top-0 z-10">
<p className="col-span-5">Name</p>
<p className="col-span-6">Name</p>
<p className="col-span-3">Date</p>
<p className="col-span-2">Kind</p>
<p className="col-span-2">Cached</p>
</div>
<div
@ -122,11 +238,10 @@ function Directory({
{loadingMessage}
</p>
</div>
) : !!files.items ? (
) : files.items ? (
files.items.map(
(item, index) =>
(item.name === "custom-documents" ||
(item.type === "folder" && item.items.length > 0)) && (
item.type === "folder" && (
<FolderRow
key={index}
item={item}
@ -134,12 +249,9 @@ function Directory({
item.id,
item.type === "folder" ? item : null
)}
fetchKeys={fetchKeys}
onRowClick={() => toggleSelection(item)}
toggleSelection={toggleSelection}
isSelected={isSelected}
setLoading={setLoading}
setLoadingMessage={setLoadingMessage}
autoExpanded={index === 0}
/>
)
@ -154,24 +266,41 @@ function Directory({
</div>
{amountSelected !== 0 && (
<div className="absolute bottom-0 left-0 w-full flex justify-between items-center h-9 bg-white rounded-b-2xl">
<div className="flex gap-x-5 w-[80%] justify-center">
<div className="w-full justify-center absolute bottom-[12px] flex">
<div className="justify-center flex flex-row items-center bg-white/40 rounded-lg py-1 px-2 gap-x-2">
<button
onClick={moveToWorkspace}
onMouseEnter={() => setHighlightWorkspace(true)}
onMouseLeave={() => setHighlightWorkspace(false)}
onClick={moveToWorkspace}
className="border-none text-sm font-semibold h-7 px-2.5 rounded-lg hover:text-white hover:bg-neutral-800/80 flex items-center"
className="border-none text-sm font-semibold bg-white h-[30px] px-2.5 rounded-lg hover:text-white hover:bg-neutral-800/80"
>
Move {amountSelected} {pluralize("file", amountSelected)} to
workspace
Move to Workspace
</button>
<div className="relative">
<button
onClick={() => setShowFolderSelection(!showFolderSelection)}
className="border-none text-sm font-semibold bg-white h-[32px] w-[32px] rounded-lg text-[#222628] hover:bg-neutral-800/80 flex justify-center items-center group"
>
<MoveToFolderIcon className="text-[#222628] group-hover:text-white" />
</button>
{showFolderSelection && (
<FolderSelectionPopup
folders={files.items.filter(
(item) => item.type === "folder"
)}
onSelect={moveToFolder}
onClose={() => setShowFolderSelection(false)}
/>
)}
</div>
<button
onClick={deleteFiles}
className="border-none text-sm font-semibold bg-white h-[32px] w-[32px] rounded-lg text-[#222628] hover:text-white hover:bg-neutral-800/80 flex justify-center items-center"
>
<Trash size={18} weight="bold" />
</button>
</div>
<button
onClick={deleteFiles}
className="border-none text-red-500/50 text-sm font-semibold h-7 px-2.5 rounded-lg hover:text-red-500/80 flex items-center"
>
Delete
</button>
</div>
)}
</div>

View File

@ -191,9 +191,10 @@ export default function DocumentSettings({ workspace, systemSettings }) {
};
return (
<div className="flex gap-x-6 justify-center -mt-6 z-10 relative">
<div className="flex gap-x-6 justify-center -mt-6 z-10">
<Directory
files={availableDocs}
setFiles={setAvailableDocs}
loading={loading}
loadingMessage={loadingMessage}
setLoading={setLoading}

View File

@ -64,9 +64,9 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
return (
<div className="w-screen h-screen fixed top-0 left-0 flex justify-center items-center z-99">
<div className="backdrop h-full w-full absolute top-0 z-10" />
<div className={`absolute max-h-full w-fit transition duration-300 z-20`}>
<div className="absolute max-h-full w-fit transition duration-300 z-20">
<div className="relative bg-main-gradient rounded-[12px] shadow border-2 border-slate-300/10">
<div className="flex items-start justify-between p-2 rounded-t border-gray-500/50 z-40 relative">
<div className="flex items-start justify-between p-2 rounded-t border-gray-500/50 z-20 relative">
<button
onClick={hideModal}
type="button"

View File

@ -598,18 +598,18 @@ dialog::backdrop {
color: #fff;
}
.file-row:nth-child(odd) {
.file-row:nth-child(even) {
@apply bg-[#1C1E21];
}
.file-row:nth-child(even) {
.file-row:nth-child(odd) {
@apply bg-[#2C2C2C];
}
.file-row.selected:nth-child(odd) {
.file-row.selected:nth-child(even) {
@apply bg-sky-500/20;
}
.file-row.selected:nth-child(even) {
.file-row.selected:nth-child(odd) {
@apply bg-sky-500/10;
}

View File

@ -0,0 +1,38 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const Document = {
createFolder: async (name) => {
return await fetch(`${API_BASE}/document/create-folder`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ name }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
moveToFolder: async (files, folderName) => {
const data = {
files: files.map((file) => ({
from: file.folderName ? `${file.folderName}/${file.name}` : file.name,
to: `${folderName}/${file.name}`,
})),
};
return await fetch(`${API_BASE}/document/move-files`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify(data),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
};
export default Document;

View File

@ -4,11 +4,15 @@ const { setupMulter } = require("../../../utils/files/multer");
const {
viewLocalFiles,
findDocumentInDocuments,
normalizePath,
} = require("../../../utils/files");
const { reqBody } = require("../../../utils/http");
const { EventLogs } = require("../../../models/eventLogs");
const { CollectorApi } = require("../../../utils/collectorApi");
const { handleUploads } = setupMulter();
const fs = require("fs");
const path = require("path");
const { Document } = require("../../../models/documents");
function apiDocumentEndpoints(app) {
if (!app) return;
@ -552,6 +556,181 @@ function apiDocumentEndpoints(app) {
response.sendStatus(500).end();
}
});
app.post(
"/v1/document/create-folder",
[validApiKey],
async (request, response) => {
/*
#swagger.tags = ['Documents']
#swagger.description = 'Create a new folder inside the documents storage directory.'
#swagger.requestBody = {
description: 'Name of the folder to create.',
required: true,
type: 'object',
content: {
"application/json": {
schema: {
type: 'object',
example: {
"name": "new-folder"
}
}
}
}
}
#swagger.responses[200] = {
content: {
"application/json": {
schema: {
type: 'object',
example: {
success: true,
message: null
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
}
}
*/
try {
const { name } = reqBody(request);
const storagePath = path.join(
__dirname,
"../../../storage/documents",
normalizePath(name)
);
if (fs.existsSync(storagePath)) {
response.status(500).json({
success: false,
message: "Folder by that name already exists",
});
return;
}
fs.mkdirSync(storagePath, { recursive: true });
response.status(200).json({ success: true, message: null });
} catch (e) {
console.error(e);
response.status(500).json({
success: false,
message: `Failed to create folder: ${e.message}`,
});
}
}
);
app.post(
"/v1/document/move-files",
[validApiKey],
async (request, response) => {
/*
#swagger.tags = ['Documents']
#swagger.description = 'Move files within the documents storage directory.'
#swagger.requestBody = {
description: 'Array of objects containing source and destination paths of files to move.',
required: true,
type: 'object',
content: {
"application/json": {
schema: {
type: 'object',
example: {
"files": [
{
"from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json",
"to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
}
]
}
}
}
}
}
#swagger.responses[200] = {
content: {
"application/json": {
schema: {
type: 'object',
example: {
success: true,
message: null
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
}
}
*/
try {
const { files } = reqBody(request);
const docpaths = files.map(({ from }) => from);
const documents = await Document.where({ docpath: { in: docpaths } });
const embeddedFiles = documents.map((doc) => doc.docpath);
const moveableFiles = files.filter(
({ from }) => !embeddedFiles.includes(from)
);
const movePromises = moveableFiles.map(({ from, to }) => {
const sourcePath = path.join(
__dirname,
"../../../storage/documents",
normalizePath(from)
);
const destinationPath = path.join(
__dirname,
"../../../storage/documents",
normalizePath(to)
);
return new Promise((resolve, reject) => {
fs.rename(sourcePath, destinationPath, (err) => {
if (err) {
console.error(`Error moving file ${from} to ${to}:`, err);
reject(err);
} else {
resolve();
}
});
});
});
Promise.all(movePromises)
.then(() => {
const unmovableCount = files.length - moveableFiles.length;
if (unmovableCount > 0) {
response.status(200).json({
success: true,
message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`,
});
} else {
response.status(200).json({
success: true,
message: null,
});
}
})
.catch((err) => {
console.error("Error moving files:", err);
response
.status(500)
.json({ success: false, message: "Failed to move some files." });
});
} catch (e) {
console.error(e);
response
.status(500)
.json({ success: false, message: "Failed to move files." });
}
}
);
}
module.exports = { apiDocumentEndpoints };

View File

@ -0,0 +1,115 @@
const { Document } = require("../models/documents");
const { normalizePath } = require("../utils/files");
const { reqBody } = require("../utils/http");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const fs = require("fs");
const path = require("path");
function documentEndpoints(app) {
if (!app) return;
app.post(
"/document/create-folder",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const { name } = reqBody(request);
const storagePath = path.join(
__dirname,
"../storage/documents",
normalizePath(name)
);
if (fs.existsSync(storagePath)) {
response.status(500).json({
success: false,
message: "Folder by that name already exists",
});
return;
}
fs.mkdirSync(storagePath, { recursive: true });
response.status(200).json({ success: true, message: null });
} catch (e) {
console.error(e);
response.status(500).json({
success: false,
message: `Failed to create folder: ${e.message} `,
});
}
}
);
app.post(
"/document/move-files",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const { files } = reqBody(request);
const docpaths = files.map(({ from }) => from);
const documents = await Document.where({ docpath: { in: docpaths } });
const embeddedFiles = documents.map((doc) => doc.docpath);
const moveableFiles = files.filter(
({ from }) => !embeddedFiles.includes(from)
);
const movePromises = moveableFiles.map(({ from, to }) => {
const sourcePath = path.join(
__dirname,
"../storage/documents",
normalizePath(from)
);
const destinationPath = path.join(
__dirname,
"../storage/documents",
normalizePath(to)
);
return new Promise((resolve, reject) => {
fs.rename(sourcePath, destinationPath, (err) => {
if (err) {
console.error(`Error moving file ${from} to ${to}:`, err);
reject(err);
} else {
resolve();
}
});
});
});
Promise.all(movePromises)
.then(() => {
const unmovableCount = files.length - moveableFiles.length;
if (unmovableCount > 0) {
response.status(200).json({
success: true,
message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`,
});
} else {
response.status(200).json({
success: true,
message: null,
});
}
})
.catch((err) => {
console.error("Error moving files:", err);
response
.status(500)
.json({ success: false, message: "Failed to move some files." });
});
} catch (e) {
console.error(e);
response
.status(500)
.json({ success: false, message: "Failed to move files." });
}
}
);
}
module.exports = { documentEndpoints };

View File

@ -20,6 +20,7 @@ const { developerEndpoints } = require("./endpoints/api");
const { extensionEndpoints } = require("./endpoints/extensions");
const { bootHTTP, bootSSL } = require("./utils/boot");
const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
const { documentEndpoints } = require("./endpoints/document");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@ -44,6 +45,7 @@ adminEndpoints(apiRouter);
inviteEndpoints(apiRouter);
embedManagementEndpoints(apiRouter);
utilEndpoints(apiRouter);
documentEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
// Externally facing embedder endpoints

View File

@ -1340,6 +1340,143 @@
}
}
},
"/v1/document/create-folder": {
"post": {
"tags": [
"Documents"
],
"description": "Create a new folder inside the documents storage directory.",
"parameters": [
{
"name": "Authorization",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"example": {
"success": true,
"message": null
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"description": "Name of the folder to create.",
"required": true,
"type": "object",
"content": {
"application/json": {
"schema": {
"type": "object",
"example": {
"name": "new-folder"
}
}
}
}
}
}
},
"/v1/document/move-files": {
"post": {
"tags": [
"Documents"
],
"description": "Move files within the documents storage directory.",
"parameters": [
{
"name": "Authorization",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"example": {
"success": true,
"message": null
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/InvalidAPIKey"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"description": "Array of objects containing source and destination paths of files to move.",
"required": true,
"type": "object",
"content": {
"application/json": {
"schema": {
"type": "object",
"example": {
"files": [
{
"from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json",
"to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
}
]
}
}
}
}
}
}
},
"/v1/workspace/new": {
"post": {
"tags": [

View File

@ -66,6 +66,12 @@ async function viewLocalFiles() {
}
}
// Make sure custom-documents is always the first folder in picker
directory.items = [
directory.items.find((folder) => folder.name === "custom-documents"),
...directory.items.filter((folder) => folder.name !== "custom-documents"),
];
return directory;
}