@noya-app/noya-file-explorer 0.0.2
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/.eslintrc.js +11 -0
- package/.turbo/turbo-build.log +24 -0
- package/CHANGELOG.md +14 -0
- package/README.md +3 -0
- package/dist/index.css +1811 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +23279 -0
- package/dist/index.d.ts +23279 -0
- package/dist/index.js +1854 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1857 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/MediaCollection.tsx +839 -0
- package/src/__tests__/deleteMediaItems.test.ts +125 -0
- package/src/__tests__/getDepthMap.test.ts +84 -0
- package/src/__tests__/getParentDirectories.test.ts +68 -0
- package/src/__tests__/getVisibleItems.test.ts +184 -0
- package/src/__tests__/moveMediaInsideFolder.test.ts +348 -0
- package/src/__tests__/movePathsIntoTarget.test.ts +229 -0
- package/src/__tests__/moveUpAFolder.test.ts +179 -0
- package/src/__tests__/updateExpandedMap.test.ts +157 -0
- package/src/__tests__/validateMediaItemRename.test.ts +200 -0
- package/src/index.css +1 -0
- package/src/index.ts +4 -0
- package/src/utils/files.ts +274 -0
- package/src/utils/mediaItemTree.ts +110 -0
- package/tsconfig.json +3 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { MediaItem, MediaMap } from "@noya-app/noya-schemas";
|
|
2
|
+
import { path } from "imfs";
|
|
3
|
+
import { ancestorPaths } from "tree-visit";
|
|
4
|
+
import {
|
|
5
|
+
createMediaItemTree,
|
|
6
|
+
MediaItemTree,
|
|
7
|
+
rootMediaItem,
|
|
8
|
+
} from "./mediaItemTree";
|
|
9
|
+
|
|
10
|
+
export type FileKindFilter = "assets" | "directories" | "all";
|
|
11
|
+
export type ExpandedMap = Record<string, boolean | undefined>;
|
|
12
|
+
|
|
13
|
+
export type GetVisibleItemsOptions = {
|
|
14
|
+
expandedMap: ExpandedMap;
|
|
15
|
+
fileKindFilter: FileKindFilter;
|
|
16
|
+
rootItemId: string;
|
|
17
|
+
tree: MediaItemTree;
|
|
18
|
+
showAllDescendants: boolean;
|
|
19
|
+
showRootItem: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getVisibleItems = ({
|
|
23
|
+
expandedMap,
|
|
24
|
+
fileKindFilter,
|
|
25
|
+
rootItemId,
|
|
26
|
+
tree,
|
|
27
|
+
showAllDescendants,
|
|
28
|
+
showRootItem,
|
|
29
|
+
}: GetVisibleItemsOptions) => {
|
|
30
|
+
const filteredItems: MediaItem[] = [];
|
|
31
|
+
const relativeRootItem =
|
|
32
|
+
tree.find(rootMediaItem, (item) => item.id === rootItemId) ?? rootMediaItem;
|
|
33
|
+
|
|
34
|
+
tree.visit(relativeRootItem, (item) => {
|
|
35
|
+
if (relativeRootItem.id === item.id) {
|
|
36
|
+
if (showRootItem) {
|
|
37
|
+
filteredItems.push(item);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (item.kind === "file" && fileKindFilter === "all") {
|
|
42
|
+
filteredItems.push(item);
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
item.kind === "asset" &&
|
|
46
|
+
(fileKindFilter === "assets" || fileKindFilter === "all")
|
|
47
|
+
) {
|
|
48
|
+
filteredItems.push(item);
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
item.kind === "folder" &&
|
|
52
|
+
(fileKindFilter === "directories" || fileKindFilter === "all")
|
|
53
|
+
) {
|
|
54
|
+
filteredItems.push(item);
|
|
55
|
+
}
|
|
56
|
+
if (!expandedMap[item.id] || !showAllDescendants) return "skip";
|
|
57
|
+
});
|
|
58
|
+
return filteredItems;
|
|
59
|
+
};
|
|
60
|
+
|
|
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 = ({
|
|
78
|
+
basename,
|
|
79
|
+
selectedItemPath,
|
|
80
|
+
media,
|
|
81
|
+
}: {
|
|
82
|
+
basename: string;
|
|
83
|
+
selectedItemPath: string;
|
|
84
|
+
media: MediaMap;
|
|
85
|
+
}): boolean => {
|
|
86
|
+
if (!basenameValidator(basename)) return false;
|
|
87
|
+
const newItemPath = path.join(path.dirname(selectedItemPath), basename);
|
|
88
|
+
// traverse the tree and check if there is already a path with the new name
|
|
89
|
+
const newPathExists = media[newItemPath];
|
|
90
|
+
if (newPathExists) return false;
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
export const movePathsIntoTarget = ({
|
|
94
|
+
media,
|
|
95
|
+
sourceItemPaths,
|
|
96
|
+
targetItemPath,
|
|
97
|
+
tree,
|
|
98
|
+
}: {
|
|
99
|
+
media: MediaMap;
|
|
100
|
+
sourceItemPaths: string[];
|
|
101
|
+
targetItemPath: string;
|
|
102
|
+
tree: MediaItemTree;
|
|
103
|
+
}) => {
|
|
104
|
+
const ancestors = ancestorPaths(
|
|
105
|
+
sourceItemPaths.map((path) => path.split("/"))
|
|
106
|
+
);
|
|
107
|
+
const mediaClone = { ...media };
|
|
108
|
+
for (const ancestor of ancestors) {
|
|
109
|
+
const ancestorPath = ancestor.join("/");
|
|
110
|
+
const ancestorItem = mediaClone[ancestorPath];
|
|
111
|
+
const newAncestorPath = path.join(
|
|
112
|
+
targetItemPath,
|
|
113
|
+
path.basename(ancestorPath)
|
|
114
|
+
);
|
|
115
|
+
if (!ancestorItem) continue;
|
|
116
|
+
const descendantPaths = tree
|
|
117
|
+
.flat(ancestorItem)
|
|
118
|
+
.map((item) => tree.idToPathMap.get(item.id));
|
|
119
|
+
for (const descendantPath of descendantPaths) {
|
|
120
|
+
if (!descendantPath) continue;
|
|
121
|
+
const newDescendantPath = descendantPath.replace(
|
|
122
|
+
ancestorPath,
|
|
123
|
+
newAncestorPath
|
|
124
|
+
);
|
|
125
|
+
const newPathIsValid = validateMediaItemRename({
|
|
126
|
+
basename: path.basename(descendantPath),
|
|
127
|
+
selectedItemPath: newDescendantPath,
|
|
128
|
+
media,
|
|
129
|
+
});
|
|
130
|
+
if (newPathIsValid) {
|
|
131
|
+
mediaClone[newDescendantPath] = mediaClone[descendantPath];
|
|
132
|
+
delete mediaClone[descendantPath];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return mediaClone;
|
|
137
|
+
};
|
|
138
|
+
export const moveUpAFolder = ({
|
|
139
|
+
tree,
|
|
140
|
+
media,
|
|
141
|
+
selectedIds,
|
|
142
|
+
}: {
|
|
143
|
+
tree: MediaItemTree;
|
|
144
|
+
media: MediaMap;
|
|
145
|
+
selectedIds: string[];
|
|
146
|
+
}) => {
|
|
147
|
+
const indexPath = tree.findIndexPath(
|
|
148
|
+
rootMediaItem,
|
|
149
|
+
(item) => item.id === selectedIds[0]
|
|
150
|
+
);
|
|
151
|
+
if (!indexPath) return;
|
|
152
|
+
|
|
153
|
+
// Get the grandparent folder (where items will be moved to)
|
|
154
|
+
const grandparentFolder = tree.access(
|
|
155
|
+
rootMediaItem,
|
|
156
|
+
indexPath.slice(0, indexPath.length - 2)
|
|
157
|
+
);
|
|
158
|
+
const grandparentFolderPath = tree.idToPathMap.get(grandparentFolder.id);
|
|
159
|
+
if (!grandparentFolderPath) return;
|
|
160
|
+
|
|
161
|
+
const sourceItemPaths = selectedIds
|
|
162
|
+
.map((id) => tree.idToPathMap.get(id))
|
|
163
|
+
.filter((path): path is string => Boolean(path));
|
|
164
|
+
return movePathsIntoTarget({
|
|
165
|
+
media,
|
|
166
|
+
targetItemPath: grandparentFolderPath,
|
|
167
|
+
sourceItemPaths,
|
|
168
|
+
tree,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
export const getDepthMap = (
|
|
172
|
+
item: MediaItem,
|
|
173
|
+
tree: MediaItemTree,
|
|
174
|
+
showAllDescendants: boolean
|
|
175
|
+
): Record<string, number> => {
|
|
176
|
+
const depthMap: Record<string, number> = {};
|
|
177
|
+
tree.visit(item, (item, indexPath) => {
|
|
178
|
+
if (showAllDescendants) {
|
|
179
|
+
depthMap[item.id] = Math.max(indexPath.length - 1, 0);
|
|
180
|
+
} else {
|
|
181
|
+
depthMap[item.id] = 0;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
return depthMap;
|
|
185
|
+
};
|
|
186
|
+
export const updateExpandedMap = ({
|
|
187
|
+
item,
|
|
188
|
+
expanded,
|
|
189
|
+
expandable,
|
|
190
|
+
expandedMap,
|
|
191
|
+
tree,
|
|
192
|
+
}: {
|
|
193
|
+
item: MediaItem;
|
|
194
|
+
expanded: boolean;
|
|
195
|
+
expandable: boolean;
|
|
196
|
+
expandedMap: ExpandedMap;
|
|
197
|
+
tree: MediaItemTree;
|
|
198
|
+
}): ExpandedMap => {
|
|
199
|
+
const newExpandedMap = { ...expandedMap };
|
|
200
|
+
const inner = (item: MediaItem, expanded: boolean) => {
|
|
201
|
+
if (!expandable) return {};
|
|
202
|
+
// Don't allow toggling the fake root
|
|
203
|
+
if (item.id === rootMediaItem.id) return {};
|
|
204
|
+
if (!expanded) {
|
|
205
|
+
// recursively collapse all children
|
|
206
|
+
const children = tree.getChildren(item, []);
|
|
207
|
+
children.forEach((child) => inner(child, false));
|
|
208
|
+
}
|
|
209
|
+
newExpandedMap[item.id] = expanded;
|
|
210
|
+
};
|
|
211
|
+
inner(item, expanded);
|
|
212
|
+
return newExpandedMap;
|
|
213
|
+
};
|
|
214
|
+
export const deleteMediaItems = ({
|
|
215
|
+
selectedIds,
|
|
216
|
+
media,
|
|
217
|
+
tree,
|
|
218
|
+
}: {
|
|
219
|
+
selectedIds: string[];
|
|
220
|
+
media: MediaMap;
|
|
221
|
+
tree: MediaItemTree;
|
|
222
|
+
}): MediaMap => {
|
|
223
|
+
const itemsToDelete = selectedIds.flatMap((mediaItemId) => {
|
|
224
|
+
const mediaItem = tree.mediaItemsWithRoot.find(
|
|
225
|
+
(item) => item.id === mediaItemId
|
|
226
|
+
);
|
|
227
|
+
if (!mediaItem) return [];
|
|
228
|
+
return tree.flat(mediaItem);
|
|
229
|
+
});
|
|
230
|
+
const itemKeysToDelete = new Set(
|
|
231
|
+
itemsToDelete.map((item) => tree.idToPathMap.get(item.id))
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return Object.fromEntries(
|
|
235
|
+
Object.entries(media).filter(([key]) => !itemKeysToDelete.has(key))
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
export const moveMediaInsideFolder = ({
|
|
239
|
+
sourceItemIds,
|
|
240
|
+
targetItemId,
|
|
241
|
+
media,
|
|
242
|
+
tree,
|
|
243
|
+
}: {
|
|
244
|
+
sourceItemIds: string[];
|
|
245
|
+
targetItemId: string;
|
|
246
|
+
media: MediaMap;
|
|
247
|
+
tree: MediaItemTree;
|
|
248
|
+
}) => {
|
|
249
|
+
const targetItemPath = tree.idToPathMap.get(targetItemId);
|
|
250
|
+
if (!targetItemPath) return media;
|
|
251
|
+
|
|
252
|
+
const sourceItemPaths = sourceItemIds
|
|
253
|
+
.map((id) => tree.idToPathMap.get(id))
|
|
254
|
+
.filter((path): path is string => Boolean(path));
|
|
255
|
+
|
|
256
|
+
return movePathsIntoTarget({
|
|
257
|
+
media,
|
|
258
|
+
sourceItemPaths,
|
|
259
|
+
targetItemPath,
|
|
260
|
+
tree,
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
export const getParentDirectories = (mediaMap: MediaMap, folderId: string) => {
|
|
264
|
+
const tree = createMediaItemTree(mediaMap);
|
|
265
|
+
|
|
266
|
+
const indexPath = tree.findIndexPath(
|
|
267
|
+
rootMediaItem,
|
|
268
|
+
(item) => item.id === folderId
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (!indexPath) return [rootMediaItem];
|
|
272
|
+
|
|
273
|
+
return tree.accessPath(rootMediaItem, indexPath);
|
|
274
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssetMediaItem,
|
|
3
|
+
FileMediaItem,
|
|
4
|
+
FolderMediaItem,
|
|
5
|
+
MediaItem,
|
|
6
|
+
MediaMap,
|
|
7
|
+
} from "@noya-app/noya-schemas";
|
|
8
|
+
import { uuid } from "@noya-app/noya-utils";
|
|
9
|
+
import { path } from "imfs";
|
|
10
|
+
import { defineTree } from "tree-visit";
|
|
11
|
+
|
|
12
|
+
export const createMediaFolder = (
|
|
13
|
+
folder?: Omit<FolderMediaItem, "kind" | "id">
|
|
14
|
+
): FolderMediaItem => {
|
|
15
|
+
return {
|
|
16
|
+
id: uuid(),
|
|
17
|
+
kind: "folder",
|
|
18
|
+
...folder,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const createMediaAsset = (
|
|
23
|
+
asset: Omit<AssetMediaItem, "kind" | "id">
|
|
24
|
+
): AssetMediaItem => {
|
|
25
|
+
return {
|
|
26
|
+
id: uuid(),
|
|
27
|
+
kind: "asset",
|
|
28
|
+
...asset,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const createMediaFile = (
|
|
33
|
+
file: Omit<FileMediaItem, "kind" | "id">
|
|
34
|
+
): FileMediaItem => {
|
|
35
|
+
return {
|
|
36
|
+
id: uuid(),
|
|
37
|
+
kind: "file",
|
|
38
|
+
...file,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const createMediaItem = (item: MediaItem) => {
|
|
43
|
+
return {
|
|
44
|
+
...item,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const rootMediaItem: MediaItem = {
|
|
49
|
+
id: "root",
|
|
50
|
+
kind: "folder",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const rootMediaItemPath = ".";
|
|
54
|
+
export const rootMediaItemName = "All Files";
|
|
55
|
+
export const PLACEHOLDER_ITEM_NAME = "placeholder";
|
|
56
|
+
|
|
57
|
+
export const createMediaItemTree = (mediaMap: MediaMap) => {
|
|
58
|
+
const sortedMediaMap = Object.fromEntries(
|
|
59
|
+
Object.entries(mediaMap).sort((a, b) => a[0].localeCompare(b[0]))
|
|
60
|
+
);
|
|
61
|
+
const parentToChildrenMap = new Map<string, MediaItem[]>();
|
|
62
|
+
const idToPathMap = new Map<string, string>();
|
|
63
|
+
idToPathMap.set(rootMediaItem.id, rootMediaItemPath);
|
|
64
|
+
|
|
65
|
+
for (const [itemPath, item] of Object.entries(sortedMediaMap)) {
|
|
66
|
+
const parentPath = path.dirname(itemPath);
|
|
67
|
+
const children = parentToChildrenMap.get(parentPath) ?? [];
|
|
68
|
+
children.push(item);
|
|
69
|
+
parentToChildrenMap.set(parentPath, children);
|
|
70
|
+
idToPathMap.set(item.id, itemPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tree = defineTree<MediaItem>({
|
|
74
|
+
getChildren: (item) => {
|
|
75
|
+
const itemPath =
|
|
76
|
+
item === rootMediaItem ? rootMediaItemPath : idToPathMap.get(item.id);
|
|
77
|
+
if (!itemPath) return [];
|
|
78
|
+
return parentToChildrenMap.get(itemPath) ?? [];
|
|
79
|
+
},
|
|
80
|
+
onDetectCycle: "skip",
|
|
81
|
+
getIdentifier: (item) => item.id,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = tree.withOptions({
|
|
85
|
+
getLabel: (item) => idToPathMap.get(item.id) ?? "",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const mediaItemsWithRoot = [...Object.values(sortedMediaMap), rootMediaItem];
|
|
89
|
+
const getNameForId = (id: string) =>
|
|
90
|
+
id === rootMediaItem.id
|
|
91
|
+
? rootMediaItemName
|
|
92
|
+
: path.basename(idToPathMap.get(id) ?? "");
|
|
93
|
+
const getParentIdForId = (id: string) => {
|
|
94
|
+
const itemPath = idToPathMap.get(id);
|
|
95
|
+
if (!itemPath) return;
|
|
96
|
+
const parentPath = path.dirname(itemPath);
|
|
97
|
+
return idToPathMap.get(parentPath);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...result,
|
|
102
|
+
idToPathMap,
|
|
103
|
+
parentToChildrenMap,
|
|
104
|
+
mediaItemsWithRoot,
|
|
105
|
+
getParentIdForId,
|
|
106
|
+
getNameForId,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type MediaItemTree = ReturnType<typeof createMediaItemTree>;
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fixReactVirtualized from "esbuild-plugin-react-virtualized";
|
|
2
|
+
import { defineConfig } from "tsup";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
entry: ["./src/index.ts"],
|
|
6
|
+
format: ["cjs", "esm"],
|
|
7
|
+
dts: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
noExternal: ["react-virtualized", "@noya-app/noya-designsystem/index.css"],
|
|
10
|
+
esbuildPlugins: [fixReactVirtualized],
|
|
11
|
+
// https://github.com/egoist/tsup/issues/619
|
|
12
|
+
splitting: false,
|
|
13
|
+
});
|