Skip to content

Commit 738ccab

Browse files
refactor: moved helper functions from the FileManager component to a helper file- copyUtils
1 parent 888bf36 commit 738ccab

3 files changed

Lines changed: 181 additions & 172 deletions

File tree

app/components/FileManager.tsx

Lines changed: 2 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,6 @@
22

33
import { useState, useEffect } from "react";
44
import toast from "react-hot-toast";
5-
import {
6-
getFile,
7-
overwriteFile,
8-
createContainerAt,
9-
getSolidDataset,
10-
getContainedResourceUrlAll,
11-
UrlString,
12-
} from "@inrupt/solid-client";
135
import { useSearchParams, useRouter } from "next/navigation";
146
import AuthWrapper from "./AuthWrapper";
157
import Header from "./Header";
@@ -28,172 +20,10 @@ import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
2820
import {
2921
buildBreadcrumbItems,
3022
getAuthenticatedSession,
31-
getDisplayNameFromMeta,
32-
updateMetaFile,
23+
copyFileResource,
24+
copyFolderResource,
3325
} from "../lib/helpers";
3426

35-
const INVALID_NAME_CHARS = /[<>:"/\\|?*]/g;
36-
37-
const sanitizeResourceName = (name: string): string => {
38-
const sanitized = name.replace(INVALID_NAME_CHARS, "").trim();
39-
return sanitized || "Untitled";
40-
};
41-
42-
const decodeResourceNameFromUrl = (resourceUrl: string): string => {
43-
try {
44-
const urlObj = new URL(resourceUrl);
45-
const segments = urlObj.pathname.split("/").filter(Boolean);
46-
if (segments.length === 0) {
47-
return urlObj.hostname;
48-
}
49-
const lastSegment = resourceUrl.endsWith("/") ? segments[segments.length - 1] : segments[segments.length - 1];
50-
return decodeURIComponent(lastSegment);
51-
} catch {
52-
return resourceUrl;
53-
}
54-
};
55-
56-
const ensureTrailingSlash = (url: string): string => (url.endsWith("/") ? url : `${url}/`);
57-
58-
const getParentContainerUrl = (resourceUrl: string): string => {
59-
try {
60-
const urlObj = new URL(resourceUrl);
61-
const segments = urlObj.pathname.split("/").filter(Boolean);
62-
if (segments.length === 0) {
63-
return `${urlObj.origin}/`;
64-
}
65-
if (!resourceUrl.endsWith("/")) {
66-
segments.pop();
67-
} else if (segments.length > 0) {
68-
segments.pop();
69-
}
70-
const parentPath = segments.length ? `/${segments.join("/")}/` : "/";
71-
return `${urlObj.origin}${parentPath}`;
72-
} catch {
73-
return resourceUrl;
74-
}
75-
};
76-
77-
const shouldSkipResourceCopy = (resourceUrl: string): boolean => {
78-
return resourceUrl.endsWith(".meta") || resourceUrl.endsWith(".acl");
79-
};
80-
81-
const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
82-
try {
83-
const response = await fetchFn(url, { method: "HEAD" });
84-
if (response.status === 404) {
85-
return false;
86-
}
87-
if (response.status >= 200 && response.status < 300) {
88-
return true;
89-
}
90-
// For other statuses (401, 403, 405, etc.) assume the resource exists to avoid collisions
91-
return true;
92-
} catch {
93-
return false;
94-
}
95-
};
96-
97-
const generateCopyTarget = async (
98-
parentUrl: string,
99-
desiredName: string,
100-
isContainer: boolean,
101-
fetchFn: typeof fetch
102-
): Promise<{ targetUrl: string; displayName: string }> => {
103-
const parentWithSlash = ensureTrailingSlash(parentUrl);
104-
let attempt = 0;
105-
106-
while (attempt < 100) {
107-
const candidateDisplayName = attempt === 0 ? desiredName : `${desiredName} (${attempt})`;
108-
const candidatePathName = sanitizeResourceName(candidateDisplayName);
109-
const encodedName = encodeURIComponent(candidatePathName);
110-
const candidateUrl = isContainer ? `${parentWithSlash}${encodedName}/` : `${parentWithSlash}${encodedName}`;
111-
const exists = await resourceExists(candidateUrl, fetchFn);
112-
if (!exists) {
113-
return { targetUrl: candidateUrl, displayName: candidateDisplayName };
114-
}
115-
attempt += 1;
116-
}
117-
118-
throw new Error("Unable to generate a unique name for the copy");
119-
};
120-
121-
const copyFileFromSource = async (
122-
sourceUrl: string,
123-
targetUrl: string,
124-
displayName: string,
125-
fetchFn: typeof fetch,
126-
mimeTypeHint?: string
127-
): Promise<void> => {
128-
const fileBlob = await getFile(sourceUrl as UrlString, { fetch: fetchFn });
129-
const contentType = fileBlob.type || mimeTypeHint || "application/octet-stream";
130-
await overwriteFile(targetUrl as UrlString, fileBlob, {
131-
fetch: fetchFn,
132-
contentType,
133-
});
134-
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
135-
};
136-
137-
const copyFolderContents = async (
138-
sourceFolderUrl: string,
139-
destinationFolderUrl: string,
140-
fetchFn: typeof fetch
141-
): Promise<void> => {
142-
const dataset = await getSolidDataset(sourceFolderUrl, { fetch: fetchFn });
143-
const containedResources = getContainedResourceUrlAll(dataset);
144-
145-
for (const resourceUrl of containedResources) {
146-
if (shouldSkipResourceCopy(resourceUrl)) {
147-
continue;
148-
}
149-
150-
if (resourceUrl.endsWith("/")) {
151-
const childName = decodeResourceNameFromUrl(resourceUrl);
152-
const encodedChildName = encodeURIComponent(childName);
153-
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}/`;
154-
155-
await createContainerAt(childDestination as UrlString, { fetch: fetchFn });
156-
const childDisplayName =
157-
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
158-
await updateMetaFile(childDestination as UrlString, childDisplayName, fetchFn);
159-
160-
await copyFolderContents(resourceUrl, childDestination, fetchFn);
161-
} else {
162-
const childName = decodeResourceNameFromUrl(resourceUrl);
163-
const encodedChildName = encodeURIComponent(childName);
164-
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}`;
165-
const childDisplayName =
166-
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
167-
await copyFileFromSource(resourceUrl, childDestination, childDisplayName, fetchFn);
168-
}
169-
}
170-
};
171-
172-
const copyFileResource = async (file: FileItemData, fetchFn: typeof fetch): Promise<void> => {
173-
const originalLabel =
174-
(await getDisplayNameFromMeta(file.url, fetchFn)) ??
175-
file.name ??
176-
decodeResourceNameFromUrl(file.url);
177-
const parentUrl = getParentContainerUrl(file.url);
178-
const desiredName = `Copy of ${originalLabel}`;
179-
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, false, fetchFn);
180-
await copyFileFromSource(file.url, targetUrl, displayName, fetchFn, file.mimeType);
181-
};
182-
183-
const copyFolderResource = async (folder: FileItemData, fetchFn: typeof fetch): Promise<void> => {
184-
const originalLabel =
185-
(await getDisplayNameFromMeta(folder.url, fetchFn)) ??
186-
folder.name ??
187-
decodeResourceNameFromUrl(folder.url);
188-
const parentUrl = getParentContainerUrl(folder.url);
189-
const desiredName = `Copy of ${originalLabel}`;
190-
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, true, fetchFn);
191-
192-
await createContainerAt(targetUrl as UrlString, { fetch: fetchFn });
193-
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
194-
await copyFolderContents(folder.url, targetUrl, fetchFn);
195-
};
196-
19727

