From 6e113a850efb509c8d48d6947947fcbeae068368 Mon Sep 17 00:00:00 2001 From: sharevb Date: Sun, 28 Apr 2024 12:33:09 +0200 Subject: [PATCH] feat(new tool): Folder Tree Diagram Fix #938 --- components.d.ts | 2 + package.json | 6 + pnpm-lock.yaml | 61 ++++++- .../folder-structure-diagram.vue | 44 +++++ src/tools/folder-structure-diagram/index.ts | 12 ++ .../lib/FileStructure.ts | 20 +++ .../lib/generate-tree.test.ts | 165 ++++++++++++++++++ .../lib/generate-tree.ts | 138 +++++++++++++++ .../lib/line-strings.ts | 33 ++++ .../lib/mock-input.ts | 19 ++ .../lib/parse-input.test.ts | 147 ++++++++++++++++ .../lib/parse-input.ts | 81 +++++++++ src/tools/index.ts | 2 + 13 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 src/tools/folder-structure-diagram/folder-structure-diagram.vue create mode 100644 src/tools/folder-structure-diagram/index.ts create mode 100644 src/tools/folder-structure-diagram/lib/FileStructure.ts create mode 100644 src/tools/folder-structure-diagram/lib/generate-tree.test.ts create mode 100644 src/tools/folder-structure-diagram/lib/generate-tree.ts create mode 100644 src/tools/folder-structure-diagram/lib/line-strings.ts create mode 100644 src/tools/folder-structure-diagram/lib/mock-input.ts create mode 100644 src/tools/folder-structure-diagram/lib/parse-input.test.ts create mode 100644 src/tools/folder-structure-diagram/lib/parse-input.ts diff --git a/components.d.ts b/components.d.ts index e31119b3..320ffeb8 100644 --- a/components.d.ts +++ b/components.d.ts @@ -78,6 +78,7 @@ declare module '@vue/runtime-core' { Encryption: typeof import('./src/tools/encryption/encryption.vue')['default'] EtaCalculator: typeof import('./src/tools/eta-calculator/eta-calculator.vue')['default'] FavoriteButton: typeof import('./src/components/FavoriteButton.vue')['default'] + FolderStructureDiagram: typeof import('./src/tools/folder-structure-diagram/folder-structure-diagram.vue')['default'] FormatTransformer: typeof import('./src/components/FormatTransformer.vue')['default'] GitMemo: typeof import('./src/tools/git-memo/git-memo.vue')['default'] 'GitMemo.content': typeof import('./src/tools/git-memo/git-memo.content.md')['default'] @@ -159,6 +160,7 @@ declare module '@vue/runtime-core' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] RsaKeyPairGenerator: typeof import('./src/tools/rsa-key-pair-generator/rsa-key-pair-generator.vue')['default'] + SafelinkDecoder: typeof import('./src/tools/safelink-decoder/safelink-decoder.vue')['default'] SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default'] SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default'] SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default'] diff --git a/package.json b/package.json index fd6c02e6..3135138c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "@tiptap/starter-kit": "2.1.6", "@tiptap/vue-3": "2.0.3", "@types/figlet": "^1.5.8", + "@types/lodash.defaultsdeep": "^4.6.9", + "@types/lodash.flattendeep": "^4.4.9", + "@types/lodash.last": "^3.0.9", "@vicons/material": "^0.12.0", "@vicons/tabler": "^0.12.0", "@vueuse/core": "^10.3.0", @@ -68,6 +71,9 @@ "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", "lodash": "^4.17.21", + "lodash.defaultsdeep": "^4.6.1", + "lodash.flattendeep": "^4.4.0", + "lodash.last": "^3.0.0", "marked": "^10.0.0", "mathjs": "^11.9.1", "mime-types": "^2.1.35", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd6c38c9..a6f3ab31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,15 @@ dependencies: '@types/figlet': specifier: ^1.5.8 version: 1.5.8 + '@types/lodash.defaultsdeep': + specifier: ^4.6.9 + version: 4.6.9 + '@types/lodash.flattendeep': + specifier: ^4.4.9 + version: 4.4.9 + '@types/lodash.last': + specifier: ^3.0.9 + version: 3.0.9 '@vicons/material': specifier: ^0.12.0 version: 0.12.0 @@ -104,6 +113,15 @@ dependencies: lodash: specifier: ^4.17.21 version: 4.17.21 + lodash.defaultsdeep: + specifier: ^4.6.1 + version: 4.6.1 + lodash.flattendeep: + specifier: ^4.4.0 + version: 4.4.0 + lodash.last: + specifier: ^3.0.0 + version: 3.0.0 marked: specifier: ^10.0.0 version: 10.0.0 @@ -2951,6 +2969,24 @@ packages: '@types/lodash': 4.14.192 dev: false + /@types/lodash.defaultsdeep@4.6.9: + resolution: {integrity: sha512-pLtCFK0YkHfGtGLYLNMTbFB5/G5+RsmQCIbbHH8GOAXjv+gDkVilY98kILfe8JH2Kev0OCReYxp1AjxEjP8ixA==} + dependencies: + '@types/lodash': 4.14.200 + dev: false + + /@types/lodash.flattendeep@4.4.9: + resolution: {integrity: sha512-Oacs/ZMuMvVWkhMqvj+Spad457Beln5pnkauif+6s65fE2cSL7J7NoMfwkxjuQsOsr4DUCDH/iDbmuZo81Nypw==} + dependencies: + '@types/lodash': 4.14.200 + dev: false + + /@types/lodash.last@3.0.9: + resolution: {integrity: sha512-HMIEyk2LJWDumrHsuSh7ZokUs+xyXa0nZO/s3paaKjOvwrjBNoQHsM0UqcpmzEt7CJ29a16XNe3vr8buvHeMCg==} + dependencies: + '@types/lodash': 4.14.200 + dev: false + /@types/lodash@4.14.192: resolution: {integrity: sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==} @@ -3351,7 +3387,7 @@ packages: dependencies: '@unhead/dom': 0.5.1 '@unhead/schema': 0.5.1 - '@vueuse/shared': 10.7.2(vue@3.3.4) + '@vueuse/shared': 10.9.0(vue@3.3.4) unhead: 0.5.1 vue: 3.3.4 transitivePeerDependencies: @@ -3993,10 +4029,10 @@ packages: - vue dev: false - /@vueuse/shared@10.7.2(vue@3.3.4): - resolution: {integrity: sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==} + /@vueuse/shared@10.9.0(vue@3.3.4): + resolution: {integrity: sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==} dependencies: - vue-demi: 0.14.6(vue@3.3.4) + vue-demi: 0.14.7(vue@3.3.4) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -6694,6 +6730,18 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.defaultsdeep@4.6.1: + resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} + dev: false + + /lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + dev: false + + /lodash.last@3.0.0: + resolution: {integrity: sha512-14mq7rSkCxG4XMy9lF2FbIOqqgF0aH0NfPuQ3LPR3vIh0kHnUvIYP70dqa1Hf47zyXfQ8FzAg0MYOQeSuE1R7A==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -9151,8 +9199,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.6(vue@3.3.4): - resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + /vue-demi@0.14.7(vue@3.3.4): + resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9442,6 +9490,7 @@ packages: /workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained dependencies: workbox-background-sync: 7.0.0 workbox-core: 7.0.0 diff --git a/src/tools/folder-structure-diagram/folder-structure-diagram.vue b/src/tools/folder-structure-diagram/folder-structure-diagram.vue new file mode 100644 index 00000000..c453d5ca --- /dev/null +++ b/src/tools/folder-structure-diagram/folder-structure-diagram.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/tools/folder-structure-diagram/index.ts b/src/tools/folder-structure-diagram/index.ts new file mode 100644 index 00000000..ddf8fcc1 --- /dev/null +++ b/src/tools/folder-structure-diagram/index.ts @@ -0,0 +1,12 @@ +import { Folder } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Folder Structure Diagram', + path: '/folder-structure-diagram', + description: 'tree-like utility for generating ASCII folder structure diagrams', + keywords: ['folder', 'structure', 'diagram', 'tree', 'ascii'], + component: () => import('./folder-structure-diagram.vue'), + icon: Folder, + createdAt: new Date('2024-04-20'), +}); diff --git a/src/tools/folder-structure-diagram/lib/FileStructure.ts b/src/tools/folder-structure-diagram/lib/FileStructure.ts new file mode 100644 index 00000000..aee589a5 --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/FileStructure.ts @@ -0,0 +1,20 @@ +/** + * Represents a single item in a file system + * (i.e. a file or a folder) + */ +export interface FileStructure { + /** The name of the file or folder */ + name: string + + /** If a folder, the contents of the folder */ + children: FileStructure[] + + /** + * The number of spaces in front of the name + * in the original source string + */ + indentCount: number + + /** The parent directory of this file or folder */ + parent: FileStructure | null +} diff --git a/src/tools/folder-structure-diagram/lib/generate-tree.test.ts b/src/tools/folder-structure-diagram/lib/generate-tree.test.ts new file mode 100644 index 00000000..8ab591a0 --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/generate-tree.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { generateTree } from './generate-tree'; +import { mockInput } from './mock-input'; +import { parseInput } from './parse-input'; + +describe('generateTree', () => { + it('returns an UTF-8 representation of the provided FileStructure object', () => { + const actual = generateTree(parseInput(mockInput)); + + const expected = ` +. +├── my-app +│ ├── src +│ │ ├── index.html +│ │ ├── main.ts +│ │ └── main.scss +│ ├── build +│ │ ├── index.html +│ │ ├── main.js +│ │ └── main.css +│ ├── .prettierrc.json +│ ├── .gitlab-ci.yml +│ └── README.md +└── empty dir + + `.trim(); + + expect(actual).toEqual(expected); + }); + + it('returns an ASCII representation of the provided FileStructure object', () => { + const actual = generateTree(parseInput(mockInput), { charset: 'ascii' }); + + const expected = ` +. +|-- my-app +| |-- src +| | |-- index.html +| | |-- main.ts +| | \`-- main.scss +| |-- build +| | |-- index.html +| | |-- main.js +| | \`-- main.css +| |-- .prettierrc.json +| |-- .gitlab-ci.yml +| \`-- README.md +\`-- empty dir + + `.trim(); + + expect(actual).toEqual(expected); + }); + + it('does not render lines for parent directories that have already printed all of their children', () => { + const input = ` + +grandparent + parent + child + parent + child + grandchild + + `; + + const actual = generateTree(parseInput(input)); + + const expected = ` +. +└── grandparent + ├── parent + │ └── child + └── parent + └── child + └── grandchild + + `.trim(); + + expect(actual).toEqual(expected); + }); + + it('appends a trailing slash to directories if trailingDirSlash === true', () => { + const input = ` + +grandparent + parent/ + child + parent// + child + grandchild + + `; + + const actual = generateTree(parseInput(input), { trailingDirSlash: true }); + + const expected = ` +. +└── grandparent/ + ├── parent/ + │ └── child + └── parent// + └── child/ + └── grandchild + + `.trim(); + + expect(actual).toEqual(expected); + }); + + it('prints each items\' full path if fullPath === true', () => { + const input = ` + +grandparent + parent/ + child + parent// + child + grandchild + + `; + + const actual = generateTree(parseInput(input), { fullPath: true }); + + const expected = ` +. +└── ./grandparent + ├── ./grandparent/parent/ + │ └── ./grandparent/parent/child + └── ./grandparent/parent// + └── ./grandparent/parent//child + └── ./grandparent/parent//child/grandchild + + `.trim(); + + expect(actual).toEqual(expected); + }); + + it('does not render the root dot if rootDot === false', () => { + const input = ` + +grandparent + parent + child + parent + child + grandchild + + `; + + const actual = generateTree(parseInput(input), { rootDot: false }); + + const expected = ` +grandparent +├── parent +│ └── child +└── parent + └── child + └── grandchild + + `.trim(); + + expect(actual).toEqual(expected); + }); +}); diff --git a/src/tools/folder-structure-diagram/lib/generate-tree.ts b/src/tools/folder-structure-diagram/lib/generate-tree.ts new file mode 100644 index 00000000..59f530cd --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/generate-tree.ts @@ -0,0 +1,138 @@ +import type { RecursiveArray } from 'lodash'; +import defaultsDeep from 'lodash.defaultsdeep'; +import flattenDeep from 'lodash.flattendeep'; +import last from 'lodash.last'; +import type { FileStructure } from './FileStructure'; +import { LINE_STRINGS } from './line-strings'; + +/** + * Represents all rendering options available + * when calling `generateTree` + */ +interface GenerateTreeOptions { + /** + * Which set of characters to use when + * rendering directory lines + */ + charset?: 'ascii' | 'utf-8' + + /** + * Whether or not to append trailing slashes + * to directories. Items that already include a + * trailing slash will not have another appended. + */ + trailingDirSlash?: boolean + + /** + * Whether or not to print the full + * path of the item + */ + fullPath?: boolean + + /** + * Whether or not to render a dot as the root of the tree + */ + rootDot?: boolean +} + +/** The default options if no options are provided */ +const defaultOptions: GenerateTreeOptions = { + charset: 'utf-8', + trailingDirSlash: false, + fullPath: false, + rootDot: true, +}; + +/** + * Generates an ASCII tree diagram, given a FileStructure + * @param structure The FileStructure object to convert into ASCII + * @param options The rendering options + */ +export function generateTree(structure: FileStructure, + options?: GenerateTreeOptions): string { + return flattenDeep([ + getAsciiLine(structure, defaultsDeep({}, options, defaultOptions)), + structure.children.map(c => generateTree(c, options)) as RecursiveArray< + string + >, + ]) + // Remove null entries. Should only occur for the very first node + // when `options.rootDot === false` + .filter(line => line != null) + .join('\n'); +} + +/** + * Returns a line of ASCII that represents + * a single FileStructure object + * @param structure The file to render + * @param options The rendering options + */ +function getAsciiLine(structure: FileStructure, + options: GenerateTreeOptions): string | null { + const lines = LINE_STRINGS[options.charset as string]; + + // Special case for the root element + if (!structure.parent) { + return options.rootDot ? structure.name : null; + } + + const chunks = [ + isLastChild(structure) ? lines.LAST_CHILD : lines.CHILD, + getName(structure, options), + ]; + + let current = structure.parent; + while (current && current.parent) { + chunks.unshift(isLastChild(current) ? lines.EMPTY : lines.DIRECTORY); + current = current.parent; + } + + // Join all the chunks together to create the final line. + // If we're not rendering the root `.`, chop off the first 4 characters. + return chunks.join('').substring(options.rootDot ? 0 : lines.CHILD.length); +} + +/** + * Returns the name of a file or folder according to the + * rules specified by the rendering rules + * @param structure The file or folder to get the name of + * @param options The rendering options + */ +function getName(structure: FileStructure, + options: GenerateTreeOptions): string { + const nameChunks = [structure.name]; + + // Optionally append a trailing slash + if ( + // if the trailing slash option is enabled + options.trailingDirSlash + // and if the item has at least one child + && structure.children.length > 0 + // and if the item doesn't already have a trailing slash + && !/\/\s*$/.test(structure.name) + ) { + nameChunks.push('/'); + } + + // Optionally prefix the name with its full path + if (options.fullPath && structure.parent && structure.parent) { + nameChunks.unshift( + getName( + structure.parent, + defaultsDeep({}, { trailingDirSlash: true }, options), + ), + ); + } + + return nameChunks.join(''); +} + +/** + * A utility function do determine if a file or folder + * is the last child of its parent + * @param structure The file or folder to test + */ +function isLastChild(structure: FileStructure): boolean { + return Boolean(structure.parent && last(structure.parent.children) === structure); +} diff --git a/src/tools/folder-structure-diagram/lib/line-strings.ts b/src/tools/folder-structure-diagram/lib/line-strings.ts new file mode 100644 index 00000000..8177233a --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/line-strings.ts @@ -0,0 +1,33 @@ +/** + * Represents an object that contains the + * actual strings used to render the tree + */ +export interface LineStringSet { + /** The string to render immediately before non-last children */ + CHILD: string + + /** The string to render immediately before the last child */ + LAST_CHILD: string + + /** The string to render for parent directories */ + DIRECTORY: string + + /** The string to render for empty space */ + EMPTY: string +} + +/** Contains all strings for tree rendering */ +export const LINE_STRINGS: { [charset: string]: LineStringSet } = { + 'ascii': { + CHILD: '|-- ', + LAST_CHILD: '`-- ', + DIRECTORY: '| ', + EMPTY: ' ', + }, + 'utf-8': { + CHILD: '├── ', + LAST_CHILD: '└── ', + DIRECTORY: '│ ', + EMPTY: ' ', + }, +}; diff --git a/src/tools/folder-structure-diagram/lib/mock-input.ts b/src/tools/folder-structure-diagram/lib/mock-input.ts new file mode 100644 index 00000000..427051cb --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/mock-input.ts @@ -0,0 +1,19 @@ +// not using a template string in order +// to have more control over whitespace +export const mockInput = [ + 'my-app', + ' src', + ' index.html', + ' main.ts', + ' main.scss', + ' - build', + ' - index.html', + ' main.js', + ' main.css', + '', + ' ', + ' .prettierrc.json', + ' .gitlab-ci.yml', + ' README.md', + 'empty dir', +].join('\n'); diff --git a/src/tools/folder-structure-diagram/lib/parse-input.test.ts b/src/tools/folder-structure-diagram/lib/parse-input.test.ts new file mode 100644 index 00000000..b9a5286b --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/parse-input.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import type { FileStructure } from './FileStructure'; +import { mockInput } from './mock-input'; +import { parseInput, splitInput } from './parse-input'; + +describe('parse-input', () => { + it('parses plain text input into a FileStructure object', () => { + const actual = parseInput(mockInput); + + const root: FileStructure = { + name: '.', + children: [], + indentCount: -1, + parent: null, + }; + + const myApp: FileStructure = { + name: 'my-app', + children: [], + indentCount: 0, + parent: root, + }; + root.children.push(myApp); + + const src: FileStructure = { + name: 'src', + children: [], + indentCount: 2, + parent: myApp, + }; + myApp.children.push(src); + + const srcIndexHtml: FileStructure = { + name: 'index.html', + children: [], + indentCount: 4, + parent: src, + }; + src.children.push(srcIndexHtml); + + const mainTs: FileStructure = { + name: 'main.ts', + children: [], + indentCount: 4, + parent: src, + }; + src.children.push(mainTs); + + const mainScss: FileStructure = { + name: 'main.scss', + children: [], + indentCount: 3, + parent: src, + }; + src.children.push(mainScss); + + const build: FileStructure = { + name: 'build', + children: [], + indentCount: 2, + parent: myApp, + }; + myApp.children.push(build); + + const buildIndexHtml: FileStructure = { + name: 'index.html', + children: [], + indentCount: 4, + parent: build, + }; + build.children.push(buildIndexHtml); + + const mainJs: FileStructure = { + name: 'main.js', + children: [], + indentCount: 4, + parent: build, + }; + build.children.push(mainJs); + + const mainCss: FileStructure = { + name: 'main.css', + children: [], + indentCount: 4, + parent: build, + }; + build.children.push(mainCss); + + const prettierRcJson: FileStructure = { + name: '.prettierrc.json', + children: [], + indentCount: 2, + parent: myApp, + }; + myApp.children.push(prettierRcJson); + + const gitlabCiYml: FileStructure = { + name: '.gitlab-ci.yml', + children: [], + indentCount: 2, + parent: myApp, + }; + myApp.children.push(gitlabCiYml); + + const readmeMd: FileStructure = { + name: 'README.md', + children: [], + indentCount: 2, + parent: myApp, + }; + myApp.children.push(readmeMd); + + const emptyDir: FileStructure = { + name: 'empty dir', + children: [], + indentCount: 0, + parent: root, + }; + root.children.push(emptyDir); + + expect(actual).toEqual(root); + }); +}); + +describe('splitInput', () => { + it('splits plain text into an array of File Structure objects', () => { + const actual = splitInput(mockInput); + + const expected = [ + { name: 'my-app', children: [], indentCount: 0, parent: null }, + { name: 'src', children: [], indentCount: 2, parent: null }, + { name: 'index.html', children: [], indentCount: 4, parent: null }, + { name: 'main.ts', children: [], indentCount: 4, parent: null }, + { name: 'main.scss', children: [], indentCount: 3, parent: null }, + { name: 'build', children: [], indentCount: 2, parent: null }, + { name: 'index.html', children: [], indentCount: 4, parent: null }, + { name: 'main.js', children: [], indentCount: 4, parent: null }, + { name: 'main.css', children: [], indentCount: 4, parent: null }, + { name: '.prettierrc.json', children: [], indentCount: 2, parent: null }, + { name: '.gitlab-ci.yml', children: [], indentCount: 2, parent: null }, + { name: 'README.md', children: [], indentCount: 2, parent: null }, + { name: 'empty dir', children: [], indentCount: 0, parent: null }, + ]; + + expect(actual).toEqual(expected); + }); +}); diff --git a/src/tools/folder-structure-diagram/lib/parse-input.ts b/src/tools/folder-structure-diagram/lib/parse-input.ts new file mode 100644 index 00000000..4bf2c068 --- /dev/null +++ b/src/tools/folder-structure-diagram/lib/parse-input.ts @@ -0,0 +1,81 @@ +import last from 'lodash.last'; +import type { FileStructure } from './FileStructure'; + +/** + * Matches the whitespace in front of a file name. + * Also will match a markdown bullet point if included. + * For example, testing against " - hello" will return + * a positive match with the first capturing group + * with " - " and a second with " " + */ +const leadingWhitespaceAndBulletRegex = /^((\s*)(?:-\s)?)/; + +/** Matches lines that only contain whitespace */ +const onlyWhitespaceRegex = /^\s*$/; + +/** Used to split a block of text into individual lines */ +const newlineSplitterRegex = /[^\r\n]+/g; + +/** + * Translates a block of user-created text into + * a nested FileStructure structure + * @param input The plain-text input from the user + */ +export function parseInput(input: string): FileStructure { + const structures = splitInput(input); + + const root: FileStructure = { + name: '.', + children: [], + indentCount: -1, + parent: null, + }; + + const path = [root]; + for (const s of structures) { + while (last(path)!.indentCount >= s.indentCount) { + path.pop(); + } + + const parent = last(path) as FileStructure; + parent.children.push(s); + s.parent = parent; + + path.push(s); + } + + return root; +} + +/** + * Splits a block of user-created text into + * individual, un-nested FileStructure objects. + * Used internally as part of `parseInput`. + * @param input The plain-text input from the user + */ +export function splitInput(input: string): FileStructure[] { + let lines = input.match(newlineSplitterRegex)?.map(m => m) || []; + + // filter out empty lines + lines = lines.filter(l => !onlyWhitespaceRegex.test(l)); + + return lines.map((l) => { + const matchResult = leadingWhitespaceAndBulletRegex.exec(l); + + if (!matchResult) { + throw new Error( + `Unable to execute leadingWhitespaceAndBulletRegex against string: "${l}"`, + ); + } + + const name = l.replace(matchResult[1], ''); + const indentCount = matchResult[2].length; + + return { + name, + children: [], + indentCount, + parent: null, + }; + }); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index aa861c93..a43ef985 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import { tool as asciiTextDrawer } from './ascii-text-drawer'; import { tool as textToUnicode } from './text-to-unicode'; import { tool as safelinkDecoder } from './safelink-decoder'; +import { tool as folderStructureDiagram } from './folder-structure-diagram'; import { tool as pdfSignatureChecker } from './pdf-signature-checker'; import { tool as numeronymGenerator } from './numeronym-generator'; import { tool as macAddressGenerator } from './mac-address-generator'; @@ -128,6 +129,7 @@ export const toolsByCategory: ToolCategory[] = [ httpStatusCodes, jsonDiff, safelinkDecoder, + folderStructureDiagram, ], }, {