diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..91e6bfc --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "File Graph Yarn Jest", + "runtimeExecutable": "yarn", + "args": ["test", "--runInBand"], + "cwd": "${workspaceFolder}/packages/file-graph", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} diff --git a/packages/file-graph/errors.js b/packages/file-graph/errors.js new file mode 100644 index 0000000..966cd57 --- /dev/null +++ b/packages/file-graph/errors.js @@ -0,0 +1,21 @@ +/* eslint-disable max-classes-per-file */ +export class DuplicateItemError extends Error { + constructor(message) { + super(message) + this.name = 'DuplicateItemError' + } +} + +export class ParentNotExistsError extends Error { + constructor(message) { + super(message) + this.name = 'ParentNotExistsError' + } +} + +export class FileNotExistsError extends Error { + constructor(message) { + super(message) + this.name = 'FileNotExistsError' + } +} diff --git a/packages/file-graph/file.js b/packages/file-graph/file.js new file mode 100644 index 0000000..986e280 --- /dev/null +++ b/packages/file-graph/file.js @@ -0,0 +1,18 @@ +import { basename } from './utils.js' + +export default class File { + constructor({ path, type, discovered }) { + this.path = path + this.name = basename(path) + this.type = type + this.discovered = discovered + } + + isDirectory() { + return this.type === 'folder' + } + + contains(file) { + return this.isDirectory() && file.path.startsWith(this.path) + } +} diff --git a/packages/file-graph/fileGraph.js b/packages/file-graph/fileGraph.js new file mode 100644 index 0000000..611fc4c --- /dev/null +++ b/packages/file-graph/fileGraph.js @@ -0,0 +1,57 @@ +import FileNode from './fileNode.js' + +export default class FileGraph { + constructor(root) { + this.root = new FileNode(root) + } + + add(files) { + // Logic to add the file node to the graph + // You can implement the logic to find the appropriate location in the file graph + // If the file node already exists, throw an error + + files.forEach((file) => { + this.root.addChild(file, { makeParents: true }) + }) + } + + remove(files) { + // Logic to remove a file node from the graph + // If the file node is a folder, also remove all children + // If the file node doesn't exist, throw an error + + files.forEach((file) => { + this.root.removeChild(file) + }) + } + + discover(eventFiles) { + // Logic to handle the discover event + // Overwrite the file graph with the provided files + // Remove file nodes that don't exist anymore and add new ones + + const newNode = new FileNode(eventFiles[0]) + + eventFiles.slice(1).forEach((file) => { + newNode.addChild(file, { makeParents: true }) + }) + + this.root.merge(newNode) + } + + handleEvent(event) { + // eslint-disable-next-line default-case + switch (true) { + case !!event.add: + this.add(event.add) + break + case !!event.remove: + this.remove(event.remove) + break + case !!event.discover: + this.discover(event.discover) + break + // Implement other cases if needed + } + } +} diff --git a/packages/file-graph/fileGraph.test.js b/packages/file-graph/fileGraph.test.js new file mode 100644 index 0000000..7f6447a --- /dev/null +++ b/packages/file-graph/fileGraph.test.js @@ -0,0 +1,149 @@ +import FileGraph from './fileGraph.js' +import FileNode from './fileNode.js' + +describe('FileGraph', () => { + let root + + beforeEach(() => { + root = new FileNode({ + path: '/', + type: 'folder', + discovered: true, + }) + }) + + test('add files to graph', () => { + const fileGraph = new FileGraph(root) + + const filesToAdd = [ + { + path: '/home/user/file1.txt', + type: 'file', + discovered: true, + }, + { + path: '/home/user/folder1', + type: 'folder', + discovered: true, + }, + ] + + fileGraph.add(filesToAdd) + + console.log(fileGraph.root.toString()) + expect(fileGraph.root.children.length).toBe(1) + expect(fileGraph.root.children[0].children[0].children.length).toBe(2) + }) + + test('remove files from graph', () => { + const fileGraph = new FileGraph(root) + + const filesToAdd = [ + { + path: '/home/user/folder1', + type: 'folder', + discovered: true, + }, + { + path: '/home/user/folder2', + type: 'folder', + discovered: true, + }, + { + path: '/home/user/folder1/file1.txt', + type: 'file', + discovered: true, + }, + { + path: '/home/user/folder2/file2.txt', + type: 'file', + discovered: true, + }, + ] + + fileGraph.add(filesToAdd) + + const filesToRemove = [ + { + path: '/home/user/folder1/file1.txt', + type: 'folder', + }, + { + path: '/home/user/folder2', + type: 'folder', + }, + ] + + fileGraph.remove(filesToRemove) + + console.log(fileGraph.root.toString()) + expect(fileGraph.root.children[0].children[0].children.length).toBe(1) + expect(fileGraph.root.children[0].children[0].children[0].name).toBe( + 'folder1' + ) + expect( + fileGraph.root.children[0].children[0].children[0].children.length + ).toBe(0) + }) + + test('discover new files', () => { + const fileGraph = new FileGraph(root) + + const filesToAdd = [ + { + path: '/home/user/folder1', + type: 'folder', + discovered: true, + }, + { + path: '/home/user/folder2', + type: 'folder', + discovered: true, + }, + { + path: '/home/user/folder1/file1.txt', + type: 'file', + discovered: true, + }, + { + path: '/home/user/folder2/file2.txt', + type: 'file', + discovered: true, + }, + ] + + fileGraph.add(filesToAdd) + console.log(fileGraph.root.toString()) + + const newFiles = [ + { + path: '/home/user/folder1', + type: 'folder', + discovered: true, + }, + { + path: '/home/user/folder1/file1.txt', + type: 'file', + discovered: true, + }, + { + path: '/home/user/folder1/file2.txt', + type: 'file', + discovered: true, + }, + { + path: '/home/user/folder1/file3.txt', + type: 'file', + discovered: true, + }, + ] + + fileGraph.discover(newFiles) + + console.log(fileGraph.root.toString()) + expect(fileGraph.root.children[0].children[0].children.length).toBe(2) + expect( + fileGraph.root.children[0].children[0].children[0].children.length + ).toBe(3) + }) +}) diff --git a/packages/file-graph/fileNode.js b/packages/file-graph/fileNode.js new file mode 100644 index 0000000..96fc60c --- /dev/null +++ b/packages/file-graph/fileNode.js @@ -0,0 +1,119 @@ +import File from './file.js' +import { + DuplicateItemError, + ParentNotExistsError, + FileNotExistsError, +} from './errors.js' +import { basename, dirname } from './utils.js' + +export default class FileNode extends File { + constructor(file) { + super(file) + this.children = file.children || [] + } + + addChild(fileInput, { makeParents } = {}) { + const file = new FileNode(fileInput) + if (file.path === this.path) { + throw new DuplicateItemError( + `File ${file.path} already exists in the tree: ${this.toString()}` + ) + } + + if (this.children.some((child) => child.path === file.path)) { + throw new DuplicateItemError( + `File ${file.path} already exists in the tree: ${this.toString()}` + ) + } + + const parentPath = dirname(file.path) + if (parentPath === this.path) { + // Direct child of this node + this.children.push(file) + } else { + // Recursively call addChild on the correct child node + const childNode = this.children.find((child) => child.contains(file)) + if (childNode) { + childNode.addChild(file) + } else if (makeParents) { + const parent = new FileNode({ + path: parentPath, + name: basename(parentPath), + type: 'folder', + discovered: false, + children: [file], + }) + this.addChild(parent, { makeParents }) + } else { + throw new ParentNotExistsError( + `Parent path not found in the tree, ${file.path}, ${this.path}` + ) + } + } + } + + removeChild(fileToRemove) { + const file = new FileNode(fileToRemove) + const parentPath = dirname(file.path) + + if (parentPath === this.path) { + // Direct child of this node + this.children = this.children.filter((child) => child.path !== file.path) + } else { + // Recursively call removeChild on the correct child node + const childNode = this.children.find((child) => child.contains(file)) + if (childNode) { + childNode.removeChild(file) + } else { + throw new FileNotExistsError( + `File ${file.path} does not exist in the tree: ${this.toString()}` + ) + } + } + } + + merge(fileNode) { + // Merge the children of the provided file node into this node + // If a child already exists, merge the children of that child + // If a child does not exist, add it to this node + if (fileNode.path === this.path) { + fileNode.children.forEach((child) => { + const existingChild = this.children.find((c) => c.path === child.path) + if (existingChild) { + existingChild.merge(child) + } else { + this.children.push(child) + } + }) + + // If a child exists in this node but not in the provided node, remove it + if (fileNode.children.length > 0) { + this.children = this.children.filter((child) => + fileNode.children.some((c) => c.path === child.path) + ) + } + } else if (this.contains(fileNode)) { + const childNode = this.children.find((child) => child.contains(fileNode)) + if (childNode) { + childNode.merge(fileNode) + } else { + this.addChild(fileNode) + } + } else { + throw new Error( + 'Cannot merge file nodes with different paths', + this.path, + fileNode.path + ) + } + } + + toString() { + const indent = ' '.repeat(this.path.split('/').length - 1) + const suffix = this.isDirectory() ? '/' : '' + const childrenString = this.children + .map((child) => child.toString()) + .join('') + return `${indent}${this.name}${suffix}\n${childrenString}` + } +} diff --git a/packages/file-graph/fileNode.test.js b/packages/file-graph/fileNode.test.js new file mode 100644 index 0000000..b58e38f --- /dev/null +++ b/packages/file-graph/fileNode.test.js @@ -0,0 +1,221 @@ +import { DuplicateItemError, ParentNotExistsError } from './errors.js' +import FileNode from './fileNode.js' + +function createNodeFromFiles(files) { + const node = new FileNode(files[0]) + files.slice(1).forEach((file) => node.addChild(file)) + return node +} + +function mergeTest(files1, files2, expectedFiles) { + const node1 = createNodeFromFiles(files1) + const node2 = createNodeFromFiles(files2) + const expectedNode = createNodeFromFiles(expectedFiles) + + console.log('node1', node1.toString()) + console.log('node2', node2.toString()) + console.log('expected', expectedNode.toString()) + + node1.merge(node2) + + console.log('after-merged', node1.toString()) + + expect(node1.toString()).toBe(expectedNode.toString()) +} + +function removeChildTest(files, fileToRemove, expectedFiles) { + const node = createNodeFromFiles(files) + const expectedNode = createNodeFromFiles(expectedFiles) + + node.removeChild(fileToRemove) + + expect(node.toString()).toBe(expectedNode.toString()) +} + +describe('FileNode', () => { + let rootNode + + beforeEach(() => { + rootNode = new FileNode({ + path: '/home/rudeus', + name: 'rudeus', + type: 'folder', + discovered: true, + }) + }) + + test('should throw error when adding duplicate item', () => { + const child = { + path: '/home/rudeus/test', + name: 'test', + type: 'folder', + } + + // Adding the child for the first time + rootNode.addChild(child) + + expect(rootNode.children.length).toBe(1) + + // Trying to add the same child again should throw an error + expect(() => { + rootNode.addChild(child) + }).toThrow(DuplicateItemError) + }) + + test('should throw error when parent does not exist', () => { + const child = { + path: '/home/rudeus/alphabet/test', + name: 'test', + type: 'folder', + } + + // Trying to add to a nonexistent parent should throw an error + expect(() => { + rootNode.addChild(child) + }).toThrow(ParentNotExistsError) + }) + + test('adds nested children to the tree', () => { + const files = [ + { + path: '/home/rudeus/forgotten_scroll.txt', + name: 'forgotten_scroll.txt', + type: 'file', + discovered: 'test', + }, + { + path: '/home/rudeus/test', + name: 'test', + type: 'folder', + discovered: 'test', + }, + { + path: '/home/rudeus/test/a', + name: 'a', + type: 'file', + discovered: false, + }, + ] + + files.forEach((file) => { + rootNode.addChild(file) + }) + + expect(rootNode.children.length).toBe(2) + expect(rootNode.children[1].children.length).toBe(1) + console.log(rootNode.toString()) + }) + + test('adds nested children to the tree with makeParents option', () => { + const file = { + path: '/home/rudeus/test/a/b/c/d/e', + name: 'a', + type: 'file', + discovered: false, + } + + rootNode.addChild(file, { makeParents: true }) + + console.log(rootNode.toString()) + let currentNode = rootNode + for (let i = 0; i < 5; i += 1) { + expect(currentNode.children.length).toBe(1) + // eslint-disable-next-line prefer-destructuring + currentNode = currentNode.children[0] + } + }) + + test('merge-subtree', () => { + const root = { + path: '/', + type: 'folder', + } + const files1 = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/d', type: 'file' }, + { path: '/b/c/f', type: 'file' }, + ] + + const files2 = [ + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/e', type: 'file' }, + { path: '/b/c/g', type: 'file' }, + ] + + const expected = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/e', type: 'file' }, + { path: '/b/c/g', type: 'file' }, + ] + + mergeTest(files1, files2, expected) + }) + + test('merge-middle-level', () => { + // Test if the node to merge is only the middle part of the tree + // (i.e. the node to merge doesn't contains all level of children) + // The original tree should keep the children that are not in the node to merge + const root = { + path: '/', + type: 'folder', + } + const files1 = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/d', type: 'file' }, + { path: '/b/c/f', type: 'file' }, + ] + + const files2 = [ + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/e', type: 'file' }, + ] + + const expected = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/e', type: 'file' }, + { path: '/b/c/f', type: 'file' }, + ] + + mergeTest(files1, files2, expected) + }) + + test('remove-child', () => { + const root = { + path: '/', + type: 'folder', + } + const files = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/c', type: 'folder' }, + { path: '/b/d', type: 'file' }, + { path: '/b/c/f', type: 'file' }, + ] + + const fileToRemove = { path: '/b/c', type: 'folder' } + + const expected = [ + root, + { path: '/a', type: 'file' }, + { path: '/b', type: 'folder' }, + { path: '/b/d', type: 'file' }, + ] + + removeChildTest(files, fileToRemove, expected) + }) +}) diff --git a/packages/file-graph/package.json b/packages/file-graph/package.json new file mode 100644 index 0000000..69b2535 --- /dev/null +++ b/packages/file-graph/package.json @@ -0,0 +1,10 @@ +{ + "name": "file-graph", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "scripts": { + "test": "yarn node --experimental-vm-modules $(yarn bin jest)" + } +} diff --git a/packages/file-graph/utils.js b/packages/file-graph/utils.js new file mode 100644 index 0000000..48fffd9 --- /dev/null +++ b/packages/file-graph/utils.js @@ -0,0 +1,13 @@ +export function dirname(path) { + const parts = path.split('/') + if (parts.length <= 2) { + return '/' + } + parts.pop() + return parts.join('/') +} + +export function basename(path) { + const parts = path.split('/') + return parts.pop() +}