@noya-app/noya-file-explorer 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +13 -13
- package/CHANGELOG.md +27 -0
- package/dist/index.css +916 -843
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +11207 -45
- package/dist/index.d.ts +11207 -45
- package/dist/index.js +1361 -160
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1399 -156
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/MediaCollection.tsx +156 -97
- package/src/ResourceExplorer.tsx +1141 -0
- package/src/__tests__/deleteMediaItems.test.ts +7 -7
- package/src/__tests__/getDepthMap.test.ts +7 -7
- package/src/__tests__/getParentDirectories.test.ts +6 -6
- package/src/__tests__/getVisibleItems.test.ts +9 -9
- package/src/__tests__/moveMediaInsideFolder.test.ts +11 -11
- package/src/__tests__/movePathsIntoTarget.test.ts +9 -9
- package/src/__tests__/moveUpAFolder.test.ts +7 -7
- package/src/__tests__/renameMediaItemAndDescendantPaths.test.ts +6 -6
- package/src/__tests__/updateExpandedMap.test.ts +8 -8
- package/src/__tests__/validateMediaItemRename.test.ts +11 -11
- package/src/index.ts +2 -1
- package/src/utils/files.ts +28 -37
- package/src/utils/handleFileDrop.ts +165 -0
- package/src/utils/resourceUtils.ts +329 -0
- package/src/formatByteSize.ts +0 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MediaMap } from "@noya-app/noya-schemas";
|
|
2
2
|
import { expect, it } from "bun:test";
|
|
3
|
-
import {
|
|
3
|
+
import { mediaValidateMediaItemRename } from "../utils/files";
|
|
4
4
|
import { createMediaAsset, createMediaFolder } from "../utils/mediaItemTree";
|
|
5
5
|
|
|
6
6
|
it("should return true for valid rename with no conflicts", () => {
|
|
@@ -11,7 +11,7 @@ it("should return true for valid rename with no conflicts", () => {
|
|
|
11
11
|
"dir1/file1.jpg": file1,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const result =
|
|
14
|
+
const result = mediaValidateMediaItemRename({
|
|
15
15
|
basename: "newname.jpg",
|
|
16
16
|
selectedItemPath: "dir1/file1.jpg",
|
|
17
17
|
media: mediaMap,
|
|
@@ -30,7 +30,7 @@ it("should return false when a file with the same name already exists", () => {
|
|
|
30
30
|
"dir1/file2.jpg": file2,
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
const result =
|
|
33
|
+
const result = mediaValidateMediaItemRename({
|
|
34
34
|
basename: "file2.jpg",
|
|
35
35
|
selectedItemPath: "dir1/file1.jpg",
|
|
36
36
|
media: mediaMap,
|
|
@@ -47,7 +47,7 @@ it("should return false when renaming a file to its own name", () => {
|
|
|
47
47
|
"dir1/file1.jpg": file1,
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
const result =
|
|
50
|
+
const result = mediaValidateMediaItemRename({
|
|
51
51
|
basename: "file1.jpg",
|
|
52
52
|
selectedItemPath: "dir1/file1.jpg",
|
|
53
53
|
media: mediaMap,
|
|
@@ -64,7 +64,7 @@ it("should return true when renaming a folder with no conflicts", () => {
|
|
|
64
64
|
"dir1/dir2": dir2,
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
const result =
|
|
67
|
+
const result = mediaValidateMediaItemRename({
|
|
68
68
|
basename: "newdir",
|
|
69
69
|
selectedItemPath: "dir1/dir2",
|
|
70
70
|
media: mediaMap,
|
|
@@ -83,7 +83,7 @@ it("should return false when a folder with the same name already exists", () =>
|
|
|
83
83
|
"dir1/dir3": dir3,
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
const result =
|
|
86
|
+
const result = mediaValidateMediaItemRename({
|
|
87
87
|
basename: "dir3",
|
|
88
88
|
selectedItemPath: "dir1/dir2",
|
|
89
89
|
media: mediaMap,
|
|
@@ -100,7 +100,7 @@ it("should handle special characters in basename", () => {
|
|
|
100
100
|
"dir1/file1.jpg": file1,
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
const result =
|
|
103
|
+
const result = mediaValidateMediaItemRename({
|
|
104
104
|
basename: "new-file@name.jpg",
|
|
105
105
|
selectedItemPath: "dir1/file1.jpg",
|
|
106
106
|
media: mediaMap,
|
|
@@ -121,7 +121,7 @@ it("should return true when renaming to a name that exists in a different direct
|
|
|
121
121
|
"dir2/file2.jpg": file2,
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
-
const result =
|
|
124
|
+
const result = mediaValidateMediaItemRename({
|
|
125
125
|
basename: "file2.jpg",
|
|
126
126
|
selectedItemPath: "dir1/file1.jpg",
|
|
127
127
|
media: mediaMap,
|
|
@@ -138,7 +138,7 @@ it("should return false when trying to rename to an empty basename", () => {
|
|
|
138
138
|
"dir1/file1.jpg": file1,
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
-
const result =
|
|
141
|
+
const result = mediaValidateMediaItemRename({
|
|
142
142
|
basename: "",
|
|
143
143
|
selectedItemPath: "dir1/file1.jpg",
|
|
144
144
|
media: mediaMap,
|
|
@@ -159,7 +159,7 @@ it("should handle deep nested directories", () => {
|
|
|
159
159
|
"dir1/dir2/dir3/file1.jpg": file1,
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
-
const result =
|
|
162
|
+
const result = mediaValidateMediaItemRename({
|
|
163
163
|
basename: "renamed.jpg",
|
|
164
164
|
selectedItemPath: "dir1/dir2/dir3/file1.jpg",
|
|
165
165
|
media: mediaMap,
|
|
@@ -189,7 +189,7 @@ it("should return false when basename contains invalid filesystem characters", (
|
|
|
189
189
|
];
|
|
190
190
|
|
|
191
191
|
invalidNames.forEach((basename) => {
|
|
192
|
-
const result =
|
|
192
|
+
const result = mediaValidateMediaItemRename({
|
|
193
193
|
basename,
|
|
194
194
|
selectedItemPath: "dir1/file1.jpg",
|
|
195
195
|
media: mediaMap,
|
package/src/index.ts
CHANGED
package/src/utils/files.ts
CHANGED
|
@@ -6,27 +6,28 @@ import {
|
|
|
6
6
|
MediaItemTree,
|
|
7
7
|
rootMediaItem,
|
|
8
8
|
} from "./mediaItemTree";
|
|
9
|
+
import { basenameValidator } from "./resourceUtils";
|
|
9
10
|
|
|
10
|
-
export type
|
|
11
|
-
export type
|
|
11
|
+
export type MediaFileKindFilter = "assets" | "directories" | "all";
|
|
12
|
+
export type MediaExpandedMap = Record<string, boolean | undefined>;
|
|
12
13
|
|
|
13
|
-
export type
|
|
14
|
-
expandedMap:
|
|
15
|
-
fileKindFilter:
|
|
14
|
+
export type MediaGetVisibleItemsOptions = {
|
|
15
|
+
expandedMap: MediaExpandedMap;
|
|
16
|
+
fileKindFilter: MediaFileKindFilter;
|
|
16
17
|
rootItemId: string;
|
|
17
18
|
tree: MediaItemTree;
|
|
18
19
|
showAllDescendants: boolean;
|
|
19
20
|
showRootItem: boolean;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
export const
|
|
23
|
+
export const mediaGetVisibleItems = ({
|
|
23
24
|
expandedMap,
|
|
24
25
|
fileKindFilter,
|
|
25
26
|
rootItemId,
|
|
26
27
|
tree,
|
|
27
28
|
showAllDescendants,
|
|
28
29
|
showRootItem,
|
|
29
|
-
}:
|
|
30
|
+
}: MediaGetVisibleItemsOptions) => {
|
|
30
31
|
const filteredItems: MediaItem[] = [];
|
|
31
32
|
const relativeRootItem =
|
|
32
33
|
tree.find(rootMediaItem, (item) => item.id === rootItemId) ?? rootMediaItem;
|
|
@@ -38,6 +39,9 @@ export const getVisibleItems = ({
|
|
|
38
39
|
}
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
42
|
+
if (item.kind === "noyaFile" && fileKindFilter === "all") {
|
|
43
|
+
filteredItems.push(item);
|
|
44
|
+
}
|
|
41
45
|
if (item.kind === "file" && fileKindFilter === "all") {
|
|
42
46
|
filteredItems.push(item);
|
|
43
47
|
}
|
|
@@ -58,23 +62,7 @@ export const getVisibleItems = ({
|
|
|
58
62
|
return filteredItems;
|
|
59
63
|
};
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
* Validates if a basename follows typical file naming conventions
|
|
63
|
-
* Disallows:
|
|
64
|
-
* - Path separators (/ and \)
|
|
65
|
-
* - Characters typically invalid in filesystems (<, >, :, ", |, ?, *)
|
|
66
|
-
* - Empty names
|
|
67
|
-
*/
|
|
68
|
-
export const basenameValidator = (basename: string): boolean => {
|
|
69
|
-
// Check for empty string
|
|
70
|
-
if (!basename || basename.trim() === "") return false;
|
|
71
|
-
|
|
72
|
-
// Simple regex matching common invalid filesystem characters
|
|
73
|
-
const invalidCharsRegex = /[/\\<>:"|?*]/;
|
|
74
|
-
return !invalidCharsRegex.test(basename);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export const validateMediaItemRename = ({
|
|
65
|
+
export const mediaValidateMediaItemRename = ({
|
|
78
66
|
basename,
|
|
79
67
|
selectedItemPath,
|
|
80
68
|
media,
|
|
@@ -91,7 +79,7 @@ export const validateMediaItemRename = ({
|
|
|
91
79
|
return true;
|
|
92
80
|
};
|
|
93
81
|
|
|
94
|
-
export const
|
|
82
|
+
export const mediaMovePathsIntoTarget = ({
|
|
95
83
|
media,
|
|
96
84
|
sourceItemPaths,
|
|
97
85
|
targetItemPath,
|
|
@@ -123,7 +111,7 @@ export const movePathsIntoTarget = ({
|
|
|
123
111
|
ancestorPath,
|
|
124
112
|
newAncestorPath
|
|
125
113
|
);
|
|
126
|
-
const newPathIsValid =
|
|
114
|
+
const newPathIsValid = mediaValidateMediaItemRename({
|
|
127
115
|
basename: path.basename(descendantPath),
|
|
128
116
|
selectedItemPath: newDescendantPath,
|
|
129
117
|
media,
|
|
@@ -137,7 +125,7 @@ export const movePathsIntoTarget = ({
|
|
|
137
125
|
return mediaClone;
|
|
138
126
|
};
|
|
139
127
|
|
|
140
|
-
export const
|
|
128
|
+
export const mediaMoveUpAFolder = ({
|
|
141
129
|
tree,
|
|
142
130
|
media,
|
|
143
131
|
selectedIds,
|
|
@@ -163,7 +151,7 @@ export const moveUpAFolder = ({
|
|
|
163
151
|
const sourceItemPaths = selectedIds
|
|
164
152
|
.map((id) => tree.idToPathMap.get(id))
|
|
165
153
|
.filter((path): path is string => Boolean(path));
|
|
166
|
-
return
|
|
154
|
+
return mediaMovePathsIntoTarget({
|
|
167
155
|
media,
|
|
168
156
|
targetItemPath: grandparentFolderPath,
|
|
169
157
|
sourceItemPaths,
|
|
@@ -171,7 +159,7 @@ export const moveUpAFolder = ({
|
|
|
171
159
|
});
|
|
172
160
|
};
|
|
173
161
|
|
|
174
|
-
export const
|
|
162
|
+
export const mediaGetDepthMap = (
|
|
175
163
|
item: MediaItem,
|
|
176
164
|
tree: MediaItemTree,
|
|
177
165
|
showAllDescendants: boolean
|
|
@@ -187,7 +175,7 @@ export const getDepthMap = (
|
|
|
187
175
|
return depthMap;
|
|
188
176
|
};
|
|
189
177
|
|
|
190
|
-
export const
|
|
178
|
+
export const mediaUpdateExpandedMap = ({
|
|
191
179
|
item,
|
|
192
180
|
expanded,
|
|
193
181
|
expandable,
|
|
@@ -197,9 +185,9 @@ export const updateExpandedMap = ({
|
|
|
197
185
|
item: MediaItem;
|
|
198
186
|
expanded: boolean;
|
|
199
187
|
expandable: boolean;
|
|
200
|
-
expandedMap:
|
|
188
|
+
expandedMap: MediaExpandedMap;
|
|
201
189
|
tree: MediaItemTree;
|
|
202
|
-
}):
|
|
190
|
+
}): MediaExpandedMap => {
|
|
203
191
|
const newExpandedMap = { ...expandedMap };
|
|
204
192
|
const inner = (item: MediaItem, expanded: boolean) => {
|
|
205
193
|
if (!expandable) return {};
|
|
@@ -216,7 +204,7 @@ export const updateExpandedMap = ({
|
|
|
216
204
|
return newExpandedMap;
|
|
217
205
|
};
|
|
218
206
|
|
|
219
|
-
export const
|
|
207
|
+
export const mediaDeleteMediaItems = ({
|
|
220
208
|
selectedIds,
|
|
221
209
|
media,
|
|
222
210
|
tree,
|
|
@@ -241,7 +229,7 @@ export const deleteMediaItems = ({
|
|
|
241
229
|
);
|
|
242
230
|
};
|
|
243
231
|
|
|
244
|
-
export const
|
|
232
|
+
export const mediaMoveMediaInsideFolder = ({
|
|
245
233
|
sourceItemIds,
|
|
246
234
|
targetItemId,
|
|
247
235
|
media,
|
|
@@ -259,7 +247,7 @@ export const moveMediaInsideFolder = ({
|
|
|
259
247
|
.map((id) => tree.idToPathMap.get(id))
|
|
260
248
|
.filter((path): path is string => Boolean(path));
|
|
261
249
|
|
|
262
|
-
return
|
|
250
|
+
return mediaMovePathsIntoTarget({
|
|
263
251
|
media,
|
|
264
252
|
sourceItemPaths,
|
|
265
253
|
targetItemPath,
|
|
@@ -267,7 +255,10 @@ export const moveMediaInsideFolder = ({
|
|
|
267
255
|
});
|
|
268
256
|
};
|
|
269
257
|
|
|
270
|
-
export const
|
|
258
|
+
export const mediaGetParentDirectories = (
|
|
259
|
+
mediaMap: MediaMap,
|
|
260
|
+
folderId: string
|
|
261
|
+
) => {
|
|
271
262
|
const tree = createMediaItemTree(mediaMap);
|
|
272
263
|
|
|
273
264
|
const indexPath = tree.findPath(
|
|
@@ -280,7 +271,7 @@ export const getParentDirectories = (mediaMap: MediaMap, folderId: string) => {
|
|
|
280
271
|
return tree.accessPath(rootMediaItem, indexPath);
|
|
281
272
|
};
|
|
282
273
|
|
|
283
|
-
export const
|
|
274
|
+
export const mediaRenameMediaItemAndDescendantPaths = ({
|
|
284
275
|
newName,
|
|
285
276
|
selectedItemPath,
|
|
286
277
|
media,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAssetResource,
|
|
3
|
+
createDirectoryResource,
|
|
4
|
+
Resource,
|
|
5
|
+
ResourceCreate,
|
|
6
|
+
ResourceCreateMap,
|
|
7
|
+
ResourceMap,
|
|
8
|
+
} from "@noya-app/noya-schemas";
|
|
9
|
+
import { Base64, uuid } from "@noya-app/noya-utils";
|
|
10
|
+
import { path } from "imfs";
|
|
11
|
+
|
|
12
|
+
function isDirectoryEntry(
|
|
13
|
+
entry: FileSystemEntry
|
|
14
|
+
): entry is FileSystemDirectoryEntry {
|
|
15
|
+
return entry.isDirectory;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isFileEntry(entry: FileSystemEntry): entry is FileSystemFileEntry {
|
|
19
|
+
return entry.isFile;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function handleDataTransfer({
|
|
23
|
+
accessibleByFileId,
|
|
24
|
+
dataTransfer,
|
|
25
|
+
rootItemPath,
|
|
26
|
+
resourceMap,
|
|
27
|
+
}: {
|
|
28
|
+
accessibleByFileId: string;
|
|
29
|
+
dataTransfer: DataTransfer;
|
|
30
|
+
rootItemPath: string;
|
|
31
|
+
resourceMap: ResourceMap;
|
|
32
|
+
}): Promise<ResourceCreateMap | undefined> {
|
|
33
|
+
try {
|
|
34
|
+
const dataTransferItems = Array.from(dataTransfer.items ?? []);
|
|
35
|
+
|
|
36
|
+
const supportsEntries = dataTransferItems.some(
|
|
37
|
+
(item) => typeof item?.webkitGetAsEntry === "function"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
type DroppedFile = { file: File; relativePath: string };
|
|
41
|
+
|
|
42
|
+
const collectFromEntry = async (
|
|
43
|
+
entry: FileSystemEntry,
|
|
44
|
+
basePath: string
|
|
45
|
+
): Promise<DroppedFile[]> => {
|
|
46
|
+
if (!entry) return [];
|
|
47
|
+
if (isFileEntry(entry)) {
|
|
48
|
+
const file: File = await new Promise((resolve) =>
|
|
49
|
+
(entry as FileSystemFileEntry).file((f: File) => resolve(f))
|
|
50
|
+
);
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
file,
|
|
54
|
+
relativePath: path.join(basePath, file.name),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (isDirectoryEntry(entry)) {
|
|
59
|
+
const reader = entry.createReader();
|
|
60
|
+
const readAll = async (): Promise<FileSystemEntry[]> => {
|
|
61
|
+
const all: FileSystemEntry[] = [];
|
|
62
|
+
// readEntries may return partial results; loop until empty
|
|
63
|
+
// eslint-disable-next-line no-constant-condition
|
|
64
|
+
while (true) {
|
|
65
|
+
const entries: FileSystemEntry[] = await new Promise((resolve) =>
|
|
66
|
+
reader.readEntries((ents: FileSystemEntry[]) => resolve(ents))
|
|
67
|
+
);
|
|
68
|
+
if (!entries.length) break;
|
|
69
|
+
all.push(...entries);
|
|
70
|
+
}
|
|
71
|
+
return all;
|
|
72
|
+
};
|
|
73
|
+
const children = await readAll();
|
|
74
|
+
const results = await Promise.all(
|
|
75
|
+
children.map((child) =>
|
|
76
|
+
collectFromEntry(child, path.join(basePath, entry.name))
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
return results.flat();
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let dropped: DroppedFile[] = [];
|
|
85
|
+
|
|
86
|
+
if (supportsEntries) {
|
|
87
|
+
const topLevelEntries = dataTransferItems.flatMap((item) => {
|
|
88
|
+
const entry = item.webkitGetAsEntry?.();
|
|
89
|
+
if (!entry) return [];
|
|
90
|
+
return [entry];
|
|
91
|
+
});
|
|
92
|
+
const nested = await Promise.all(
|
|
93
|
+
topLevelEntries.map((entry) => collectFromEntry(entry, ""))
|
|
94
|
+
);
|
|
95
|
+
dropped = nested.flat();
|
|
96
|
+
} else {
|
|
97
|
+
const files = Array.from(dataTransfer.files);
|
|
98
|
+
if (files.length === 0) return;
|
|
99
|
+
dropped = files.map((file) => ({
|
|
100
|
+
file,
|
|
101
|
+
// Best effort: try webkitRelativePath; fall back to name
|
|
102
|
+
relativePath:
|
|
103
|
+
file.webkitRelativePath && file.webkitRelativePath.length > 0
|
|
104
|
+
? file.webkitRelativePath
|
|
105
|
+
: file.name,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (dropped.length === 0) return;
|
|
110
|
+
|
|
111
|
+
const folderRelativePaths = new Set<string>();
|
|
112
|
+
for (const { relativePath } of dropped) {
|
|
113
|
+
const dir = path.dirname(relativePath);
|
|
114
|
+
if (dir && dir !== ".") {
|
|
115
|
+
const parts = dir.split("/").filter(Boolean);
|
|
116
|
+
let acc = "";
|
|
117
|
+
for (const part of parts) {
|
|
118
|
+
acc = acc ? path.join(acc, part) : part;
|
|
119
|
+
folderRelativePaths.add(acc);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const folderEntries: [string, Resource][] = Array.from(folderRelativePaths)
|
|
125
|
+
.map((rel) => path.join(rootItemPath, rel))
|
|
126
|
+
.filter((full) => !resourceMap[full])
|
|
127
|
+
.map((full) => [
|
|
128
|
+
full,
|
|
129
|
+
createDirectoryResource({
|
|
130
|
+
id: uuid(),
|
|
131
|
+
path: full,
|
|
132
|
+
accessibleByFileId,
|
|
133
|
+
stableId: uuid(),
|
|
134
|
+
}) as Resource,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const uploadResults = await Promise.all(
|
|
138
|
+
dropped.map(
|
|
139
|
+
async ({ file, relativePath }): Promise<[string, ResourceCreate]> => {
|
|
140
|
+
return [
|
|
141
|
+
path.join(rootItemPath, relativePath),
|
|
142
|
+
createAssetResource({
|
|
143
|
+
id: uuid(),
|
|
144
|
+
asset: {
|
|
145
|
+
content: Base64.encode(await file.arrayBuffer()),
|
|
146
|
+
contentType: file.type,
|
|
147
|
+
encoding: "base64",
|
|
148
|
+
},
|
|
149
|
+
path: path.join(rootItemPath, relativePath),
|
|
150
|
+
accessibleByFileId,
|
|
151
|
+
stableId: uuid(),
|
|
152
|
+
}) as Resource,
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
...resourceMap,
|
|
160
|
+
...Object.fromEntries([...folderEntries, ...uploadResults]),
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error("Failed to upload dropped files:", error);
|
|
164
|
+
}
|
|
165
|
+
}
|