19828
export default function FileManager() {
19929
const searchParams = useSearchParams();

app/lib/helpers/copyUtils.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {
2+
getFile,
3+
overwriteFile,
4+
createContainerAt,
5+
getSolidDataset,
6+
getContainedResourceUrlAll,
7+
UrlString,
8+
} from "@inrupt/solid-client";
9+
import { getDisplayNameFromMeta, updateMetaFile } from "./metaFileUtils";
10+
11+
const INVALID_NAME_CHARS = /[<>:"/\\|?*]/g;
12+
13+
export const sanitizeResourceName = (name: string): string => {
14+
const sanitized = name.replace(INVALID_NAME_CHARS, "").trim();
15+
return sanitized || "Untitled";
16+
};
17+
18+
export const decodeResourceNameFromUrl = (resourceUrl: string): string => {
19+
try {
20+
const urlObj = new URL(resourceUrl);
21+
const segments = urlObj.pathname.split("/").filter(Boolean);
22+
if (segments.length === 0) {
23+
return urlObj.hostname;
24+
}
25+
const lastSegment = resourceUrl.endsWith("/") ? segments[segments.length - 1] : segments[segments.length - 1];
26+
return decodeURIComponent(lastSegment);
27+
} catch {
28+
return resourceUrl;
29+
}
30+
};
31+
32+
export const ensureTrailingSlash = (url: string): string => (url.endsWith("/") ? url : `${url}/`);
33+
34+
export const getParentContainerUrl = (resourceUrl: string): string => {
35+
try {
36+
const urlObj = new URL(resourceUrl);
37+
const segments = urlObj.pathname.split("/").filter(Boolean);
38+
if (segments.length === 0) {
39+
return `${urlObj.origin}/`;
40+
}
41+
if (!resourceUrl.endsWith("/")) {
42+
segments.pop();
43+
} else if (segments.length > 0) {
44+
segments.pop();
45+
}
46+
const parentPath = segments.length ? `/${segments.join("/")}/` : "/";
47+
return `${urlObj.origin}${parentPath}`;
48+
} catch {
49+
return resourceUrl;
50+
}
51+
};
52+
53+
const shouldSkipResourceCopy = (resourceUrl: string): boolean => {
54+
return resourceUrl.endsWith(".meta") || resourceUrl.endsWith(".acl");
55+
};
56+
57+
const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
58+
try {
59+
const response = await fetchFn(url, { method: "HEAD" });
60+
if (response.status === 404) {
61+
return false;
62+
}
63+
if (response.status >= 200 && response.status < 300) {
64+
return true;
65+
}
66+
// For other statuses (401, 403, 405, etc.) assume the resource exists to avoid collisions
67+
return true;
68+
} catch {
69+
return false;
70+
}
71+
};
72+
73+
export const generateCopyTarget = async (
74+
parentUrl: string,
75+
desiredName: string,
76+
isContainer: boolean,
77+
fetchFn: typeof fetch
78+
): Promise<{ targetUrl: string; displayName: string }> => {
79+
const parentWithSlash = ensureTrailingSlash(parentUrl);
80+
let attempt = 0;
81+
82+
while (attempt < 100) {
83+
const candidateDisplayName = attempt === 0 ? desiredName : `${desiredName} (${attempt})`;
84+
const candidatePathName = sanitizeResourceName(candidateDisplayName);
85+
const encodedName = encodeURIComponent(candidatePathName);
86+
const candidateUrl = isContainer ? `${parentWithSlash}${encodedName}/` : `${parentWithSlash}${encodedName}`;
87+
const exists = await resourceExists(candidateUrl, fetchFn);
88+
if (!exists) {
89+
return { targetUrl: candidateUrl, displayName: candidateDisplayName };
90+
}
91+
attempt += 1;
92+
}
93+
94+
throw new Error("Unable to generate a unique name for the copy");
95+
};
96+
97+
const copyFileFromSource = async (
98+
sourceUrl: string,
99+
targetUrl: string,
100+
displayName: string,
101+
fetchFn: typeof fetch,
102+
mimeTypeHint?: string
103+
): Promise<void> => {
104+
const fileBlob = await getFile(sourceUrl as UrlString, { fetch: fetchFn });
105+
const contentType = fileBlob.type || mimeTypeHint || "application/octet-stream";
106+
await overwriteFile(targetUrl as UrlString, fileBlob, {
107+
fetch: fetchFn,
108+
contentType,
109+
});
110+
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
111+
};
112+
113+
const copyFolderContents = async (
114+
sourceFolderUrl: string,
115+
destinationFolderUrl: string,
116+
fetchFn: typeof fetch
117+
): Promise<void> => {
118+
const dataset = await getSolidDataset(sourceFolderUrl, { fetch: fetchFn });
119+
const containedResources = getContainedResourceUrlAll(dataset);
120+
121+
for (const resourceUrl of containedResources) {
122+
if (shouldSkipResourceCopy(resourceUrl)) {
123+
continue;
124+
}
125+
126+
if (resourceUrl.endsWith("/")) {
127+
const childName = decodeResourceNameFromUrl(resourceUrl);
128+
const encodedChildName = encodeURIComponent(childName);
129+
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}/`;
130+
131+
await createContainerAt(childDestination as UrlString, { fetch: fetchFn });
132+
const childDisplayName =
133+
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
134+
await updateMetaFile(childDestination as UrlString, childDisplayName, fetchFn);
135+
136+
await copyFolderContents(resourceUrl, childDestination, fetchFn);
137+
} else {
138+
const childName = decodeResourceNameFromUrl(resourceUrl);
139+
const encodedChildName = encodeURIComponent(childName);
140+
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}`;
141+
const childDisplayName =
142+
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
143+
await copyFileFromSource(resourceUrl, childDestination, childDisplayName, fetchFn);
144+
}
145+
}
146+
};
147+
148+
export const copyFileResource = async (
149+
file: { url: string; name?: string; mimeType?: string },
150+
fetchFn: typeof fetch
151+
): Promise<void> => {
152+
const originalLabel =
153+
(await getDisplayNameFromMeta(file.url, fetchFn)) ??
154+
file.name ??
155+
decodeResourceNameFromUrl(file.url);
156+
const parentUrl = getParentContainerUrl(file.url);
157+
const desiredName = `Copy of ${originalLabel}`;
158+
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, false, fetchFn);
159+
await copyFileFromSource(file.url, targetUrl, displayName, fetchFn, file.mimeType);
160+
};
161+
162+
export const copyFolderResource = async (
163+
folder: { url: string; name?: string },
164+
fetchFn: typeof fetch
165+
): Promise<void> => {
166+
const originalLabel =
167+
(await getDisplayNameFromMeta(folder.url, fetchFn)) ??
168+
folder.name ??
169+
decodeResourceNameFromUrl(folder.url);
170+
const parentUrl = getParentContainerUrl(folder.url);
171+
const desiredName = `Copy of ${originalLabel}`;
172+
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, true, fetchFn);
173+
174+
await createContainerAt(targetUrl as UrlString, { fetch: fetchFn });
175+
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
176+
await copyFolderContents(folder.url, targetUrl, fetchFn);
177+
};
178+

app/lib/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export * from "./fileTypeUtils";
88
export * from "./profileUtils";
99
export * from "./sessionUtils";
1010
export * from "./metaFileUtils";
11+
export * from "./copyUtils";
1112

0 commit comments

Comments
 (0)