@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.
@@ -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
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@repo/typescript-config/library.json"
3
+ }
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
+ });