mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge pull request #19 from lancatlin/file-graph-algorithm
feat: Add file-graph-algorithm
This commit is contained in:
9 files changed
+623
No files matched your search
Vendored
+15
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user