├── .github
└── workflows
│ ├── deploy-mkdocs.yml
│ └── nextjs.yml
├── .gitignore
├── .idea
├── .gitignore
├── modules.xml
├── node-crawler.iml
└── vcs.xml
├── Dockerfile
├── README.md
├── documentation
├── docs
│ ├── extend-project
│ │ └── implementing-a-new-node.md
│ ├── index.md
│ └── stylesheets
│ │ └── extra.css
└── mkdocs.yml
├── next.config.js
├── nginx.conf
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── next.svg
├── node-crawler-overview-canvas.png
├── node-crawler-overview-output.png
└── vercel.svg
├── src
├── app
│ ├── AppTheme.tsx
│ ├── api
│ │ └── fetch
│ │ │ └── route.ts
│ ├── editor
│ │ └── page.tsx
│ ├── globals.css
│ ├── icon.png
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── editor
│ │ ├── Engine.tsx
│ │ ├── headerbar
│ │ │ ├── HeaderBar.tsx
│ │ │ └── PageNavigation.tsx
│ │ └── pages
│ │ │ ├── canvas
│ │ │ ├── DragAndDropFlow.tsx
│ │ │ ├── DragAndDropFlowStyles.css
│ │ │ ├── edges
│ │ │ │ ├── EdgeGradientStyles.css
│ │ │ │ ├── Edges.ts
│ │ │ │ └── util
│ │ │ │ │ └── EdgeCreators.tsx
│ │ │ ├── nodes
│ │ │ │ ├── DatabaseTableNode.tsx
│ │ │ │ ├── ExtractorNode.tsx
│ │ │ │ ├── FetchWebsiteNode.tsx
│ │ │ │ ├── HtmlToTextNode.tsx
│ │ │ │ ├── SaveNode.tsx
│ │ │ │ ├── StartNode.tsx
│ │ │ │ ├── ZipNode.tsx
│ │ │ │ └── util
│ │ │ │ │ ├── Creators.tsx
│ │ │ │ │ └── OptionsUtil.ts
│ │ │ └── toolbars
│ │ │ │ ├── Html.css
│ │ │ │ ├── NodesToolbar.tsx
│ │ │ │ ├── OnCanvasNodesSelector.tsx
│ │ │ │ └── OptionsToolbar.tsx
│ │ │ ├── html
│ │ │ ├── CssSelectorCard.tsx
│ │ │ ├── HtmlSelectorPage.tsx
│ │ │ ├── SelectableHtmlPreview.tsx
│ │ │ └── util
│ │ │ │ └── HtmlUtils.ts
│ │ │ └── output
│ │ │ ├── Files.tsx
│ │ │ ├── Log.tsx
│ │ │ ├── OutputPage.tsx
│ │ │ └── util
│ │ │ └── SmoothScrollUtil.ts
│ └── form
│ │ ├── CacheTextField.tsx
│ │ ├── DraggableOptionsListContainer.tsx
│ │ ├── FileNameInputOption.tsx
│ │ ├── MultiSelectOption.tsx
│ │ ├── OptionsContainer.tsx
│ │ ├── RowOptionsContainer.tsx
│ │ ├── SelectOption.tsx
│ │ └── TextInputOption.tsx
├── config
│ ├── ConnectionRules.ts
│ ├── NodeType.ts
│ ├── NodesMetadata.tsx
│ ├── OutputValueType.ts
│ └── colors.ts
├── engine
│ └── nodes
│ │ ├── BasicNode.ts
│ │ ├── EngineDatabaseTableNode.ts
│ │ ├── EngineExtractorNode.ts
│ │ ├── EngineFetchWebsiteNode.ts
│ │ ├── EngineHtmlToTextNode.ts
│ │ ├── EngineSaveNode.ts
│ │ ├── EngineStartNode.ts
│ │ └── EngineZipNode.ts
├── model
│ ├── CrawlerProjectDto.ts
│ ├── NextNodeKey.ts
│ ├── NodeData.ts
│ └── NodeMap.ts
├── stores
│ └── editor
│ │ ├── EditorPageStore.tsx
│ │ ├── HtmlSelectorStore.ts
│ │ ├── PlayStore.ts
│ │ └── ReactFlowStore.ts
└── util
│ ├── IOUtil.ts
│ └── NodeMapTransformer.ts
├── tailwind.config.js
└── tsconfig.json
/.github/workflows/deploy-mkdocs.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 | on:
3 | push:
4 | branches:
5 | - master
6 | - main
7 | permissions:
8 | contents: write
9 | jobs:
10 | deploy:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-python@v4
15 | with:
16 | python-version: 3.x
17 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
18 | - uses: actions/cache@v3
19 | with:
20 | key: mkdocs-material-${{ env.cache_id }}
21 | path: .cache
22 | restore-keys: |
23 | mkdocs-material-
24 | - run: pip install mkdocs-material
25 | - name: Navigate to documentation directory and Deploy to GitHub Pages
26 | run: |
27 | cd documentation
28 | mkdocs gh-deploy --force
29 |
--------------------------------------------------------------------------------
/.github/workflows/nextjs.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a Next.js site to GitHub Pages
2 | #
3 | # To get started with Next.js see: https://nextjs.org/docs/getting-started
4 | #
5 | name: Deploy Next.js site to Pages
6 |
7 | on:
8 | # Runs on pushes targeting the default branch
9 | push:
10 | branches: ["master"]
11 |
12 | # Allows you to run this workflow manually from the Actions tab
13 | workflow_dispatch:
14 |
15 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
16 | permissions:
17 | contents: read
18 | pages: write
19 | id-token: write
20 |
21 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
22 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
23 | concurrency:
24 | group: "pages"
25 | cancel-in-progress: false
26 |
27 | jobs:
28 | # Build job
29 | build:
30 | runs-on: ubuntu-latest
31 | steps:
32 | - name: Checkout
33 | uses: actions/checkout@v3
34 | - name: Detect package manager
35 | id: detect-package-manager
36 | run: |
37 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then
38 | echo "manager=yarn" >> $GITHUB_OUTPUT
39 | echo "command=install" >> $GITHUB_OUTPUT
40 | echo "runner=yarn" >> $GITHUB_OUTPUT
41 | exit 0
42 | elif [ -f "${{ github.workspace }}/package.json" ]; then
43 | echo "manager=npm" >> $GITHUB_OUTPUT
44 | echo "command=ci" >> $GITHUB_OUTPUT
45 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT
46 | exit 0
47 | else
48 | echo "Unable to determine package manager"
49 | exit 1
50 | fi
51 | - name: Setup Node
52 | uses: actions/setup-node@v3
53 | with:
54 | node-version: "16"
55 | cache: ${{ steps.detect-package-manager.outputs.manager }}
56 | - name: Setup Pages
57 | uses: actions/configure-pages@v3
58 | with:
59 | # Automatically inject basePath in your Next.js configuration file and disable
60 | # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
61 | #
62 | # You may remove this line if you want to manage the configuration yourself.
63 | static_site_generator: next
64 | - name: Restore cache
65 | uses: actions/cache@v3
66 | with:
67 | path: |
68 | .next/cache
69 | # Generate a new cache whenever packages or source files change.
70 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
71 | # If source files changed but packages didn't, rebuild from a prior cache.
72 | restore-keys: |
73 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
74 | - name: Install dependencies
75 | run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
76 | - name: Build with Next.js
77 | run: ${{ steps.detect-package-manager.outputs.runner }} next build
78 | - name: Static HTML export with Next.js
79 | run: ${{ steps.detect-package-manager.outputs.runner }} next export
80 | - name: Upload artifact
81 | uses: actions/upload-pages-artifact@v2
82 | with:
83 | path: ./out
84 |
85 | # Deployment job
86 | deploy:
87 | environment:
88 | name: github-pages
89 | url: ${{ steps.deployment.outputs.page_url }}
90 | runs-on: ubuntu-latest
91 | needs: build
92 | steps:
93 | - name: Deploy to GitHub Pages
94 | id: deployment
95 | uses: actions/deploy-pages@v2
96 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 | /documentation/site/
37 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/node-crawler.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Setzen Sie das Basis-Image
2 | FROM node:16-alpine
3 |
4 | # Setzen Sie das Arbeitsverzeichnis
5 | WORKDIR /app
6 |
7 | # Kopieren Sie die Datei package.json (und die dazugehörige package-lock.json, falls vorhanden)
8 | COPY package*.json ./
9 |
10 | # Installieren Sie alle Abhängigkeiten
11 | RUN npm install
12 |
13 | # Kopieren Sie den Rest des Anwendungsquellcodes
14 | COPY . .
15 |
16 | # Bauen Sie Ihre Next.js-Anwendung
17 | RUN npm install && npm run build
18 |
19 | # Exponieren Sie den Port für den Next.js-Server (standardmäßig 3000)
20 | EXPOSE 3002
21 |
22 | # Führen Sie den Next.js-Server aus
23 | CMD ["npm", "start"]
24 |
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Project Name: Node-Crawler
2 |
3 | [](https://nextjs.org/)
4 | [](https://reactflow.dev/)
5 |
6 | ## Overview
7 |
8 | ! Work in progress !
9 |
10 | See https://mertend.github.io/node-crawler/ for a detailed documentation.
11 |
12 | Node-Crawler is a highly customizable, Node-based web application for creating web crawlers and further processing and transforming the retrieved data. Users can build tailor-made web crawlers and manipulate and transform the collected data as needed. The output data format can be set to a wide range of formats including JSON, CSV, and various database formats.
13 |
14 | The app is developed using [Next.js](https://nextjs.org/) and the [React Flow](https://reactflow.dev/) library provides a framework for creating the node-based editor.
15 |
16 | 
17 | 
18 |
19 | ## Features
20 |
21 | - **Node-based Editing**: Users can create and edit their own crawler workflows by drag-and-dropping nodes.
22 | - **Data Transformation**: The application supports a variety of data manipulation and transformation operations for cleaning and restructuring the gathered data.
23 | - **Data Export**: The transformed data can be output in a variety of formats including JSON, CSV, and various database formats.
24 |
25 | ## Installation
26 |
27 | Make sure you have [Node.js](https://nodejs.org/en) and npm installed on your system before you start.
28 |
29 | 1. Clone the repository:
30 |
31 | ```shell
32 | git clone https://github.com/MertenD/node-crawler.git
33 | ```
34 |
35 | 2. Navigate into the directory and install the dependencies:
36 |
37 | ```shell
38 | cd node-crawler
39 | npm install
40 | ```
41 |
42 | 3. Start the development server:
43 |
44 | ```shell
45 | npm run dev
46 | ```
47 |
48 | Now you should be able to see the web application on `http://localhost:3000` in your browser.
49 |
50 | ## Implementing a New Node
51 |
52 | Follow these steps when you need to create a new Node:
53 |
54 | ### Step 1: Add the Node to the NodeType Enum
55 |
56 | Next, add your newly created Node to the **config/NodeType** enum.
57 |
58 | ### Step 2: Create the New Node
59 |
60 | First, create a new file in the **components/editor/pages/canvas/nodes** directory. In this file, you will define the following elements:
61 |
62 | 1. **Data Interface:** Create a Data interface that stores all data the user can configure.
63 |
64 | 2. **Style Function:** Create a Style function (using the ```createNodeShapeStyle()``` function), where you can customize the Node's appearance.
65 |
66 | 3. **Node Component:** Create a Node component (using the ```createNodeComponent()``` function), which will be the Node on the canvas.
67 |
68 | 4. **Options Component:** Create an Options component (using the ```createOptionsComponent()``` function), where the user can configure the Node's behavior.
69 |
70 | You can use the following template to create a new Node:
71 |
72 | ```tsx
73 | // TODO: Replace [NAME] everywhere
74 |
75 | // --- Data ---
76 | export interface [NAME]NodeData extends NodeData {
77 | // TODO: Add data attributes here
78 | }
79 |
80 | // --- Style ---
81 | export const [NAME]ShapeStyle = createNodeShapeStyle({
82 | // TODO: Add additional CSS for the node's shape here
83 | })
84 |
85 | // --- Node ---
86 | export const [NAME]Node = createNodeComponent<[NAME]NodeData>(
87 | NodeType.[NAME]_NODE,
88 | [NAME]ShapeStyle,
89 | (id, selected, data) => {
90 | // TODO: Place the node content here
91 | }
92 | )
93 |
94 | // --- Options ---
95 | export const [NAME]Options = createOptionsComponent<[NAME]NodeData>("Start", ({ id, data, onDataUpdated }) => {
96 | return // TODO: Place options here
97 | })
98 | ```
99 |
100 | ### Step 3: Add Metadata to NodesMetadata.tsx
101 |
102 | Add all metadata of the new Node to the **config/NodesMetadata.tsx** file.
103 |
104 | ### Step 4: Add Connection Rules
105 |
106 | Define the connection rules for the new Node in the **config/ConnectionRules.ts** file.
107 |
108 | ### Step 5: Create a New Node Class for Execution
109 |
110 | You need to create a new class in the **engine/nodes** directory. This class should extend the ```BasicNode``` interface. Below is a basic template for your reference:
111 |
112 | ```ts
113 | // TODO: Replace [NAME]
114 | export class Engine[NAME]Node implements BasicNode {
115 | id: string;
116 | nodeType: NodeType
117 | data: // TODO
118 |
119 | constructor(id: string, data: /* TODO */) {
120 | this.id = id
121 | this.nodeType = // TODO
122 | this.data = data
123 | }
124 |
125 | async run() {
126 | // Optional: Get inputs from previous nodes
127 | const input = usePlayStore.getState().getInput(this.id, "input")
128 |
129 | if (input) {
130 | // TODO Put the logic of the node here
131 |
132 | // Optional: Add downloadable file
133 | usePlayStore.getState().addFile(/* TODO */)
134 |
135 | // Optional: Make outputs accessable for the next node
136 | usePlayStore.getState().addOutgoingPipelines(this.id, /* TODO */)
137 |
138 | // Optional: Write to the log
139 | usePlayStore.getState().writeToLog(/* TODO */)
140 |
141 | // End with calling the next node
142 | usePlayStore.getState().nextNode()
143 | }
144 | }
145 | }
146 | ```
147 |
148 | ### Step 6: Add Node Transformation Logic
149 |
150 | The final step involves adding the transformation logic for the node. This transformation will convert a [React Flow](https://reactflow.dev/) Node
151 | into an instance of your newly created class from **Step 5**. To do this, navigate to the **util/NodeMapTransformer.ts** file and
152 | add a new case to the ```getNodeFromType()``` method where you create the instance.
153 |
--------------------------------------------------------------------------------
/documentation/docs/extend-project/implementing-a-new-node.md:
--------------------------------------------------------------------------------
1 | # Implementing a New Node
2 |
3 | Follow these steps when you need to create a new Node:
4 |
5 | ## Step 1: Add the Node to the NodeType Enum
6 |
7 | Next, add your newly created Node to the **config/NodeType** enum.
8 |
9 | ## Step 2: Create the New Node
10 |
11 | First, create a new file in the **components/editor/pages/canvas/nodes** directory. In this file, you will define the following elements:
12 |
13 | 1. **Data Interface:** Create a Data interface that stores all data the user can configure.
14 |
15 | 2. **Style Function:** Create a Style function (using the ```createNodeShapeStyle()``` function), where you can customize the Node's appearance.
16 |
17 | 3. **Node Component:** Create a Node component (using the ```createNodeComponent()``` function), which will be the Node on the canvas.
18 |
19 | 4. **Options Component:** Create an Options component (using the ```createOptionsComponent()``` function), where the user can configure the Node's behavior.
20 |
21 | You can use the following template to create a new Node:
22 |
23 | ```tsx
24 | // TODO: Replace [NAME] everywhere
25 |
26 | // --- Data ---
27 | export interface [NAME]NodeData extends NodeData {
28 | // TODO: Add data attributes here
29 | }
30 |
31 | // --- Style ---
32 | export const [NAME]ShapeStyle = createNodeShapeStyle({
33 | // TODO: Add additional CSS for the node's shape here
34 | })
35 |
36 | // --- Node ---
37 | export const [NAME]Node = createNodeComponent<[NAME]NodeData>(
38 | NodeType.[NAME]_NODE,
39 | [NAME]ShapeStyle,
40 | (id, selected, data) => {
41 | // TODO: Place the node content here
42 | }
43 | )
44 |
45 | // --- Options ---
46 | export const [NAME]Options = createOptionsComponent<[NAME]NodeData>("Start", ({ id, data, onDataUpdated }) => {
47 | return // TODO: Place options here
48 | })
49 | ```
50 |
51 | ## Step 3: Add Metadata to NodesInformation.tsx
52 |
53 | Add all metadata of the new Node to the **config/NodesInformation.tsx** file.
54 |
55 | ## Step 4: Add Connection Rules
56 |
57 | Define the connection rules for the new Node in the **config/ConnectionRules.ts** file.
58 |
59 | ## Step 5: Create a New Node Class for Execution
60 |
61 | You need to create a new class in the **engine/nodes** directory. This class should extend the ```BasicNode``` interface. Below is a basic template for your reference:
62 |
63 | ```ts
64 | // TODO: Replace [NAME]
65 | export class Engine[NAME]Node implements BasicNode {
66 | id: string;
67 | nodeType: NodeType
68 | data: // TODO
69 |
70 | constructor(id: string, data: /* TODO */) {
71 | this.id = id
72 | this.nodeType = // TODO
73 | this.data = data
74 | }
75 |
76 | async run() {
77 | // Optional: Get inputs from previous nodes
78 | const input = usePlayStore.getState().getInput(this.id, "input")
79 |
80 | if (input) {
81 | // TODO Put the logic of the node here
82 |
83 | // Optional: Add downloadable file
84 | usePlayStore.getState().addFile(/* TODO */)
85 |
86 | // Optional: Make outputs accessable for the next node
87 | usePlayStore.getState().addOutgoingPipelines(this.id, /* TODO */)
88 |
89 | // Optional: Write to the log
90 | usePlayStore.getState().writeToLog(/* TODO */)
91 |
92 | // End with calling the next node
93 | usePlayStore.getState().nextNode()
94 | }
95 | }
96 | }
97 | ```
98 |
99 | ## Step 6: Add Node Transformation Logic
100 |
101 | The final step involves adding the transformation logic for the node. This transformation will convert a [React Flow](https://reactflow.dev/) Node
102 | into an instance of your newly created class from **Step 5**. To do this, navigate to the **util/NodeMapTransformer.ts** file and
103 | add a new case to the ```getNodeFromType()``` method where you create the instance.
--------------------------------------------------------------------------------
/documentation/docs/index.md:
--------------------------------------------------------------------------------
1 | # Welcome to Node‐Crawler's Wiki!
2 |
3 | Welcome to the official wiki page of the Node-Crawler repository. This is your go-to resource for understanding and using this tool. Node-Crawler is a powerful, flexible, and easy-to-use web crawler, with a node-based editing system built on top of [Next.js](https://nextjs.org/) and [React Flow](https://reactflow.dev/).
4 |
5 | Here you'll find detailed documentation about each of the functionalities of this application and guides on how to extend the application to help you make the most out of Node-Crawler.
6 |
7 | ## About Node-Crawler
8 |
9 | Node-Crawler is a highly customizable, Node-based web application for creating web crawlers and further processing and transforming the retrieved data. Users can build tailor-made web crawlers and manipulate and transform the collected data as needed. The output data format can be set to a wide range of formats including JSON, CSV, and various database formats.
10 |
11 | 
12 | 
13 |
14 | ## Project Goals and Use Cases
15 |
16 | - **Node-based Editing**: Users can create and edit their own crawler workflows by drag-and-dropping nodes.
17 | - **Data Transformation**: The application supports a variety of data manipulation and transformation operations for cleaning and restructuring the gathered data.
18 | - **Data Export**: The transformed data can be output in a variety of formats including JSON, CSV, and various database formats.
19 |
20 |
21 | ## Installation Guide
22 |
23 | Make sure you have [Node.js](https://nodejs.org/en) and npm installed on your system before you start.
24 |
25 | 1. Clone the repository:
26 |
27 | ```shell
28 | git clone https://github.com/MertenD/node-crawler.git
29 | ```
30 |
31 | 2. Navigate into the directory and install the dependencies:
32 |
33 | ```shell
34 | cd node-crawler
35 | npm install
36 | ```
37 |
38 | 3. Start the development server:
39 |
40 | ```shell
41 | npm run dev
42 | ```
43 |
44 | Now you should be able to see the web application on `http://localhost:3000` in your browser.
45 |
--------------------------------------------------------------------------------
/documentation/docs/stylesheets/extra.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --md-default-fg-color: hsla(0, 100%, 100%, 0.9);
3 | --md-default-fg-color--light: hsla(0, 100%, 100%, 0.9);
4 | --md-default-fg-color--lighter: hsla(0, 100%, 100%, 0.9);
5 | --md-default-fg-color--lightest: #222C3C;
6 | --md-default-bg-color: #222C3C;
7 | --md-default-bg-color--light: #222C3C;
8 | --md-default-bg-color--lighter: #222C3C;
9 | --md-default-bg-color--lightest: #222C3C;
10 |
11 | --md-accent-fg-color: #9BA8BD;
12 |
13 | --md-primary-fg-color: #F98E35;
14 | --md-primary-fg-color--dark: #F98E35;
15 |
16 | --md-code-fg-color: hsla(0, 100%, 100%, 0.9);
17 | --md-code-bg-color: #1A202C;
18 |
19 | }
--------------------------------------------------------------------------------
/documentation/mkdocs.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json
2 |
3 | site_name: Node-Crawler Docs
4 | site_url: https://mertend.github.io/node-crawler/
5 | site_author: Merten Dieckmann
6 | site_description: "Node-Crawler is a highly customizable, Node-based web application for creating web crawlers and further processing and transforming the retrieved data. "
7 | repo_url: "https://github.com/MertenD/node-crawler"
8 |
9 | theme:
10 | name: material
11 |
12 | extra_css:
13 | - stylesheets/extra.css
14 |
15 | nav:
16 | - Home: index.md
17 | - Extend Project:
18 | - 'Implementing a new Node': 'extend-project/implementing-a-new-node.md'
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | module.exports = nextConfig
5 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 3002;
3 | server_name localhost;
4 |
5 | location / {
6 | proxy_pass http://localhost:3002;
7 | proxy_http_version 1.1;
8 | proxy_set_header Upgrade $http_upgrade;
9 | proxy_set_header Connection 'upgrade';
10 | proxy_set_header Host $host;
11 | proxy_cache_bypass $http_upgrade;
12 | }
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-crawler",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@emotion/react": "^11.11.1",
13 | "@emotion/styled": "^11.11.0",
14 | "@fontsource/roboto": "^5.0.4",
15 | "@mui/icons-material": "^5.13.7",
16 | "@mui/lab": "^5.0.0-alpha.137",
17 | "@mui/material": "^5.13.7",
18 | "@types/node": "20.3.3",
19 | "@types/react": "18.2.14",
20 | "@types/react-beautiful-dnd": "^13.1.4",
21 | "@types/react-dom": "18.2.6",
22 | "autoprefixer": "10.4.14",
23 | "cheerio": "^1.0.0-rc.12",
24 | "css-selector-generator": "^3.6.4",
25 | "dompurify": "^3.0.5",
26 | "get-selector": "^1.0.4",
27 | "next": "13.4.8",
28 | "postcss": "8.4.24",
29 | "react": "18.2.0",
30 | "react-beautiful-dnd": "^13.1.1",
31 | "react-dom": "18.2.0",
32 | "react-flow-renderer": "^10.3.17",
33 | "react-toastify": "^9.1.3",
34 | "reactflow": "^11.7.4",
35 | "tailwindcss": "3.3.2",
36 | "typescript": "5.1.6",
37 | "uuid": "^9.0.0",
38 | "zustand": "4.3.3"
39 | },
40 | "devDependencies": {
41 | "@types/cheerio": "^0.22.31",
42 | "@types/dompurify": "^3.0.2",
43 | "@types/uuid": "^9.0.2",
44 | "eslint": "^8.45.0"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/node-crawler-overview-canvas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MertenD/node-crawler/56c0a807be09c770e95336edd5bf9f7464254b49/public/node-crawler-overview-canvas.png
--------------------------------------------------------------------------------
/public/node-crawler-overview-output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MertenD/node-crawler/56c0a807be09c770e95336edd5bf9f7464254b49/public/node-crawler-overview-output.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/AppTheme.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {createTheme, ThemeProvider} from "@mui/material/styles";
4 | import {ReactNode} from "react";
5 | import {disabledColor, selectedColor, selectedColorHover, toolbarBackgroundColor} from "@/config/colors";
6 |
7 | const appTheme = createTheme({
8 | palette: {
9 | mode: "dark",
10 | text: {
11 | primary: "#fff"
12 | },
13 | primary: {
14 | main: selectedColor
15 | },
16 | background: {
17 | default: toolbarBackgroundColor
18 | },
19 | warning: {
20 | main: selectedColor
21 | }
22 | },
23 | components: {
24 | MuiButton: {
25 | styleOverrides: {
26 | contained: {
27 | backgroundColor: selectedColor + ' !important',
28 | '&:hover': {
29 | backgroundColor: selectedColorHover + ' !important',
30 | },
31 | '&:disabled': {
32 | backgroundColor: disabledColor + ' !important',
33 | },
34 | }
35 | }
36 | },
37 | MuiMenu: {
38 | styleOverrides: {
39 | paper: {
40 | backgroundColor: toolbarBackgroundColor
41 | }
42 | }
43 | }
44 | }
45 | });
46 |
47 | interface AppThemeProps {
48 | children: ReactNode;
49 | }
50 |
51 | export default function AppTheme({ children }: AppThemeProps) {
52 | return
53 | { children }
54 |
55 | }
--------------------------------------------------------------------------------
/src/app/api/fetch/route.ts:
--------------------------------------------------------------------------------
1 | export async function POST(request: Request) {
2 |
3 | const responseHeaders = {
4 | 'Access-Control-Allow-Origin': '*',
5 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
6 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
7 | }
8 |
9 | try {
10 | const body = await request.json();
11 |
12 | const response = await fetch(body.url);
13 | if (!response.ok) {
14 | throw new Error(`Status code was: ${response.status}`);
15 | }
16 |
17 | const data = await response.text();
18 |
19 | return new Response(data, {
20 | status: 200,
21 | headers: responseHeaders
22 | });
23 | } catch (error) {
24 | if (error instanceof Error) {
25 | return new Response(error.toString(), {
26 | status: 500,
27 | headers: responseHeaders
28 | });
29 | } else {
30 | return new Response("An unexpected error occurred", {
31 | status: 500,
32 | headers: responseHeaders
33 | });
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/editor/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {ReactFlowProvider} from "reactflow";
4 | import React, {useEffect} from "react";
5 | import HeaderBar, {CANVAS_HEIGHT} from "@/components/editor/headerbar/HeaderBar";
6 | import useEditorPageState from "@/stores/editor/EditorPageStore";
7 | import {Alert, Snackbar} from "@mui/material";
8 | import Engine from "@/components/editor/Engine";
9 | import {selectedColor} from "@/config/colors";
10 |
11 | export default function Canvas() {
12 |
13 | const selectedPage = useEditorPageState(state => state.selectedPage)
14 | const getPage = useEditorPageState(state => state.getPage)
15 |
16 | const isSnackBarOpen = useEditorPageState(state => state.isSnackBarOpen)
17 | const setIsSnackBarOpen = useEditorPageState(state => state.setIsSnackBarOpen)
18 | const snackBarSeverity = useEditorPageState(state => state.snackBarSeverity)
19 | const snackBarText = useEditorPageState(state => state.snackBarText)
20 |
21 | const handleOnSnackBarClosed = () => {
22 | setIsSnackBarOpen(false)
23 | }
24 |
25 | useEffect(() => {
26 | window.addEventListener('beforeunload', preventReload)
27 | return () => {
28 | window.removeEventListener('beforeunload', preventReload)
29 | }
30 | }, [])
31 |
32 | const preventReload = (e: Event) => {
33 | e.preventDefault()
34 | }
35 |
36 | useEffect(() => {
37 | document.documentElement.style.setProperty('--selected-color', selectedColor);
38 | }, []);
39 |
40 | return
41 |
42 |
43 |
49 |
50 | { snackBarText }
51 |
52 |
53 |
54 |
55 | { getPage(selectedPage)?.child }
56 |
57 |
58 |
59 | }
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 255, 255, 255;
7 | --background-start-rgb: 33, 43, 60;
8 | --background-end-rgb: 33, 43, 60;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 33, 43, 60;
15 | --background-end-rgb: 33, 43, 60;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MertenD/node-crawler/56c0a807be09c770e95336edd5bf9f7464254b49/src/app/icon.png
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import {Inter} from 'next/font/google'
3 | import React from "react";
4 | import AppTheme from "./AppTheme";
5 |
6 | const inter = Inter({ subsets: ['latin'] })
7 |
8 | export const metadata = {
9 | title: 'Node-Crawler',
10 | description: 'Node-Crawler is a highly customizable, Node-based web application for creating web crawlers and further processing and transforming the retrieved data.',
11 | }
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode
17 | }) {
18 | return (
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Typography from '@mui/material/Typography';
4 | import {Button} from "@mui/material";
5 | import Link from "next/link";
6 | import useEditorPageState from "@/stores/editor/EditorPageStore";
7 |
8 | // TODO Es wäre geil Verbindungsregeln in einer Config Datei setzten zu können. Vielleicht kann ich dort ja auch die NodeType und co. setzen. Eine einheitliche Struktur wäre auf jeden Fall schön
9 |
10 | export default function Home() {
11 | // noinspection HtmlUnknownTarget
12 | return
20 |
21 | Node-Crawler
22 |
23 |
24 |
31 |
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/editor/Engine.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { usePlayStore } from "@/stores/editor/PlayStore";
3 |
4 | export default function Engine() {
5 |
6 | const [nodeContent, setNodeContent] = useState(null);
7 | const currentNode = usePlayStore((state) => state.currentNode);
8 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
9 |
10 | useEffect(() => {
11 | const runCurrentNode = async () => {
12 | if (currentNode !== null) {
13 | const content = await currentNode.node.run();
14 | setNodeContent(content);
15 | }
16 | };
17 |
18 | if (isProcessRunning) {
19 | // noinspection JSIgnoredPromiseFromCall
20 | runCurrentNode();
21 | }
22 | }, [currentNode]);
23 |
24 | return {nodeContent || <>>}
;
25 | }
--------------------------------------------------------------------------------
/src/components/editor/headerbar/HeaderBar.tsx:
--------------------------------------------------------------------------------
1 | import {useReactFlowStore} from "@/stores/editor/ReactFlowStore";
2 | import React, {useRef} from "react";
3 | import {IconButton, Tooltip} from "@mui/material";
4 | import SaveIcon from '@mui/icons-material/Save';
5 | import UploadIcon from '@mui/icons-material/Upload'
6 | import {loadCrawlerProject, onSave} from "@/util/IOUtil";
7 | import {CrawlerProjectDto} from "@/model/CrawlerProjectDto";
8 | import {useReactFlow} from "reactflow";
9 | import PageNavigation from "@/components/editor/headerbar/PageNavigation";
10 | import HomeIcon from '@mui/icons-material/Home';
11 | import Link from "next/link";
12 | import {usePlayStore} from "@/stores/editor/PlayStore";
13 | import VisibilityIcon from '@mui/icons-material/Visibility';
14 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
15 | import SkipNextIcon from '@mui/icons-material/SkipNext';
16 | import PlayArrowIcon from '@mui/icons-material/PlayArrow';
17 | import StopIcon from '@mui/icons-material/Stop';
18 | import {disabledColor, toolbarBackgroundColor} from "@/config/colors";
19 |
20 | export const TOOLBAR_HEIGHT = 8
21 | export const CANVAS_HEIGHT = 100 - TOOLBAR_HEIGHT
22 |
23 | export default function HeaderBar() {
24 |
25 | // TODO Ich will einen Schritt für Schritt Debug mode, in dem bei jedem Schritt die Inputs und Outputs angezeigt werden, wöhrend man
26 | // beim aktuellen Knoten die Möglichkeit hat die Optionen anzupassen
27 | // + Live Preview des vorläugfigen Ergebnis was rauskomen würde
28 |
29 | const reactFlowInstance = useReactFlow();
30 | const inputFile = useRef(null);
31 |
32 | const { nodes, edges } = useReactFlowStore();
33 | const setup = usePlayStore(state => state.setup)
34 | const stop = usePlayStore(state => state.stop)
35 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
36 | const isConnectionHighlightingActivated = useReactFlowStore(state => state.isConnectionHighlightingActivated)
37 | const setIsConnectionHighlightingActivated = useReactFlowStore(state => state.setIsConnectionHighlightingActivated)
38 | const { isStepByStep, setIsStepByStep, isNextStepReady, executeNextStep } = usePlayStore()
39 |
40 | return
51 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {
67 | const data = {
68 | nodes: nodes,
69 | edges: edges
70 | } as CrawlerProjectDto
71 | const dataString = JSON.stringify(data, null, 2)
72 | onSave("crawler-project.ncp", dataString, "saveProject")
73 | }}>
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | {
82 | loadCrawlerProject(event, reactFlowInstance)
83 | }}/>
84 |
85 |
86 |
87 |
88 | {
89 | if (isStepByStep) {
90 | setIsStepByStep(false)
91 | executeNextStep()
92 | } else {
93 | setup()
94 | }
95 | }}>
96 |
97 |
98 |
99 |
100 | {
101 | if (!isStepByStep) {
102 | setIsStepByStep(true)
103 | setup()
104 | } else {
105 | executeNextStep()
106 | }
107 | }}>
108 |
109 |
110 |
111 |
112 | {
113 | usePlayStore.getState().writeToLog("Crawler was stopped manually")
114 | stop()
115 | }}>
116 |
117 |
118 |
119 |
120 |
121 | {
122 | setIsConnectionHighlightingActivated(!isConnectionHighlightingActivated)
123 | }}>
124 | { isConnectionHighlightingActivated ? : }
125 |
126 |
127 |
128 |
131 |
134 |
135 |
136 |
137 | }
--------------------------------------------------------------------------------
/src/components/editor/headerbar/PageNavigation.tsx:
--------------------------------------------------------------------------------
1 | import {BottomNavigation, BottomNavigationAction} from "@mui/material";
2 | import useEditorPageState from "@/stores/editor/EditorPageStore";
3 | import React from "react";
4 |
5 | export default function PageNavigation() {
6 |
7 | const { pages, selectedPage } = useEditorPageState()
8 |
9 | return {
16 | useEditorPageState.getState().onPageChanged(newSelectedPage)
17 | }}
18 | >
19 | { Array.from(pages, ([value, page]) => {
20 | const {label, icon} = page;
21 | return
26 | }) }
27 |
28 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/DragAndDropFlow.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import ReactFlow, {
4 | Background,
5 | BackgroundVariant,
6 | Connection,
7 | Controls,
8 | MiniMap,
9 | Node,
10 | OnConnectStartParams,
11 | Panel,
12 | useReactFlow,
13 | } from 'reactflow';
14 | import 'reactflow/dist/style.css';
15 | import shallow from 'zustand/shallow';
16 | import {useReactFlowStore} from "@/stores/editor/ReactFlowStore";
17 | import React, {useCallback, useRef, useState} from "react";
18 | import {v4 as uuidv4} from 'uuid';
19 | import NodesToolbar from "@/components/editor/pages/canvas/toolbars/NodesToolbar";
20 | import './DragAndDropFlowStyles.css'
21 | import './edges/EdgeGradientStyles.css'
22 | import OptionsToolbar from "@/components/editor/pages/canvas/toolbars/OptionsToolbar";
23 | import {usePlayStore} from "@/stores/editor/PlayStore";
24 | import OnCanvasNodesToolbar from "@/components/editor/pages/canvas/toolbars/OnCanvasNodesSelector";
25 | import {getConnectionRule} from "@/config/ConnectionRules";
26 | import {nodesMetadataMap} from "@/config/NodesMetadata";
27 | import {NodeType} from "@/config/NodeType";
28 | import {selectedColor, toolbarBackgroundColor} from "@/config/colors";
29 |
30 | const selector = (state: any) => ({
31 | nodes: state.nodes,
32 | edges: state.edges,
33 | onNodesChange: state.onNodesChange,
34 | onEdgesChange: state.onEdgesChange,
35 | onConnect: state.onConnect,
36 | nodeTypes: state.nodeTypes,
37 | edgeTypes: state.edgeTypes,
38 | getNodeById: state.getNodeById,
39 | setCurrentConnectionStartNode: state.setCurrentConnectionStartNode
40 | });
41 |
42 | export default function DragAndDropFlow() {
43 | const { nodes, edges, onNodesChange, onEdgesChange, onConnect, nodeTypes, edgeTypes, getNodeById, setCurrentConnectionStartNode } = useReactFlowStore(selector, shallow)
44 |
45 | const setSelectedNodes = useReactFlowStore((state) => state.setSelectedNodes)
46 |
47 | const connectStartParams = useRef(null)
48 | const reactFlowWrapper = useRef(null)
49 | const reactFlowInstance = useReactFlow()
50 |
51 | const [isOnCanvasNodeSelectorOpen, setIsOnCanvasNodeSelectorOpen] = useState(false)
52 | const [lastEventPosition, setLastEventPosition] = useState<{x: number, y: number}>({x: 0, y: 0})
53 |
54 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
55 | const isStepByStep = usePlayStore(state => state.isStepByStep)
56 | const isNextStepReady = usePlayStore(state => state.isNextStepReady)
57 |
58 | const onDragOver = useCallback((event: any) => {
59 | event.preventDefault();
60 | event.dataTransfer.dropEffect = 'move';
61 | }, []);
62 |
63 | const onDrop = useCallback((event: any) => {
64 | event.preventDefault();
65 |
66 | // @ts-ignore
67 | const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
68 | const { nodeType, nodeData } = JSON.parse(event.dataTransfer.getData('application/reactflow'));
69 |
70 | // check if the dropped element is valid
71 | if (typeof nodeType === 'undefined' || !nodeType) {
72 | return;
73 | }
74 |
75 | const position = reactFlowInstance.project({
76 | x: event.clientX - reactFlowBounds.left,
77 | y: event.clientY - reactFlowBounds.top,
78 | });
79 |
80 | addNodeAtPosition(position, nodeType, nodeData)
81 | }, [reactFlowInstance]);
82 |
83 | const onConnectStart = useCallback((event: any, node: OnConnectStartParams) => {
84 | connectStartParams.current = node;
85 | setCurrentConnectionStartNode(getNodeById(node.nodeId))
86 | }, []);
87 |
88 | const onConnectEnd = useCallback(
89 | (event: any) => {
90 | setCurrentConnectionStartNode(null)
91 | const targetIsPane = event.target.classList.contains('react-flow__pane');
92 | const targetIsChallengeNode = event.target.parentElement.classList.contains("react-flow__node-challengeNode")
93 |
94 | if ((targetIsPane || targetIsChallengeNode) && connectStartParams.current?.handleType === "source" && reactFlowWrapper.current !== null) {
95 | // @ts-ignore
96 | const { top, left } = reactFlowWrapper.current.getBoundingClientRect();
97 | setLastEventPosition({ x: event.clientX - left, y: event.clientY - top })
98 | setIsOnCanvasNodeSelectorOpen(true)
99 | }
100 | },
101 | [reactFlowInstance.project]
102 | );
103 |
104 | function addNodeAtPosition(position: {x: number, y: number}, nodeType: NodeType, data: any = {}): string {
105 | let yOffset = 0
106 | let zIndex = 0
107 |
108 | let nodeInfo = nodesMetadataMap[nodeType]
109 | if(nodeInfo && nodeInfo.style.minHeight && typeof nodeInfo.style.minHeight === "number") {
110 | yOffset += nodeInfo.style.minHeight / 2
111 | }
112 |
113 | const id = uuidv4();
114 | const newNode = {
115 | id,
116 | type: nodeType,
117 | position: { ...position, y: position.y - yOffset },
118 | zIndex: zIndex,
119 | data: data,
120 | } as Node;
121 |
122 | reactFlowInstance.addNodes(newNode);
123 |
124 | return id
125 | }
126 |
127 | return (
128 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
165 | {
172 | setIsOnCanvasNodeSelectorOpen(false)
173 |
174 | if (nodeType !== null && connectStartParams.current !== null && connectStartParams.current?.nodeId !== null) {
175 | const id = addNodeAtPosition(reactFlowInstance.project(lastEventPosition), nodeType)
176 | let rule = getConnectionRule(nodeType, id)
177 | if(rule && rule.inputRules && rule.inputRules.length > 0) {
178 | onConnect({
179 | source: connectStartParams.current?.nodeId,
180 | target: id,
181 | sourceHandle: connectStartParams.current?.handleId,
182 | // TODO Eventuell abändern, sobald ich dynamische nodes in dem onCanvasSelect drin hab
183 | targetHandle: rule.inputRules[0].handleId
184 | } as Connection)
185 | }
186 | }
187 | }}
188 | />
189 |
190 | );
191 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/DragAndDropFlowStyles.css:
--------------------------------------------------------------------------------
1 | /*noinspection ALL,CssUnusedSymbol*/
2 | .react-flow__controls-button {
3 | background: #1A202C;
4 | border-color: #1A202C;
5 | fill: white
6 | }
7 |
8 | /*noinspection ALL,CssUnusedSymbol*/
9 | .react-flow__controls-button:hover {
10 | background-color: #131820;
11 | }
12 |
13 | .react-flow__minimap svg path {
14 | fill: #131820aa
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/edges/EdgeGradientStyles.css:
--------------------------------------------------------------------------------
1 | /* Incoming Edge */
2 | /*noinspection ALL,CssUnusedSymbol*/
3 | .react-flow__edge.incoming .react-flow__edge-path {
4 | stroke: url(#incoming-edge-gradient);
5 | stroke-width: 2;
6 | stroke-opacity: 0.75;
7 | }
8 |
9 | /* Outgoing Edge */
10 | /*noinspection ALL,CssUnusedSymbol*/
11 | .react-flow__edge.outgoing .react-flow__edge-path {
12 | stroke: url(#outgoing-edge-gradient);
13 | stroke-width: 2;
14 | stroke-opacity: 0.75;
15 | }
16 |
17 | /* Both Selected Edge */
18 | /*noinspection ALL,CssUnusedSymbol*/
19 | .react-flow__edge.bothSelected .react-flow__edge-path {
20 | stroke: url(#bothSelected-edge-gradient);
21 | stroke-width: 2;
22 | stroke-opacity: 0.75;
23 | }
24 |
25 | /* Edge Selected Edge */
26 | /*noinspection ALL,CssUnusedSymbol*/
27 | .react-flow__edge.edgeSelected .react-flow__edge-path {
28 | stroke: url(#edgeSelected-edge-gradient);
29 | stroke-width: 2;
30 | stroke-opacity: 0.75;
31 | filter: drop-shadow(0px 0px 5px var(--selected-color));
32 | }
33 |
34 | /* Default Edge */
35 | /*noinspection ALL,CssUnusedSymbol*/
36 | .react-flow__edge.default .react-flow__edge-path {
37 | stroke: url(#default-edge-gradient);
38 | stroke-width: 2;
39 | stroke-opacity: 0.75;
40 | }
41 |
42 | /* HtmlHighlight Edge */
43 | /*noinspection ALL,CssUnusedSymbol*/
44 | .react-flow__edge.html .react-flow__edge-path {
45 | stroke: url(#html-edge-gradient);
46 | stroke-width: 2;
47 | stroke-opacity: 0.75;
48 | }
49 |
50 | /* TextHighlight Edge */
51 | /*noinspection ALL,CssUnusedSymbol*/
52 | .react-flow__edge.text .react-flow__edge-path {
53 | stroke: url(#text-edge-gradient);
54 | stroke-width: 2;
55 | stroke-opacity: 0.75;
56 | }
57 |
58 | /* JsonHighlight Edge */
59 | /*noinspection ALL,CssUnusedSymbol*/
60 | .react-flow__edge.json .react-flow__edge-path {
61 | stroke: url(#json-edge-gradient);
62 | stroke-width: 2;
63 | stroke-opacity: 0.75;
64 | }
65 |
66 | /* NoneHighlight Edge */
67 | /*noinspection ALL,CssUnusedSymbol*/
68 | .react-flow__edge.none .react-flow__edge-path {
69 | stroke: url(#none-edge-gradient);
70 | stroke-width: 2;
71 | stroke-opacity: 0.75;
72 | }
73 |
74 | /* DatabaseHighlight Edge */
75 | /*noinspection ALL,CssUnusedSymbol*/
76 | .react-flow__edge.database .react-flow__edge-path {
77 | stroke: url(#database-edge-gradient);
78 | stroke-width: 2;
79 | stroke-opacity: 0.75;
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/edges/Edges.ts:
--------------------------------------------------------------------------------
1 | import {createEdge} from "@/components/editor/pages/canvas/edges/util/EdgeCreators";
2 | import {EdgeProps} from "reactflow";
3 | import React from "react";
4 | import {OutputValueType} from "@/config/OutputValueType";
5 | import './EdgeGradientStyles.css'
6 | import {defaultEdgeColor, selectedColor} from "@/config/colors";
7 |
8 | export const DefaultEdge = createEdge(defaultEdgeColor, defaultEdgeColor, "default")
9 | export const SelectedIncomingEdge = createEdge(defaultEdgeColor, selectedColor, "incoming")
10 | export const SelectedOutgoingEdge = createEdge(selectedColor, defaultEdgeColor, "outgoing")
11 | export const BothSelectedEdge = createEdge(selectedColor, selectedColor, "bothSelected")
12 | export const EdgeSelectedEdge = createEdge(selectedColor, selectedColor, "edgeSelected")
13 |
14 | export const highlightEdges: HighlightEdgeInfoTypes = {
15 | [OutputValueType.NONE]: createEdge(defaultEdgeColor, defaultEdgeColor, OutputValueType.NONE),
16 | [OutputValueType.TEXT]: createEdge("#7CFC00", "#7CFC00", OutputValueType.TEXT),
17 | [OutputValueType.JSON]: createEdge("#87CEEB", "#87CEEB", OutputValueType.JSON),
18 | [OutputValueType.HTML]: createEdge("#FF00FF", "#FF00FF", OutputValueType.HTML),
19 | [OutputValueType.DATABASE]: createEdge("#FFFF00", "#FFFF00", OutputValueType.DATABASE)
20 | }
21 |
22 | type HighlightEdgeInfoTypes = {
23 | [K in OutputValueType]: EdgeType
24 | }
25 |
26 | export type EdgeType = ({id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, markerEnd}: EdgeProps) => React.ReactNode | null
27 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/edges/util/EdgeCreators.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {EdgeProps, getSmoothStepPath} from 'reactflow';
3 | import "../EdgeGradientStyles.css"
4 |
5 | export function createEdge(startColor: string, endColor: string, edgeId: string) {
6 |
7 | return ({
8 | id,
9 | sourceX,
10 | sourceY,
11 | targetX,
12 | targetY,
13 | sourcePosition,
14 | targetPosition,
15 | style = {}
16 | }: EdgeProps) => {
17 |
18 | const xEqual = sourceX === targetX;
19 | const yEqual = sourceY === targetY;
20 |
21 | const [edgePath] = getSmoothStepPath({
22 | sourceX: xEqual ? sourceX + 0.0001 : sourceX,
23 | sourceY: yEqual ? sourceY + 0.0001 : sourceY,
24 | sourcePosition,
25 | targetX,
26 | targetY,
27 | targetPosition,
28 | });
29 |
30 | return (
31 |
32 |
33 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
57 |
58 | );
59 | }
60 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/DatabaseTableNode.tsx:
--------------------------------------------------------------------------------
1 | // --- Data ---
2 | import {
3 | createDynamicNodeComponent,
4 | createNodeShapeStyle,
5 | createOptionsComponent
6 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
7 | import {NodeType} from "@/config/NodeType";
8 | import React, {useEffect} from "react";
9 | import {NodeMetadata} from "@/config/NodesMetadata";
10 | import {DynamicNodeData, NodeData} from "@/model/NodeData";
11 | import {EngineDatabaseTableNode} from "@/engine/nodes/EngineDatabaseTableNode";
12 | import {Button, IconButton} from "@mui/material";
13 | import {InputRule} from "@/config/ConnectionRules";
14 | import {OutputValueType} from "@/config/OutputValueType";
15 | import TextInputOption from "@/components/form/TextInputOption";
16 | import StorageIcon from '@mui/icons-material/Storage';
17 | import RowOptionsContainer from "@/components/form/RowOptionsContainer";
18 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
19 | import MultiSelectOption from "@/components/form/MultiSelectOption";
20 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
21 | import DraggableOptionsListContainer from "@/components/form/DraggableOptionsListContainer";
22 |
23 | export interface DatabaseTableNodeData extends DynamicNodeData {
24 | tableName: string
25 | }
26 |
27 | // --- Style ---
28 | export const DatabaseTableShapeStyle = createNodeShapeStyle({
29 | height: 70,
30 | width: 100,
31 | display: "flex",
32 | flexDirection: "column",
33 | alignItems: "center"
34 | })
35 |
36 | // --- Node ---
37 | export const DatabaseTableNode = createDynamicNodeComponent(
38 | NodeType.DATABASE_TABLE_NODE,
39 | DatabaseTableShapeStyle,
40 | () => {
41 | return
49 |
50 |
51 | }
52 | )
53 |
54 | // --- Options ---
55 | export const DatabaseTableOptions = createOptionsComponent("Database Table", ({ id, data, onDataUpdated }) => {
56 |
57 | const [inputs, setInputs] = React.useState(data.connectionRule ? [...data.connectionRule.inputRules] : [])
58 |
59 | function addInput() {
60 | setInputs([...inputs, {
61 | handleId: "",
62 | allowedValueTypes: [
63 | OutputValueType.HTML
64 | ],
65 | maxConnections: 999
66 | } as InputRule])
67 | }
68 |
69 | useEffect(() => {
70 | onDataUpdated("connectionRule", {
71 | ...data.connectionRule,
72 | inputRules: inputs,
73 | outputValueType: OutputValueType.DATABASE
74 | })
75 | }, [inputs]);
76 |
77 | return <>
78 | ) => {
82 | onDataUpdated("tableName", event.target.value)
83 | }}
84 | />
85 |
86 | items={inputs}
87 | onOrderChanged={(newItems) => setInputs(newItems)}
88 | mapItem={(item: InputRule, index: number) => {
89 | return
90 | ) => {
95 | const cleanedValue = event.target.value.replaceAll(" ", "-")
96 | useReactFlowStore.getState().replaceEdgeAfterHandleRename(id, item.handleId, cleanedValue)
97 | const newInputs = [...inputs]
98 | newInputs[index].handleId = cleanedValue
99 | setInputs(newInputs)
100 | }}
101 | />
102 | {
106 | const newInputs = [...inputs]
107 | newInputs[index].allowedValueTypes = newSelection.map(selection => selection as OutputValueType)
108 | setInputs(newInputs)
109 | }}
110 | />
111 | {
113 | const newInputs = [...inputs]
114 | newInputs.splice(index, 1)
115 | setInputs(newInputs)
116 | }
117 | } style={{ width: 40, height: 40 }} >
118 |
119 |
120 |
121 | }}
122 | />
123 |
124 | >
125 | })
126 |
127 |
128 | // --- Metadata ---
129 | export const DatabaseTableNodeMetadata = {
130 | title: "Database Table",
131 | type: NodeType.DATABASE_TABLE_NODE,
132 | getNodeComponent: DatabaseTableNode,
133 | getOptionsComponent: (id: string) => ,
134 | style: DatabaseTableShapeStyle(true),
135 | icon: ,
136 | getEngineNode: (id: string, data: NodeData) => {
137 | return new EngineDatabaseTableNode(id, data as DatabaseTableNodeData)
138 | }
139 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/ExtractorNode.tsx:
--------------------------------------------------------------------------------
1 | // --- Data ---
2 | import {
3 | createStaticNodeComponent,
4 | createNodeShapeStyle,
5 | createOptionsComponent
6 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
7 | import {NodeData} from "@/model/NodeData";
8 | import {NodeType} from "@/config/NodeType";
9 | import React from "react";
10 | import ManageSearchIcon from '@mui/icons-material/ManageSearch';
11 | import TextInputOption from "@/components/form/TextInputOption";
12 | import {EngineExtractorNode} from "@/engine/nodes/EngineExtractorNode";
13 | import {NodeMetadata} from "@/config/NodesMetadata";
14 | import SelectOption from "@/components/form/SelectOption";
15 |
16 | export interface ExtractorNodeData extends NodeData {
17 | tag: string
18 | extractionMode: ExtractionMode
19 | attributeToExtract: string
20 | }
21 |
22 | // --- Style ---
23 | export const extractorShapeStyle = createNodeShapeStyle()
24 |
25 | // --- Node ---
26 | export const ExtractorNode = createStaticNodeComponent(
27 | NodeType.EXTRACTOR_NODE,
28 | extractorShapeStyle,
29 | () => {
30 | return
38 |
39 |
40 | }
41 | )
42 |
43 | // --- Options ---
44 | export const ExtractorOptions = createOptionsComponent("Extractor", ({ id, data, onDataUpdated }) => {
45 | return <>
46 | {
50 | onDataUpdated("tag", event.target.value)
51 | }}
52 | />
53 | {
57 | onDataUpdated("extractionMode", newSelection)
58 | }}
59 | label={"Select what should be extracted"}
60 | />
61 | { data.extractionMode === ExtractionMode.ATTRIBUTE && {
65 | onDataUpdated("attributeToExtract", event.target.value)
66 | }}
67 | /> }
68 | >
69 | })
70 |
71 |
72 | // --- Metadata ---
73 | export const extractorNodeMetadata = {
74 | title: "Extractor",
75 | type: NodeType.EXTRACTOR_NODE,
76 | getNodeComponent: ExtractorNode,
77 | getOptionsComponent: (id: string) => ,
78 | style: extractorShapeStyle(true),
79 | icon: ,
80 | getEngineNode: (id: string, data: NodeData) => {
81 | return new EngineExtractorNode(id, data as ExtractorNodeData)
82 | }
83 | } as NodeMetadata
84 |
85 |
86 | export enum ExtractionMode {
87 | CONTENT = "Content",
88 | ATTRIBUTE = "Attribute"
89 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/FetchWebsiteNode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from "react";
4 | import TextInputOption from "@/components/form/TextInputOption";
5 | import {Badge, Button, IconButton, Typography} from "@mui/material";
6 | import CloudDownloadTwoToneIcon from '@mui/icons-material/CloudDownloadTwoTone';
7 | import {NodeData} from "@/model/NodeData";
8 | import {
9 | createNodeShapeStyle,
10 | createOptionsComponent,
11 | createStaticNodeComponent
12 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
13 | import {EngineFetchWebsiteNode} from "@/engine/nodes/EngineFetchWebsiteNode";
14 | import {NodeType} from "@/config/NodeType";
15 | import {NodeMetadata} from "@/config/NodesMetadata";
16 | import {defaultEdgeColor, selectedColor} from "@/config/colors";
17 | import DraggableOptionsListContainer from "@/components/form/DraggableOptionsListContainer";
18 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
19 |
20 |
21 | // --- Data ---
22 | export interface FetchWebsiteNodeData extends NodeData {
23 | name: string
24 | urls: string[]
25 | }
26 |
27 |
28 | // --- Style ---
29 | export const fetchWebsiteShapeStyle = createNodeShapeStyle({
30 | minWidth: 100
31 | })
32 |
33 |
34 | // --- Node ---
35 | export const FetchWebsiteNode = createStaticNodeComponent(
36 | NodeType.FETCH_WEBSITE_NODE,
37 | fetchWebsiteShapeStyle,
38 | (id, selected, data) => {
39 | return } anchorOrigin={{ horizontal: "left", vertical: "top" }} >
42 |
50 |
51 | { (data.name && data.name !== "") ? data.name : "Fetch Website" }
52 |
53 |
54 |
55 | }
56 | )
57 |
58 |
59 | // --- Options ---
60 | export const FetchWebsiteOptions = createOptionsComponent("Fetch Website", ({ id, data, onDataUpdated }) => {
61 |
62 | const [urls, setUrls] = React.useState(data.urls ?? [""])
63 |
64 | function addValue() {
65 | setUrls([...urls, ""])
66 | }
67 |
68 | return <>
69 | ) => {
73 | onDataUpdated("name", event.target.value)
74 | }}
75 | />
76 | setUrls(newUrls)}
79 | mapItem={(url: string, index: number) => {
80 | return 0 ? index + 1 : "")}
83 | value={url}
84 | onChange={(event: React.ChangeEvent) => {
85 | const newUrls = [...urls]
86 | newUrls[index] = event.target.value
87 | setUrls(newUrls)
88 | onDataUpdated("urls", newUrls)
89 | }}
90 | inputProps = {{
91 | endAdornment: (
92 | index > 0 ?
93 | {
95 | const newUrls = [...urls]
96 | newUrls.splice(index, 1)
97 | setUrls(newUrls)
98 | onDataUpdated("urls", newUrls)
99 | }
100 | }>
101 |
102 |
103 | :
104 | <>>
105 | )
106 | }}
107 | />
108 | }}
109 | />
110 |
111 | >
112 | })
113 |
114 |
115 | // --- Metadata ---
116 | export const fetchWebsiteNodeMetadata = {
117 | title: "Fetch Website",
118 | type: NodeType.FETCH_WEBSITE_NODE,
119 | getNodeComponent: FetchWebsiteNode,
120 | getOptionsComponent: (id: string) => ,
121 | style: fetchWebsiteShapeStyle(true),
122 | icon: ,
123 | getEngineNode: (id: string, data: NodeData) => {
124 | return new EngineFetchWebsiteNode(id, data as FetchWebsiteNodeData)
125 | }
126 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/HtmlToTextNode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from "react";
4 | import {NodeData} from "@/model/NodeData";
5 | import {
6 | createStaticNodeComponent,
7 | createNodeShapeStyle,
8 | createOptionsComponent
9 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
10 | import {NodeType} from "@/config/NodeType";
11 | import {NodeMetadata} from "@/config/NodesMetadata";
12 | import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
13 | import {EngineHtmlToTextNode} from "@/engine/nodes/EngineHtmlToTextNode";
14 |
15 |
16 | // --- Data ---
17 | export interface HtmlToTextNodeData extends NodeData {
18 | fileName: string
19 | extension: string
20 | separator: string
21 | }
22 |
23 |
24 | // --- Style ---
25 | export const htmlToTextShapeStyle = createNodeShapeStyle()
26 |
27 |
28 | // --- Node ---
29 | export const HtmlToTextNode = createStaticNodeComponent(
30 | NodeType.HTML_TO_TEXT_NODE,
31 | htmlToTextShapeStyle,
32 | () => {
33 | return
41 |
42 |
43 | }
44 | )
45 |
46 | // --- Options ---
47 | export const HtmlToTextOptions = createOptionsComponent("HtmlToText", ({id, data, onDataUpdated}) => {
48 | return <>>
49 | })
50 |
51 |
52 | // --- Metadata ---
53 | export const htmlToTextNodeMetadata = {
54 | title: "HtmlToText",
55 | type: NodeType.HTML_TO_TEXT_NODE,
56 | getNodeComponent: HtmlToTextNode,
57 | getOptionsComponent: (id: string) => ,
58 | style: htmlToTextShapeStyle(true),
59 | icon: ,
60 | getEngineNode: (id: string, data: NodeData) => {
61 | return new EngineHtmlToTextNode(id, data as HtmlToTextNodeData)
62 | }
63 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/SaveNode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from "react";
4 | import {NodeData} from "@/model/NodeData";
5 | import FileNameInputOption from "@/components/form/FileNameInputOption";
6 | import TextInputOption from "@/components/form/TextInputOption";
7 | import {
8 | createStaticNodeComponent,
9 | createNodeShapeStyle,
10 | createOptionsComponent
11 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
12 | import {NodeType} from "@/config/NodeType";
13 | import SaveIcon from '@mui/icons-material/Save';
14 | import {EngineSaveNode} from "@/engine/nodes/EngineSaveNode";
15 | import {NodeMetadata} from "@/config/NodesMetadata";
16 |
17 |
18 | // --- Data ---
19 | export interface SaveNodeData extends NodeData {
20 | fileName: string
21 | extension: string
22 | separator: string
23 | }
24 |
25 |
26 | // --- Style ---
27 | export const saveShapeStyle = createNodeShapeStyle()
28 |
29 |
30 | // --- Node ---
31 | export const SaveNode = createStaticNodeComponent(
32 | NodeType.SAVE_NODE,
33 | saveShapeStyle,
34 | () => {
35 | return
43 |
44 |
45 | }
46 | )
47 |
48 | // --- Options ---
49 | export const SaveOptions = createOptionsComponent("Save", ({id, data, onDataUpdated}) => {
50 | return <>
51 | {
54 | onDataUpdated("fileName", event.target.value)
55 | }}
56 | extension={data.extension}
57 | onExtensionChange={(event) => {
58 | onDataUpdated("extension", event.target.value)
59 | }}
60 | />
61 | {
65 | onDataUpdated("separator", event.target.value)
66 | }}
67 | />
68 | >
69 | })
70 |
71 |
72 | // --- Metadata ---
73 | export const saveNodeMetadata = {
74 | title: "Save",
75 | type: NodeType.SAVE_NODE,
76 | getNodeComponent: SaveNode,
77 | getOptionsComponent: (id: string) => ,
78 | style: saveShapeStyle(true),
79 | icon: ,
80 | getEngineNode: (id: string, data: NodeData) => {
81 | return new EngineSaveNode(id, data as SaveNodeData)
82 | }
83 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/StartNode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from "react";
4 | import {NodeData} from "@/model/NodeData";
5 | import {LoadingButton} from "@mui/lab";
6 | import PlayCircleFilledIcon from '@mui/icons-material/PlayCircleFilled';
7 | import {usePlayStore} from "@/stores/editor/PlayStore";
8 | import {
9 | createStaticNodeComponent,
10 | createNodeShapeStyle,
11 | createOptionsComponent
12 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
13 | import {NodeType} from "@/config/NodeType";
14 | import PlayArrowIcon from "@mui/icons-material/PlayArrow";
15 | import {EngineStartNode} from "@/engine/nodes/EngineStartNode";
16 | import {NodeMetadata} from "@/config/NodesMetadata";
17 |
18 | // --- Data ---
19 | export interface StartNodeData extends NodeData {
20 | }
21 |
22 |
23 | // --- Style ---
24 | export const startShapeStyle = createNodeShapeStyle({
25 | borderRadius: "50%"
26 | })
27 |
28 |
29 | // --- Node ---
30 | export const StartNode = createStaticNodeComponent(
31 | NodeType.START_NODE,
32 | startShapeStyle,
33 | () => {
34 | return
44 | }
45 | )
46 |
47 | // --- Options ---
48 | export const StartOptions = createOptionsComponent("Start", () => {
49 | return }
52 | variant="contained"
53 | onClick={() => {
54 | usePlayStore.getState().setup()
55 | }}
56 | sx={{
57 | width: "100%"
58 | }}
59 | >
60 | Start Crawler
61 |
62 | })
63 |
64 |
65 | // --- Metadata ---
66 | export const startNodeMetadata = {
67 | title: "Start",
68 | type: NodeType.START_NODE,
69 | getNodeComponent: StartNode,
70 | getOptionsComponent: (id: string) => ,
71 | style: startShapeStyle(true),
72 | icon: ,
73 | getEngineNode: (id: string, data: NodeData) => {
74 | return new EngineStartNode(id, data as StartNodeData)
75 | }
76 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/ZipNode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from "react";
4 | import {NodeData} from "@/model/NodeData";
5 | import {
6 | createStaticNodeComponent,
7 | createNodeShapeStyle,
8 | createOptionsComponent
9 | } from "@/components/editor/pages/canvas/nodes/util/Creators";
10 | import {NodeType} from "@/config/NodeType";
11 | import {NodeMetadata} from "@/config/NodesMetadata";
12 | import MergeIcon from '@mui/icons-material/Merge';
13 | import {EngineZipNode} from "@/engine/nodes/EngineZipNode";
14 |
15 | // TODO beliebig viele Inputs jeweils mit Namen einstellbar in den Optionen. Dafür muss ziemlich sicher wieder etwas an den connectionRules geändert werden
16 |
17 | // --- Data ---
18 | export interface ZipNodeData extends NodeData {
19 | }
20 |
21 |
22 | // --- Style ---
23 | export const zipShapeStyle = createNodeShapeStyle({})
24 |
25 |
26 | // --- Node ---
27 | export const ZipNode = createStaticNodeComponent(
28 | NodeType.ZIP_NODE,
29 | zipShapeStyle,
30 | () => {
31 | return
39 |
40 |
41 | }
42 | )
43 |
44 | // --- Options ---
45 | export const ZipOptions = createOptionsComponent("Zip", () => {
46 | return <>>
47 | })
48 |
49 |
50 | // --- Metadata ---
51 | export const zipNodeMetadata = {
52 | title: "Zip",
53 | type: NodeType.ZIP_NODE,
54 | getNodeComponent: ZipNode,
55 | getOptionsComponent: (id: string) => ,
56 | style: zipShapeStyle(true),
57 | icon: ,
58 | getEngineNode: (id: string, data: NodeData) => {
59 | return new EngineZipNode(id, data as ZipNodeData)
60 | }
61 | } as NodeMetadata
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/util/Creators.tsx:
--------------------------------------------------------------------------------
1 | import {Handle, Node, NodeProps, Position, useUpdateNodeInternals} from "reactflow";
2 | import React, {CSSProperties, useEffect, useMemo, useState} from "react";
3 | import {handleStyle, useReactFlowStore} from "@/stores/editor/ReactFlowStore";
4 | import {addOrUpdateDynamicRule, getInputRules, getOutputValueType} from "@/config/ConnectionRules";
5 | import OptionsContainer from "@/components/form/OptionsContainer";
6 | import {setNodeWithUpdatedDataValue} from "@/components/editor/pages/canvas/nodes/util/OptionsUtil";
7 | import {usePlayStore} from "@/stores/editor/PlayStore";
8 | import CacheTextField from "@/components/form/CacheTextField";
9 | import {Tooltip} from "@mui/material";
10 | import {NodeType} from "@/config/NodeType";
11 | import {DynamicNodeData, NodeData} from "@/model/NodeData";
12 | import {nodeBackgroundColor, nodeShadowColor, selectedColor} from "@/config/colors";
13 | import Typography from "@mui/material/Typography";
14 |
15 | export const createNodeShapeStyle = (additionalCSS: CSSProperties = {}): (selected: boolean) => CSSProperties => {
16 | return function(selected) {
17 | return {
18 | minWidth: 60,
19 | minHeight: 60,
20 | backgroundColor: nodeBackgroundColor,
21 | borderRadius: 6,
22 | border: "2px solid",
23 | borderColor: selected ? selectedColor : nodeBackgroundColor,
24 | boxShadow: selected ? `0px 0px 5px 1px ${selectedColor}` : `0px 0px 3px 2px ${nodeShadowColor}`,
25 | ...additionalCSS
26 | } as CSSProperties
27 | }
28 | }
29 |
30 | export function createStaticNodeComponent(
31 | nodeType: NodeType,
32 | shapeStyle: (isSelected: boolean) => CSSProperties,
33 | content: (id: string, selected: boolean, data: DataType) => React.ReactNode
34 | ) {
35 |
36 | return function({ id, selected, data }: NodeProps) {
37 |
38 | const inputRules = getInputRules(nodeType, id)
39 |
40 | const currentConnectionStartNode = useReactFlowStore(state => state.currentConnectionStartNode)
41 | const handleHighlightedMap = useMemo(() => {
42 | let newMap = new Map()
43 |
44 | if (inputRules && currentConnectionStartNode) {
45 | // Find the rule for the currentConnectionStartNodeType once before the loop
46 | const outputValueType = getOutputValueType(currentConnectionStartNode.type as NodeType, currentConnectionStartNode.id)
47 | if (outputValueType) {
48 | inputRules.forEach(rule => {
49 | newMap.set(rule.handleId, rule.allowedValueTypes.includes(outputValueType) && currentConnectionStartNode.id !== id);
50 | });
51 | }
52 | }
53 |
54 | return newMap;
55 | }, [currentConnectionStartNode]);
56 |
57 | return
60 | { getInputRules(nodeType, id).map(rule => {
61 | return
62 |
66 |
67 | }) }
68 | { getOutputValueType(nodeType, id) && (
69 |
70 |
71 |
72 | ) }
73 | { content(id, selected, data) }
74 |
75 | }
76 | }
77 |
78 | export function createDynamicNodeComponent(
79 | nodeType: NodeType,
80 | shapeStyle: (isSelected: boolean) => CSSProperties,
81 | content: (id: string, selected: boolean, data: DataType) => React.ReactNode
82 | ) {
83 |
84 | return function({ id, selected, data }: NodeProps) {
85 |
86 | const updateNodeInternals = useUpdateNodeInternals()
87 |
88 | useEffect(() => {
89 | if (data.connectionRule) {
90 | addOrUpdateDynamicRule(nodeType, id, data.connectionRule)
91 | updateNodeInternals(id)
92 | }
93 | }, [data.connectionRule]);
94 |
95 | const currentConnectionStartNode = useReactFlowStore(state => state.currentConnectionStartNode)
96 | const handleHighlightedMap = useMemo(() => {
97 | let newMap = new Map()
98 |
99 | if (data.connectionRule?.inputRules && currentConnectionStartNode) {
100 | // Find the rule for the currentConnectionStartNodeType once before the loop
101 | const outputValueType = getOutputValueType(currentConnectionStartNode.type as NodeType, currentConnectionStartNode.id)
102 | if (outputValueType) {
103 | data.connectionRule.inputRules.forEach(rule => {
104 | newMap.set(rule.handleId, rule.allowedValueTypes.includes(outputValueType) && currentConnectionStartNode.id !== id);
105 | });
106 | }
107 | }
108 |
109 | return newMap;
110 | }, [currentConnectionStartNode]);
111 |
112 | return
116 | { data.connectionRule?.inputRules.map((rule, index) => {
117 | return
118 |
124 |
125 | }) }
126 | { data.connectionRule?.outputValueType && (
127 |
128 | 0 ?
131 | Math.max(shapeStyle(selected).height as number || 0, shapeStyle(selected).minHeight as number || 0) + ((data.connectionRule.inputRules.length - 1) * 30) / 2
132 | : undefined,
133 | zIndex: 1
134 | }} type="source" position={Position.Right}/>
135 |
136 | ) }
137 | { content(id, selected, data) }
138 |
147 | { data.connectionRule?.inputRules.map((rule) => {
148 | return
149 |
150 | {rule.handleId}
151 |
152 |
153 | }) }
154 |
155 |
156 | }
157 | }
158 |
159 | export function createOptionsComponent(
160 | optionsTitle: string,
161 | Options: React.ComponentType<{ id: string, data: DataType, onDataUpdated: (attributeName: keyof DataType, newValue: any) => void }>
162 | ) {
163 | return function(props: { id: string }) {
164 | const updateNodeData = useReactFlowStore((state) => state.updateNodeData)
165 | const getNodeById = useReactFlowStore((state) => state.getNodeById)
166 |
167 | const pipelines = usePlayStore(state => state.pipelines)
168 | const [cachedOutput, setCachedOutput] = useState(null)
169 |
170 | useEffect(() => {
171 | const ownOutgoingPipeline = pipelines.find(pipeline => pipeline.from === props.id)
172 | if (ownOutgoingPipeline && ownOutgoingPipeline.value) {
173 | setCachedOutput(ownOutgoingPipeline.value.map(output => JSON.stringify(output.value)))
174 | } else {
175 | setCachedOutput(null)
176 | }
177 | }, [pipelines])
178 |
179 | const [localNode, setLocalNode] = useState(null)
180 |
181 | useEffect(() => {
182 | const currentNode = getNodeById(props.id);
183 | setLocalNode(currentNode);
184 | }, [props.id, getNodeById])
185 |
186 | useEffect(() => {
187 | if (localNode !== null) {
188 | updateNodeData(props.id, localNode.data as DataType)
189 |
190 | // Change node data inside of node in nodemap when process is running
191 | // Allows to edit node options while the process runs
192 | if (usePlayStore.getState().isProcessRunning) {
193 | const newNode = usePlayStore.getState().nodeMap.get(props.id)
194 | if (newNode) {
195 | newNode.node.data = localNode.data as DataType
196 | usePlayStore.getState().nodeMap.set(props.id, newNode)
197 | }
198 | }
199 | }
200 | }, [localNode, updateNodeData])
201 |
202 | return localNode !== null &&
203 | {
207 | if(typeof attributeName === "string") {
208 | setNodeWithUpdatedDataValue(setLocalNode, attributeName, newValue)
209 | }
210 | }}
211 | />
212 | { cachedOutput !== null &&
213 | console.log("Change")}
217 | />
218 | }
219 |
220 | }
221 | }
222 |
223 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/nodes/util/OptionsUtil.ts:
--------------------------------------------------------------------------------
1 | import {Node} from "reactflow";
2 | import React from "react";
3 |
4 | export function setNodeWithUpdatedDataValue(
5 | setNode: React.Dispatch>,
6 | key: string,
7 | value: string
8 | ): void {
9 | setNode((oldNode) => {
10 | if (oldNode === null) {
11 | return null
12 | }
13 | return {
14 | ...oldNode,
15 | data: {
16 | ...oldNode.data,
17 | [key]: value
18 | }
19 | }
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/toolbars/Html.css:
--------------------------------------------------------------------------------
1 | .highlighted {
2 | background-color: yellow;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/toolbars/NodesToolbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {Tooltip} from "@mui/material";
4 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
5 | import {useEffect, useState} from "react";
6 | import {NodeType} from "@/config/NodeType";
7 | import {getAllNodesMetadata, NodeMetadata} from "@/config/NodesMetadata";
8 |
9 | export default function NodesToolbar() {
10 |
11 | const onDragStart = (event: any, nodeType: String, nodeData: any) => {
12 | event.dataTransfer.setData('application/reactflow', JSON.stringify({ nodeType, nodeData }));
13 | event.dataTransfer.effectAllowed = 'move';
14 | };
15 |
16 | const nodes = useReactFlowStore(state => state.nodes)
17 | const [isStartAlreadyPlaced, setIsStartAlreadyPlaced] = useState(false)
18 |
19 | useEffect(() => {
20 | setIsStartAlreadyPlaced(nodes.filter(node => node.type === NodeType.START_NODE).length !== 0)
21 | }, [nodes])
22 |
23 | return (
24 |
66 | );
67 | };
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/toolbars/OnCanvasNodesSelector.tsx:
--------------------------------------------------------------------------------
1 | 'use Client'
2 |
3 | import * as React from 'react';
4 | import DialogTitle from '@mui/material/DialogTitle';
5 | import Dialog from '@mui/material/Dialog';
6 | import {Tooltip, Typography} from "@mui/material";
7 | import {NodeType} from "@/config/NodeType";
8 | import {Node} from "reactflow";
9 | import {getInputRules, getOutputValueType, isDynamicNodeType} from "@/config/ConnectionRules";
10 | import {getAllNodesMetadata} from "@/config/NodesMetadata";
11 |
12 | export interface OnCanvasNodesToolbarProps {
13 | open: boolean;
14 | position: {x: number, y: number}
15 | onClose: (nodeType: NodeType | null) => void;
16 | sourceNode: Node | null
17 | }
18 |
19 | export default function OnCanvasNodesToolbar(props: OnCanvasNodesToolbarProps) {
20 | const { onClose, open, position } = props;
21 | // Change width and height when adding new elements to toolbar
22 | const width = 160
23 | const height = 500
24 |
25 | const [windowDimensions, setWindowDimensions] = React.useState<{ width: number, height: number }>({
26 | width: typeof window !== "undefined" ? window.innerWidth : 900, // Fall back to 900 if window is not defined
27 | height: typeof window !== "undefined" ? window.innerHeight : 600, // Fall back to 600 if window is not defined
28 | });
29 |
30 | React.useEffect(() => {
31 | function handleResize() {
32 | setWindowDimensions({ width: window.innerWidth, height: window.innerHeight });
33 | }
34 |
35 | window.addEventListener('resize', handleResize);
36 | return () => window.removeEventListener('resize', handleResize);
37 | }, []);
38 |
39 | const handleClose = () => {
40 | onClose(null)
41 | }
42 |
43 | const handleNodeSelected = (nodeType: NodeType) => {
44 | onClose(nodeType);
45 | };
46 |
47 | return (
48 |
113 | );
114 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/canvas/toolbars/OptionsToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useMemo, useState} from "react";
2 | import {useReactFlowStore} from "@/stores/editor/ReactFlowStore";
3 | import {Node,} from 'reactflow';
4 | import {usePlayStore} from "@/stores/editor/PlayStore";
5 | import Log from "@/components/editor/pages/output/Log";
6 | import OptionsContainer from "@/components/form/OptionsContainer";
7 | import {nodesMetadataMap} from "@/config/NodesMetadata";
8 | import {BottomNavigation, BottomNavigationAction, Typography} from "@mui/material";
9 | import TuneIcon from '@mui/icons-material/Tune';
10 | import DescriptionIcon from '@mui/icons-material/Description';
11 | import {NodeType} from "@/config/NodeType";
12 | import {selectedColor, toolbarBackgroundColor} from "@/config/colors";
13 |
14 | export default function OptionsToolbar() {
15 |
16 | const selectedNodes = useReactFlowStore((state) => state.selectedNodes)
17 | const [options, setOptions] = useState(<>>)
18 | const [currentNode, setCurrentNode] = useState(null)
19 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
20 |
21 | const [selectedPage, setSelectedPage] = useState("options")
22 | const toolbarPages = useMemo(() => {
23 | return new Map([
24 | ["options", {
25 | icon: ,
26 | tooltip: "Options"
27 | }],
28 | ["log", {
29 | icon: ,
30 | tooltip: "Log"
31 | }]
32 | ])
33 | }, [options])
34 |
35 | useEffect(() => {
36 | if (isProcessRunning) {
37 | setSelectedPage("log")
38 | }
39 | }, [isProcessRunning])
40 |
41 | useEffect(() => {
42 | if (selectedNodes.length === 1) {
43 | setCurrentNode(selectedNodes[0])
44 | } else {
45 | setCurrentNode(null)
46 | }
47 | }, [selectedNodes])
48 |
49 | useEffect(() => {
50 | if (!currentNode) {
51 | setOptions(<>>)
52 | return
53 | }
54 |
55 | const nodeInfo = nodesMetadataMap[currentNode.type as NodeType]
56 |
57 | if (nodeInfo) {
58 | setOptions(nodeInfo.getOptionsComponent(currentNode.id))
59 | } else {
60 | setOptions(<>>)
61 | }
62 | }, [currentNode])
63 |
64 | return (
65 |
71 | { isProcessRunning &&
83 |
84 | ! Crawler is running !
85 |
86 | {
96 | setSelectedPage(newSelectedPage)
97 | }}
98 | >
99 | { Array.from(toolbarPages, ([value, page]) => {
100 | const {icon, tooltip} = page;
101 | return
106 | }) }
107 |
108 |
}
109 | { selectedPage === "options" && options }
110 | { selectedPage === "log" &&
{ setSelectedPage("options") } }>
111 |
112 | }
113 |
114 | )
115 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/html/CssSelectorCard.tsx:
--------------------------------------------------------------------------------
1 | import Typography from "@mui/material/Typography";
2 | import {IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, Tooltip} from "@mui/material";
3 | import MoveUpIcon from "@mui/icons-material/MoveUp";
4 | import React, {useEffect, useState} from "react";
5 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
6 | import {toolbarBackgroundColor} from "@/config/colors";
7 |
8 | export interface CssSelectorCardProps {
9 | selector: string
10 | index: number,
11 | htmlString: string,
12 | allSelectors: string[],
13 | setAllSelectors: (newSelectors: string[]) => void
14 | isAttributeSelectionVisible: boolean
15 | }
16 |
17 | export default function CssSelectorCard(props: CssSelectorCardProps) {
18 |
19 | const [attributes, setAttributes] = useState([])
20 | const [selectedAttribute, setSelectedAttribute] = React.useState('');
21 |
22 | const handleAttributeSelected = (event: SelectChangeEvent) => {
23 | setSelectedAttribute(event.target.value as string);
24 | };
25 |
26 | useEffect(() => {
27 |
28 | // Create an invisible iFrame
29 | const iframe = document.createElement('iframe')
30 |
31 | iframe.style.display = 'none'
32 | document.body.appendChild(iframe)
33 |
34 | // Put the given html in the iFrame
35 | // @ts-ignore
36 | iframe.contentDocument.open()
37 | // @ts-ignore
38 | iframe.contentDocument.write(props.htmlString)
39 | // @ts-ignore
40 | iframe.contentDocument.close()
41 |
42 | // Search for element in iFrame
43 | // @ts-ignore
44 | let element = iframe.contentDocument.querySelector(props.selector)
45 |
46 | let attributeList = []
47 |
48 | if (element) {
49 | // @ts-ignore
50 | for (let attr of element.attributes) {
51 | attributeList.push(attr.name)
52 | }
53 | }
54 |
55 | setAttributes(attributeList)
56 |
57 | document.body.removeChild(iframe)
58 | }, [props.selector])
59 |
60 | return
67 |
74 |
75 | { props.selector }
76 |
77 |
81 |
82 | {
83 | props.allSelectors[props.index] = props.selector.substring(0, props.selector.lastIndexOf(">"))
84 | if (props.allSelectors[props.index] === "") {
85 | props.allSelectors.splice(props.index, 1)
86 | }
87 | props.setAllSelectors([...props.allSelectors])
88 | }}>
89 |
90 |
91 |
92 |
93 | {
94 | props.allSelectors.splice(props.index, 1)
95 | props.setAllSelectors([...props.allSelectors])
96 | }}>
97 |
98 |
99 |
100 |
101 |
102 | { props.isAttributeSelectionVisible &&
103 | Available attributes to extract
104 | }
105 | { props.isAttributeSelectionVisible &&
}
118 |
119 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/html/HtmlSelectorPage.tsx:
--------------------------------------------------------------------------------
1 | import {SelectableHtmlPreview} from "@/components/editor/pages/html/SelectableHtmlPreview";
2 | import {IconButton, TextField, ToggleButton, ToggleButtonGroup, Tooltip} from "@mui/material";
3 | import React, {useState} from "react";
4 | import {openSuccessSnackBar, openWarningSnackBar} from "@/stores/editor/EditorPageStore";
5 | import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
6 | import Typography from "@mui/material/Typography";
7 | import AddIcon from '@mui/icons-material/Add';
8 | import DoDisturbIcon from '@mui/icons-material/DoDisturb';
9 | import ContentCopyIcon from '@mui/icons-material/ContentCopy';
10 | import useHtmlSelectorStore from "@/stores/editor/HtmlSelectorStore";
11 | import CssSelectorCard from "@/components/editor/pages/html/CssSelectorCard";
12 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
13 |
14 | export enum SelectorSelectionModes {
15 | ADD_TO_SELECTION = "Add to selection",
16 | EXCLUDE_FROM_SELECTION = "Exclude from selection",
17 | }
18 |
19 | export default function HtmlSelectorPage() {
20 |
21 | const { url, html, amountOfSelectedElements, setUrl, setHtml, cssSelector, resetSelector, selectedSelector, excludedSelector, setSelectedSelector, setExcludedSelector } = useHtmlSelectorStore()
22 | const [selectionMode, setSelectionMode] = useState(SelectorSelectionModes.ADD_TO_SELECTION)
23 |
24 | const fetchWebsite = async (url: string) => {
25 | await fetch('/api/fetch', {
26 | method: 'POST',
27 | headers: {
28 | 'Content-Type': 'application/json'
29 | },
30 | body: JSON.stringify({ url: url })
31 | })
32 | .then(response => {
33 | if (!response.ok) {
34 | throw new Error(`Response code was: ${response.status}`);
35 | }
36 | return response.text();
37 | })
38 | .then(data => {
39 | setHtml(data)
40 | })
41 | .catch(error => {
42 | console.log(error)
43 | openWarningSnackBar("Website couldn't be loaded")
44 | });
45 | }
46 |
47 | return
56 |
63 |
68 |
71 |
72 |
80 |
{
86 | setUrl(event.target.value)
87 | }}
88 | InputProps={{
89 | endAdornment: (
90 | fetchWebsite(url)}>
91 |
92 |
93 | )
94 | }}
95 | />
96 |
100 |
{
104 | if (newSelectionMode !== null) {
105 | setSelectionMode(newSelectionMode as SelectorSelectionModes)
106 | }
107 | }}
108 | aria-label="selection mode"
109 | >
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | {
123 | resetSelector()
124 | }}>
125 |
126 |
127 |
128 |
129 | {
130 | navigator.clipboard.writeText(cssSelector).then(() => {
131 | openSuccessSnackBar("CSS-Selector copied to clipboard")
132 | })
133 | }}>
134 |
135 |
136 |
137 |
138 |
139 | Total elements selected on this page: { amountOfSelectedElements }
140 |
141 |
142 | Selected
143 |
144 | { selectedSelector.map((selector, index) => {
145 | return
153 | }) }
154 |
155 | Excluded
156 |
157 | { excludedSelector.map((selector, index) => {
158 | return
166 | }) }
167 |
168 |
169 |
170 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/html/SelectableHtmlPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef} from "react";
2 | import "../canvas/toolbars/Html.css"
3 | import {SelectorSelectionModes} from "@/components/editor/pages/html/HtmlSelectorPage";
4 | import useHtmlSelectorStore from "@/stores/editor/HtmlSelectorStore";
5 | import {getUniqueSelector, isValidSelector, removeIDFromSelector} from "@/components/editor/pages/html/util/HtmlUtils";
6 |
7 | const highlightCss = ".selectedForSelector { background-color: lightgreen; } .excluded { background-color: tomato; }";
8 |
9 | export interface SelectableHtmlPreviewProps {
10 | selectionMode: SelectorSelectionModes
11 | }
12 |
13 | export function SelectableHtmlPreview(props: SelectableHtmlPreviewProps) {
14 | const {
15 | html,
16 | setAmountOfSelectedElements,
17 | setCssSelector,
18 | selectedSelector,
19 | excludedSelector,
20 | setSelectedSelector,
21 | setExcludedSelector
22 | } = useHtmlSelectorStore()
23 |
24 | const selectedSelectorRef = useRef(selectedSelector);
25 | const excludedSelectorRef = useRef(excludedSelector)
26 |
27 | const selectionModeRef = useRef(props.selectionMode);
28 | const iframeRef = useRef(null)
29 |
30 | useEffect(() => {
31 | selectionModeRef.current = props.selectionMode;
32 | }, [props.selectionMode]);
33 |
34 | useEffect(() => {
35 | // Access the document within the iframe
36 | // @ts-ignore
37 | const iframeDoc = iframeRef.current.contentDocument || iframeRef.current.contentWindow.document;
38 |
39 | // Set the HTML content inside the iframe
40 | iframeDoc.body.innerHTML = html;
41 |
42 | // Add the CSS rules for highlighting
43 | const styleTag = iframeDoc.createElement("style");
44 | styleTag.innerHTML = highlightCss;
45 | iframeDoc.head.appendChild(styleTag);
46 |
47 | // Add the event listener
48 | iframeDoc.body.addEventListener('click', handleElementClick);
49 |
50 | // Remove the event listener when unmounting
51 | return () => {
52 | iframeDoc.body.removeEventListener('click', handleElementClick);
53 | };
54 | }, [html]);
55 |
56 | useEffect(() => {
57 |
58 | selectedSelectorRef.current = selectedSelector;
59 | excludedSelectorRef.current = excludedSelector;
60 |
61 | if (excludedSelector.length > 0) {
62 | setCssSelector(selectedSelector.join() + ":not(" + excludedSelector.join() + ")")
63 | } else {
64 | setCssSelector(selectedSelector.join())
65 | }
66 |
67 | // Get document from iFrame
68 | // @ts-ignore
69 | const iframeDoc = iframeRef.current.contentDocument || iframeRef.current.contentWindow.document;
70 |
71 | iframeDoc.querySelectorAll('.selectedForSelector').forEach((el: { classList: { remove: (arg0: string) => any; }; }) => el.classList.remove('selectedForSelector'));
72 | iframeDoc.querySelectorAll('.excluded').forEach((el: { classList: { remove: (arg0: string) => any; }; }) => el.classList.remove('excluded'));
73 |
74 | if (isValidSelector(iframeDoc, selectedSelector.join())) {
75 | iframeDoc.querySelectorAll(selectedSelector.join()).forEach((el: { classList: { add: (arg0: string) => any; }; }) => el.classList.add('selectedForSelector'));
76 | }
77 |
78 | if (isValidSelector(iframeDoc, excludedSelector.join())) {
79 | iframeDoc.querySelectorAll(excludedSelector.join()).forEach((el: { classList: { add: (arg0: string) => any; }; }) => el.classList.add('excluded'));
80 | }
81 |
82 | setAmountOfSelectedElements(iframeDoc.querySelectorAll('.selectedForSelector').length)
83 | }, [selectedSelector, excludedSelector, html]);
84 |
85 | const handleElementClick = (event: { preventDefault: () => void; target: any; }) => {
86 | event.preventDefault();
87 | const element = event.target;
88 |
89 | let selectorForClick = removeIDFromSelector(getUniqueSelector(element));
90 |
91 | switch (selectionModeRef.current) {
92 | case SelectorSelectionModes.ADD_TO_SELECTION:
93 | setExcludedSelector(excludedSelectorRef.current.filter(selector => selector !== selectorForClick))
94 | setSelectedSelector([...selectedSelectorRef.current.filter(selector => selector !== selectorForClick), selectorForClick])
95 | break
96 | case SelectorSelectionModes.EXCLUDE_FROM_SELECTION:
97 | setSelectedSelector(selectedSelectorRef.current.filter(selector => selector !== selectorForClick))
98 | setExcludedSelector([...excludedSelectorRef.current.filter(selector => selector !== selectorForClick), selectorForClick])
99 | break
100 | }
101 | };
102 |
103 | return ;
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/editor/pages/html/util/HtmlUtils.ts:
--------------------------------------------------------------------------------
1 | export function getUniqueSelector(el: any): string {
2 | let path = [];
3 | while (el.nodeType === Node.ELEMENT_NODE) {
4 | let selector = el.nodeName.toLowerCase();
5 |
6 | if (el.id) {
7 | selector += `#${el.id}`;
8 | } else {
9 | let sib = el, nth = 1;
10 | while (sib = sib.previousElementSibling) {
11 | if (sib.nodeName.toLowerCase() === selector) nth++;
12 | }
13 | if (nth !== 1) selector += `:nth-of-type(${nth})`;
14 | }
15 |
16 | path.unshift(selector);
17 | el = el.parentNode;
18 | }
19 | return path.join(' > ');
20 | }
21 |
22 | /*export function getUniqueSelector(el) {
23 | let path = [];
24 | while (el.nodeType === Node.ELEMENT_NODE) {
25 | let selector = el.nodeName.toLowerCase();
26 | console.log('Current Selector:', selector);
27 |
28 | // Wenn der Name einen Bindestrich enthält, handelt es sich um ein benutzerdefiniertes Element
29 | if (selector.includes('-')) {
30 | path.unshift(selector);
31 | } else if (el.id) {
32 | selector += `#${el.id}`;
33 | path.unshift(selector);
34 | break;
35 | } else {
36 | let sib = el, nth = 1;
37 | while (sib = sib.previousElementSibling) {
38 | if (sib.nodeName.toLowerCase() === selector) nth++;
39 | }
40 | if (nth !== 1) selector += `:nth-of-type(${nth})`;
41 | path.unshift(selector);
42 | }
43 | el = el.parentNode;
44 | }
45 | return path.join(' > ');
46 | }*/
47 |
48 | export function removeIDFromSelector(selector: string): string {
49 | return selector.replace(/#[^\s]+/g, '');
50 | }
51 |
52 | export function isValidSelector(iframeDoc: any, selector: string): boolean {
53 | try {
54 | iframeDoc.querySelector(selector);
55 | return true;
56 | } catch (e) {
57 | return false
58 | }
59 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/output/Files.tsx:
--------------------------------------------------------------------------------
1 | import {usePlayStore} from "@/stores/editor/PlayStore";
2 | import React, {useEffect, useRef} from "react";
3 | import {IconButton, Tooltip, Typography} from "@mui/material";
4 | import {onSave} from "@/util/IOUtil";
5 | import SaveIcon from "@mui/icons-material/Save";
6 | import {smoothScrollToBottom} from "@/components/editor/pages/output/util/SmoothScrollUtil";
7 | import {toolbarBackgroundColor} from "@/config/colors";
8 |
9 | export default function Files() {
10 |
11 | const files = usePlayStore(state => state.files)
12 |
13 | const filesRef = useRef(null);
14 |
15 | useEffect(() => {
16 | if (filesRef.current) {
17 | smoothScrollToBottom(filesRef.current, filesRef.current?.scrollHeight, 300);
18 | }
19 | }, [files])
20 |
21 | return
27 |
28 | Created Files
29 |
30 |
41 | { files.map(({name, extension, content}, index) =>
42 |
49 |
50 | {
51 | onSave(`${name}.${extension || "txt"}`, content, `downloadFile-${name}`)
52 | }}>
53 |
54 |
55 |
56 |
57 |
58 | { name }.{ extension || "txt" }
59 |
60 |
61 | ) }
62 |
63 |
64 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/output/Log.tsx:
--------------------------------------------------------------------------------
1 | import {usePlayStore} from "@/stores/editor/PlayStore";
2 | import {Button, Typography} from "@mui/material";
3 | import React, {useEffect, useRef} from "react";
4 | import SaveIcon from "@mui/icons-material/Save";
5 | import {onSave} from "@/util/IOUtil";
6 | import {smoothScrollToBottom} from "@/components/editor/pages/output/util/SmoothScrollUtil";
7 | import {defaultEdgeColor, toolbarBackgroundColor} from "@/config/colors";
8 |
9 | export interface LogProps {
10 | hasPadding?: boolean
11 | hasTitle?: boolean
12 | }
13 |
14 | export default function Log(props: LogProps) {
15 |
16 | const log = usePlayStore(state => state.log)
17 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
18 |
19 | const logRef = useRef(null);
20 |
21 | useEffect(() => {
22 | if (logRef.current) {
23 | smoothScrollToBottom(logRef.current, logRef.current?.scrollHeight, 300);
24 | }
25 | }, [log])
26 |
27 | return
38 | { (props.hasTitle === undefined || props.hasTitle) &&
39 | Log
40 | }
41 |
50 | { log.map((message: string, index) =>
51 |
52 |
53 | { message.substring(0, 19) }:
54 |
55 | { message.substring(20) }
56 |
57 | ) }
58 |
59 |
}
61 | disabled={isProcessRunning || log.length === 0}
62 | variant="contained"
63 | onClick={() => {
64 | onSave(`log.txt`, JSON.stringify(log, null, 2), `downloadFile-log`)
65 | }}
66 | >
67 |
68 | Download Log
69 |
70 |
71 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/output/OutputPage.tsx:
--------------------------------------------------------------------------------
1 | import Log from "@/components/editor/pages/output/Log";
2 | import Files from "@/components/editor/pages/output/Files";
3 | import React from "react";
4 |
5 | export default function OutputPage() {
6 |
7 | return
15 |
16 |
17 |
18 | }
--------------------------------------------------------------------------------
/src/components/editor/pages/output/util/SmoothScrollUtil.ts:
--------------------------------------------------------------------------------
1 | export function smoothScrollToBottom(element: HTMLDivElement | null, target: number | undefined, duration: number) {
2 | if (element !== null && target !== undefined) {
3 | const startTime = Date.now()
4 | const start = element.scrollTop
5 | const distance = target - start
6 |
7 | const animationStep = () => {
8 | const progress = Date.now() - startTime
9 | const percent = Math.min(progress / duration, 1)
10 | const easeInOutQuad = percent < 0.5 ? 2 * percent * percent : 1 - Math.pow(-2 * percent + 2, 2) / 2
11 | element.scrollTop = start + distance * easeInOutQuad
12 |
13 | if (progress < duration) {
14 | requestAnimationFrame(animationStep)
15 | }
16 | };
17 |
18 | requestAnimationFrame(animationStep)
19 | }
20 | }
--------------------------------------------------------------------------------
/src/components/form/CacheTextField.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react"
2 | import {IconButton, OutlinedInputProps, TextField, Tooltip} from "@mui/material";
3 | import ContentCopyIcon from '@mui/icons-material/ContentCopy';
4 | import {openSuccessSnackBar} from "@/stores/editor/EditorPageStore";
5 | import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
6 | import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
7 |
8 | export interface CacheTextFieldProps {
9 | label: string,
10 | value: string[],
11 | onChange: (event: React.ChangeEvent) => void
12 | inputProps?: Partial
13 | }
14 |
15 | export default function CacheTextField(props: CacheTextFieldProps) {
16 |
17 | const [isExpanded, setIsExpanded] = useState(false)
18 |
19 | return (
20 |
27 |
39 |
43 |
44 | { setIsExpanded(!isExpanded) }}>
45 | { isExpanded ? : }
46 |
47 |
48 |
49 | {
50 | navigator.clipboard.writeText(props.value.join(", ")).then(() => {
51 | openSuccessSnackBar("Cache copied to clipboard")
52 | })
53 | }}>
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
--------------------------------------------------------------------------------
/src/components/form/DraggableOptionsListContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {DragDropContext, Draggable, Droppable, DropResult} from "react-beautiful-dnd";
3 | import DragHandleIcon from '@mui/icons-material/DragHandle';
4 |
5 | export interface DraggableListProps {
6 | items: T[]
7 | mapItem: (item: T, index: number) => React.ReactNode
8 | onOrderChanged: (newItems: T[]) => void
9 | }
10 |
11 | export const DraggableOptionsListContainer = (
12 | { items, mapItem, onOrderChanged }: DraggableListProps
13 | ) => {
14 |
15 | const onDragEnd = ({ destination, source }: DropResult) => {
16 | if (!destination) return;
17 |
18 | const newItems = reorder(items, source.index, destination.index); // Hier müssen Sie die reorder-Funktion definieren
19 |
20 | onOrderChanged(newItems);
21 | };
22 |
23 | return (
24 |
25 |
26 | {provided => (
27 |
33 | {items.map((item, index) => (
34 |
35 | {(provided, snapshot) => (
36 |
41 |
48 | {mapItem(item, index)}
49 |
50 |
51 |
52 | )}
53 |
54 | ))}
55 | {provided.placeholder}
56 |
57 | )}
58 |
59 |
60 | );
61 | };
62 |
63 | function reorder(
64 | list: T[],
65 | startIndex: number,
66 | endIndex: number
67 | ): T[] {
68 | const result = Array.from(list);
69 | const [removed] = result.splice(startIndex, 1);
70 | result.splice(endIndex, 0, removed);
71 |
72 | return result;
73 | }
74 |
75 | export default DraggableOptionsListContainer;
--------------------------------------------------------------------------------
/src/components/form/FileNameInputOption.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {OutlinedInputProps, TextField} from "@mui/material";
3 |
4 | export interface FileNameInputOptionProps {
5 | fileName: string | undefined | null,
6 | onFileNameChange: (event: React.ChangeEvent) => void
7 | extension: string | undefined | null,
8 | onExtensionChange: (event: React.ChangeEvent) => void
9 | inputProps?: Partial
10 | }
11 |
12 | export default function FileNameInputOption(props: FileNameInputOptionProps) {
13 |
14 | return (
15 |
23 |
34 |
45 |
46 | )
47 | }
--------------------------------------------------------------------------------
/src/components/form/MultiSelectOption.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Checkbox, ListItemText, MenuItem, Select, SelectChangeEvent} from "@mui/material";
3 | import OutlinedInput from '@mui/material/OutlinedInput';
4 |
5 | export interface MultiSelectOptionProps {
6 | values: string[],
7 | selectedValues: string[],
8 | onSelectionChanged: (newSelection: string[]) => void,
9 | label?: string
10 | }
11 |
12 | export default function MultiSelectOption(props: MultiSelectOptionProps) {
13 |
14 | return
17 |
40 |
41 | }
--------------------------------------------------------------------------------
/src/components/form/OptionsContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {IconButton, Tooltip, Typography} from "@mui/material";
3 | import CloseIcon from '@mui/icons-material/Close';
4 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
5 | import {usePlayStore} from "@/stores/editor/PlayStore";
6 | import {CANVAS_HEIGHT} from "@/components/editor/headerbar/HeaderBar";
7 | import {defaultEdgeColor} from "@/config/colors";
8 |
9 | export interface OptionsContainerProps {
10 | title: string
11 | nodeId?: string
12 | width?: number
13 | children?: React.ReactNode
14 | onClose?: () => void
15 | }
16 |
17 | export default function OptionsContainer(props: OptionsContainerProps) {
18 |
19 | const isProcessRunning = usePlayStore(state => state.isProcessRunning)
20 |
21 | return (
22 |
35 |
42 |
48 |
49 | { props.title }
50 |
51 | { props.nodeId &&
52 |
53 | { props.nodeId.substring(0, 8) }...
54 |
55 | }
56 |
57 | { !isProcessRunning &&
58 | {
59 | if (props.onClose !== undefined && props.onClose !== null) {
60 | props.onClose()
61 | } else {
62 | useReactFlowStore.getState().setNodeSelected(null)
63 | }
64 | }}>
65 |
66 |
67 | }
68 |
69 | { props.children }
70 |
71 | )
72 | }
--------------------------------------------------------------------------------
/src/components/form/RowOptionsContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export interface RowOptionsContainerProps {
4 | children?: React.ReactNode
5 | }
6 |
7 | export default function RowOptionsContainer(props: RowOptionsContainerProps) {
8 |
9 | return
17 | { props.children }
18 |
19 | }
--------------------------------------------------------------------------------
/src/components/form/SelectOption.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {InputLabel, MenuItem, Select, SelectChangeEvent} from "@mui/material";
3 |
4 | export interface SelectOptionProps {
5 | values: string[],
6 | selectedValue: string,
7 | onSelectionChanged: (newSelection: string) => void,
8 | label?: string
9 | }
10 |
11 | export default function SelectOption(props: SelectOptionProps) {
12 |
13 | return
16 | { props.label && { props.label } }
17 |
31 |
32 | }
--------------------------------------------------------------------------------
/src/components/form/TextInputOption.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {OutlinedInputProps, TextField} from "@mui/material";
3 |
4 | export interface TextInputOptionProps {
5 | label: string,
6 | value: string | undefined | null,
7 | onChange: (event: React.ChangeEvent) => void
8 | inputProps?: Partial
9 | }
10 |
11 | export default function TextInputOption(props: TextInputOptionProps) {
12 |
13 | return (
14 |
22 | )
23 | }
--------------------------------------------------------------------------------
/src/config/ConnectionRules.ts:
--------------------------------------------------------------------------------
1 | import {NodeType} from "@/config/NodeType";
2 | import {OutputValueType} from "@/config/OutputValueType";
3 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
4 |
5 | export const connectionRules = new Map([
6 | [NodeType.START_NODE, {
7 | outputValueType: OutputValueType.NONE,
8 | inputRules: []
9 | }],
10 | [NodeType.FETCH_WEBSITE_NODE, {
11 | outputValueType: OutputValueType.HTML,
12 | inputRules: [
13 | {
14 | handleId: "input",
15 | allowedValueTypes: [
16 | OutputValueType.NONE,
17 | OutputValueType.TEXT,
18 | OutputValueType.HTML
19 | ],
20 | maxConnections: 999
21 | }
22 | ]
23 | }],
24 | [NodeType.SAVE_NODE, {
25 | outputValueType: OutputValueType.NONE,
26 | inputRules: [
27 | {
28 | handleId: "input",
29 | allowedValueTypes: [
30 | OutputValueType.HTML,
31 | OutputValueType.TEXT,
32 | OutputValueType.JSON,
33 | OutputValueType.DATABASE
34 | ],
35 | maxConnections: 999
36 | }
37 | ]
38 | }],
39 | [NodeType.EXTRACTOR_NODE, {
40 | outputValueType: OutputValueType.HTML,
41 | inputRules: [
42 | {
43 | handleId: "input",
44 | allowedValueTypes: [
45 | OutputValueType.HTML
46 | ],
47 | maxConnections: 999
48 | }
49 | ]
50 | }],
51 | [NodeType.ZIP_NODE, {
52 | outputValueType: OutputValueType.JSON,
53 | inputRules: [
54 | {
55 | handleId: "input",
56 | allowedValueTypes: [
57 | OutputValueType.HTML,
58 | OutputValueType.TEXT,
59 | OutputValueType.JSON
60 | ],
61 | maxConnections: 999
62 | }
63 | ]
64 | }],
65 | [NodeType.HTML_TO_TEXT_NODE, {
66 | outputValueType: OutputValueType.TEXT,
67 | inputRules: [
68 | {
69 | handleId: "input",
70 | allowedValueTypes: [
71 | OutputValueType.HTML
72 | ],
73 | maxConnections: 999
74 | }
75 | ]
76 | }],
77 | [NodeType.DATABASE_TABLE_NODE, {}]
78 | ])
79 |
80 | export type ConnectionRule = {
81 | outputValueType: OutputValueType | null
82 | inputRules: InputRule[]
83 | }
84 |
85 | export type DynamicConnectionRule = {
86 | [key: string]: ConnectionRule
87 | }
88 |
89 | export type InputRule = {
90 | handleId: string
91 | allowedValueTypes: OutputValueType[]
92 | maxConnections: number
93 | }
94 |
95 | export function addOrUpdateDynamicRule(nodeType: NodeType, nodeId: string, rule: ConnectionRule) {
96 | if (isDynamicNodeType(nodeType) && connectionRules.get(nodeType)) {
97 | (connectionRules.get(nodeType) as DynamicConnectionRule)[nodeId] = rule;
98 | }
99 | useReactFlowStore.getState().removeIllegalEdgesAfterDynamicNodeChange(nodeType, nodeId)
100 | }
101 |
102 | export function removeDynamicRule(nodeType: NodeType, key: string) {
103 | if (isDynamicNodeType(nodeType) && connectionRules.get(nodeType)) {
104 | delete (connectionRules.get(nodeType) as DynamicConnectionRule)[key];
105 | }
106 | }
107 |
108 | export function isDynamicNodeType(nodeType: NodeType): nodeType is NodeType {
109 | const rule = connectionRules.get(nodeType)
110 | return !(!!rule?.outputValueType);
111 | }
112 |
113 | export function getConnectionRule(nodeType: NodeType, nodeId: string): ConnectionRule | undefined {
114 | return isDynamicNodeType(nodeType) ?
115 | (connectionRules.get(nodeType) as DynamicConnectionRule)[nodeId] :
116 | connectionRules.get(nodeType) as ConnectionRule
117 | }
118 |
119 | export function getOutputValueType(nodeType: NodeType, nodeId: string): OutputValueType | null {
120 | const connectionRule = getConnectionRule(nodeType, nodeId)
121 | if (!connectionRule) {
122 | return null
123 | }
124 | return connectionRule.outputValueType
125 | }
126 |
127 | export function getAllowedValueTypes(nodeType: NodeType, nodeId: string, targetHandle: string | null): OutputValueType[] {
128 | const connectionRule = getConnectionRule(nodeType, nodeId)
129 | if (!connectionRule) {
130 | return []
131 | }
132 | return connectionRule.inputRules.find(rule => {
133 | return rule.handleId === targetHandle
134 | })?.allowedValueTypes || []
135 | }
136 |
137 | export function getMaxConnections(nodeType: NodeType, nodeId: string, targetHandle: string | null): number {
138 | const connectionRule = getConnectionRule(nodeType, nodeId)
139 | if (!connectionRule) {
140 | return 0
141 | }
142 | return connectionRule.inputRules.find(rule => {
143 | return rule.handleId === targetHandle
144 | })?.maxConnections || 0
145 | }
146 |
147 | export function getInputRules(nodeType: NodeType, nodeId: string): InputRule[] {
148 | const connectionRule = getConnectionRule(nodeType, nodeId)
149 | return connectionRule?.inputRules || []
150 | }
151 |
152 | export function isConnectionAllowed(pipelineValueType: OutputValueType | null, targetNodeType: NodeType, targetNodeId: string, targetHandle: string | null): boolean {
153 | const connectionRule = getConnectionRule(targetNodeType, targetNodeId)
154 |
155 | return !!pipelineValueType && !!connectionRule?.inputRules.find(rule => {
156 | return rule.handleId === targetHandle
157 | })?.allowedValueTypes?.includes(pipelineValueType)
158 | }
159 |
--------------------------------------------------------------------------------
/src/config/NodeType.ts:
--------------------------------------------------------------------------------
1 | export enum NodeType {
2 | START_NODE = "startNode",
3 | FETCH_WEBSITE_NODE = "fetchWebsiteNode",
4 | SAVE_NODE = "saveNode",
5 | GATEWAY_NODE = "gatewayNode",
6 | EXTRACTOR_NODE = "extractorNode",
7 | ZIP_NODE = "zipNode",
8 | HTML_TO_TEXT_NODE = "htmlToTextNode",
9 | DATABASE_TABLE_NODE = "databaseTableNode"
10 | }
--------------------------------------------------------------------------------
/src/config/NodesMetadata.tsx:
--------------------------------------------------------------------------------
1 | import React, {CSSProperties} from "react";
2 | import {fetchWebsiteNodeMetadata} from "@/components/editor/pages/canvas/nodes/FetchWebsiteNode";
3 | import {saveNodeMetadata,} from "@/components/editor/pages/canvas/nodes/SaveNode";
4 | import {extractorNodeMetadata} from "@/components/editor/pages/canvas/nodes/ExtractorNode";
5 | import {NodeType} from "@/config/NodeType";
6 | import {NodeProps} from "reactflow";
7 | import {NodeData} from "@/model/NodeData";
8 | import {BasicNode} from "@/engine/nodes/BasicNode";
9 | import {startNodeMetadata} from "@/components/editor/pages/canvas/nodes/StartNode";
10 | import {zipNodeMetadata} from "@/components/editor/pages/canvas/nodes/ZipNode";
11 | import {htmlToTextNodeMetadata} from "@/components/editor/pages/canvas/nodes/HtmlToTextNode";
12 | import {DatabaseTableNodeMetadata} from "@/components/editor/pages/canvas/nodes/DatabaseTableNode";
13 |
14 | export const nodesMetadataMap: NodeMetadataType = {
15 | [NodeType.START_NODE]: startNodeMetadata,
16 | [NodeType.FETCH_WEBSITE_NODE]: fetchWebsiteNodeMetadata,
17 | [NodeType.SAVE_NODE]: saveNodeMetadata,
18 | [NodeType.GATEWAY_NODE]: null,
19 | [NodeType.EXTRACTOR_NODE]: extractorNodeMetadata,
20 | [NodeType.ZIP_NODE]: zipNodeMetadata,
21 | [NodeType.HTML_TO_TEXT_NODE]: htmlToTextNodeMetadata,
22 | [NodeType.DATABASE_TABLE_NODE]: DatabaseTableNodeMetadata
23 | };
24 |
25 | // --- Do not change anything below --- \\
26 |
27 | export function getAllNodesMetadata(): NodeMetadata[] {
28 | return Object
29 | .values(nodesMetadataMap)
30 | .filter((metadata): metadata is NodeMetadata => metadata !== null);
31 | }
32 |
33 | export type NodeMetadata = {
34 | title: string,
35 | type: NodeType,
36 | getNodeComponent: ({id, selected, data}: NodeProps) => React.ReactNode,
37 | getOptionsComponent: (id: string) => React.ReactNode,
38 | style: CSSProperties,
39 | icon: React.ReactNode,
40 | getEngineNode: (id: string, data: NodeData) => BasicNode
41 | }
42 |
43 | type NodeMetadataType = {
44 | [K in NodeType]: NodeMetadata | null
45 | }
--------------------------------------------------------------------------------
/src/config/OutputValueType.ts:
--------------------------------------------------------------------------------
1 | export enum OutputValueType {
2 | NONE = "none",
3 | HTML = "html",
4 | JSON = "json",
5 | TEXT = "text",
6 | DATABASE = "database"
7 | }
8 |
9 | export interface Output {
10 | value: any
11 | metadata?: any
12 | }
13 | export interface NoneOutput extends Output {}
14 |
15 | export interface HtmlOutput extends Output {
16 | metadata: {
17 | source_url: string
18 | }
19 | value: string
20 | }
21 |
22 | export interface JsonOutput extends Output {
23 | value: any
24 | }
25 |
26 | export interface TextOutput extends Output {
27 | value: string
28 | }
29 |
30 | export interface DatabaseOutput extends Output {
31 | value: {
32 | schema: string
33 | content: string
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/config/colors.ts:
--------------------------------------------------------------------------------
1 | export const disabledColor = "#9BA8BD33"
2 |
3 | export const selectedColorHover = "#AE6325"
4 | export const selectedColor = "#F98E35"
5 |
6 | export const nodeBackgroundColor = "#1A202C"
7 | export const nodeShadowColor = "#1b2631"
8 | export const toolbarBackgroundColor = "#1A202C"
9 |
10 | export const defaultEdgeColor = "#9BA8BD"
--------------------------------------------------------------------------------
/src/engine/nodes/BasicNode.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {NodeData} from "@/model/NodeData";
3 | import {NodeType} from "@/config/NodeType";
4 |
5 | export interface BasicNode {
6 | id: string
7 | nodeType: NodeType,
8 | data: NodeData
9 | run: () => Promise | Promise
10 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineDatabaseTableNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "@/engine/nodes/BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {DatabaseTableNodeData} from "@/components/editor/pages/canvas/nodes/DatabaseTableNode";
5 | import {DatabaseOutput, Output} from "@/config/OutputValueType";
6 |
7 | export class EngineDatabaseTableNode implements BasicNode {
8 | id: string;
9 | nodeType: NodeType
10 | data: DatabaseTableNodeData
11 |
12 | constructor(id: string, data: DatabaseTableNodeData) {
13 | this.id = id
14 | this.nodeType = NodeType.DATABASE_TABLE_NODE
15 | this.data = data
16 | }
17 |
18 | async run() {
19 |
20 | const inputs = usePlayStore.getState().getInputForAllHandles(this.id)
21 |
22 | if (inputs) {
23 |
24 | const createTableStatement = generateSQLCreateTable(inputs, this.data.tableName)
25 | const insertStatement = generateSQLInsert(inputs, this.data.tableName)
26 |
27 | usePlayStore.getState().addOutgoingPipelines(this.id, {
28 | value: {
29 | schema: createTableStatement,
30 | content: insertStatement
31 | }
32 | } as DatabaseOutput)
33 |
34 | setTimeout(() => {
35 | usePlayStore.getState().nextNode()
36 | }, 200);
37 | }
38 | }
39 | }
40 |
41 | const generateSQLCreateTable = (data: Record, tableName: string): string => {
42 | return `CREATE TABLE ${tableName} (${Object.keys(data).map(key => `${key} TEXT`).join(", ")});`
43 | }
44 |
45 | const generateSQLInsert = (data: Record, tableName: string): string => {
46 | const keys = Object.keys(data);
47 | const length = data[keys[0]].length;
48 | const valuesWithBrackets: string[] = [];
49 |
50 | for (let i = 0; i < length; i++) {
51 | const values = keys.map(key => {
52 | return `'${(data[key][i] as Output).value.replace("'", "")}'`;
53 | }).join(", ");
54 |
55 | const valueWithBrackets = `(${values})`;
56 | valuesWithBrackets.push(valueWithBrackets);
57 | }
58 |
59 | return `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES ${valuesWithBrackets.join(", ")};`
60 | };
--------------------------------------------------------------------------------
/src/engine/nodes/EngineExtractorNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "@/engine/nodes/BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {ExtractionMode, ExtractorNodeData} from "@/components/editor/pages/canvas/nodes/ExtractorNode";
4 | import {usePlayStore} from "@/stores/editor/PlayStore";
5 | import * as cheerio from 'cheerio';
6 | import {HtmlOutput} from "@/config/OutputValueType";
7 |
8 | export class EngineExtractorNode implements BasicNode {
9 | id: string;
10 | nodeType: NodeType
11 | data: ExtractorNodeData
12 |
13 | constructor(id: string, data: ExtractorNodeData) {
14 | this.id = id
15 | this.nodeType = NodeType.EXTRACTOR_NODE
16 | this.data = data
17 | }
18 |
19 | async run() {
20 | const inputs = usePlayStore.getState().getInput(this.id, "input") as HtmlOutput[] | undefined
21 |
22 | if (inputs) {
23 |
24 | usePlayStore.getState().writeToLog(`Extracting "${this.data.tag}" from provided html`)
25 |
26 | const elements = inputs.map(input => {
27 | const tag = this.data.tag;
28 |
29 | // Parse the HTML with Cheerio
30 | const $ = cheerio.load(input.value);
31 |
32 | const mapToHtmlOutput = (value: string, source_url: string): HtmlOutput => {
33 | return {
34 | metadata: {
35 | source_url: source_url
36 | },
37 | value: value
38 | } as HtmlOutput
39 | }
40 |
41 | switch (this.data.extractionMode) {
42 | case ExtractionMode.ATTRIBUTE:
43 |
44 | const extractAttribute = (el: cheerio.Element, attribute: string, baseOrigin: string): string | undefined => {
45 | let value = $(el).attr(attribute)
46 | if (attribute === "href" && value) {
47 | return new URL(value, baseOrigin).href
48 | }
49 | return value
50 | }
51 |
52 | const attributeToExtract = this.data.attributeToExtract.toLowerCase()
53 | const baseOrigin = new URL(input.metadata.source_url).origin
54 |
55 | return $(tag).map((i, el) => {
56 | return extractAttribute(el, attributeToExtract, baseOrigin)
57 | }).get().filter(Boolean).map(value => {
58 | return mapToHtmlOutput(value, input.metadata.source_url)
59 | })
60 | case ExtractionMode.CONTENT:
61 | default:
62 | return $(tag).map((i, el) => $(el).html()).get().map(el => {
63 | return mapToHtmlOutput(el, input.metadata.source_url)
64 | })
65 | }
66 | }).flat() as HtmlOutput[]
67 |
68 | usePlayStore.getState().writeToLog(`Extracted ${elements.length} elements`)
69 |
70 | // Here, 'elements' is an array that contains the inner HTML of each "tag" element
71 |
72 | usePlayStore.getState().addOutgoingPipelines(this.id, elements);
73 |
74 | // End with calling the next node
75 | setTimeout(() => {
76 | usePlayStore.getState().nextNode()
77 | }, 100);
78 | }
79 | }
80 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineFetchWebsiteNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "./BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {FetchWebsiteNodeData} from "@/components/editor/pages/canvas/nodes/FetchWebsiteNode";
5 | import {HtmlOutput, NoneOutput, TextOutput} from "@/config/OutputValueType";
6 |
7 | export class EngineFetchWebsiteNode implements BasicNode {
8 | id: string;
9 | nodeType: NodeType
10 | data: FetchWebsiteNodeData
11 |
12 | constructor(id: string, data: FetchWebsiteNodeData) {
13 | this.id = id
14 | this.nodeType = NodeType.FETCH_WEBSITE_NODE
15 | this.data = data
16 | }
17 |
18 | async run() {
19 |
20 | const inputs = usePlayStore.getState().getInput(this.id, "input") as TextOutput[] | HtmlOutput[] | NoneOutput[] | undefined
21 |
22 | const combinedUrls: string[] = [...this.data.urls ?? [], ...inputs?.map(input => input.value.toString()) ?? []]
23 |
24 | usePlayStore.getState().writeToLog(`Fetching ${combinedUrls.length} websites with name "${this.data.name}"`)
25 |
26 | let fetchResults: HtmlOutput[] = []
27 |
28 | for (const url of combinedUrls) {
29 |
30 | const index = combinedUrls.indexOf(url);
31 |
32 | usePlayStore.getState().writeToLog(`Fetching website "${url}" (${index + 1} of ${combinedUrls.length})`)
33 |
34 | await fetch('/api/fetch', {
35 | method: 'POST',
36 | headers: {
37 | 'Content-Type': 'application/json'
38 | },
39 | body: JSON.stringify({ url: url })
40 | })
41 | .then(response => {
42 | if (!response.ok) {
43 | throw new Error(`Response code was: ${response.status}`);
44 | }
45 | return response.text();
46 | })
47 | .then(data => {
48 | fetchResults.push({
49 | metadata: {
50 | source_url: url,
51 | },
52 | value: data
53 | });
54 | usePlayStore.getState().writeToLog(`Website content (First 500 characters): ${data.substring(0, 499)}`);
55 | })
56 | .catch(error => {
57 | usePlayStore.getState().writeToLog(`Error while fetching website. Error was: ${error}`);
58 | });
59 | }
60 |
61 | usePlayStore.getState().addOutgoingPipelines(this.id, fetchResults)
62 | usePlayStore.getState().nextNode();
63 | }
64 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineHtmlToTextNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "@/engine/nodes/BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {HtmlToTextNodeData} from "@/components/editor/pages/canvas/nodes/HtmlToTextNode";
5 | import {HtmlOutput, TextOutput} from "@/config/OutputValueType";
6 |
7 | export class EngineHtmlToTextNode implements BasicNode {
8 | id: string;
9 | nodeType: NodeType
10 | data: HtmlToTextNodeData
11 |
12 | constructor(id: string, data: HtmlToTextNodeData) {
13 | this.id = id
14 | this.nodeType = NodeType.HTML_TO_TEXT_NODE
15 | this.data = data
16 | }
17 |
18 | async run() {
19 | const inputs = usePlayStore.getState().getInput(this.id, "input") as HtmlOutput[] | undefined
20 |
21 | if (inputs) {
22 |
23 | usePlayStore.getState().writeToLog(`Turning ${inputs.length} HTML elements to text`)
24 |
25 | const cheerio = require('cheerio');
26 | const textElements: TextOutput[] = inputs.map(input => {
27 | const $ = cheerio.load(input.value);
28 | return {
29 | value: $('body').text()
30 | } as TextOutput
31 | })
32 |
33 | usePlayStore.getState().addOutgoingPipelines(this.id, textElements);
34 |
35 | // End with calling the next node
36 | setTimeout(() => { usePlayStore.getState().nextNode() }, 100);
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineSaveNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "./BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {SaveNodeData} from "@/components/editor/pages/canvas/nodes/SaveNode";
5 | import {HtmlOutput, JsonOutput, TextOutput} from "@/config/OutputValueType";
6 |
7 | export class EngineSaveNode implements BasicNode {
8 | id: string;
9 | nodeType: NodeType
10 | data: SaveNodeData
11 |
12 | constructor(id: string, data: SaveNodeData) {
13 | this.id = id
14 | this.nodeType = NodeType.SAVE_NODE
15 | this.data = data
16 | }
17 |
18 | async run() {
19 |
20 | const inputs = usePlayStore.getState().getInput(this.id, "input") as (HtmlOutput | TextOutput | JsonOutput)[] | undefined
21 |
22 | if (inputs) {
23 | usePlayStore.getState().writeToLog(`Saving file as "${this.data.fileName}.${this.data.extension}"`)
24 | console.log("Inputs", inputs)
25 | usePlayStore.getState().addFile(this.data.fileName, this.data.extension, inputs.map(input => {
26 | if (typeof input.value === "object") {
27 | return JSON.stringify(input.value, null, 2)
28 | }
29 | return input.value
30 | }).join(this.data.separator))
31 | setTimeout(() => { usePlayStore.getState().nextNode() }, 100);
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineStartNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "./BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {StartNodeData} from "@/components/editor/pages/canvas/nodes/StartNode";
5 |
6 | export class EngineStartNode implements BasicNode {
7 | id: string;
8 | nodeType: NodeType
9 | data: StartNodeData
10 |
11 | constructor(id: string, data: StartNodeData) {
12 | this.id = id
13 | this.nodeType = NodeType.START_NODE
14 | this.data = data
15 | }
16 |
17 | async run() {
18 | usePlayStore.getState().writeToLog("Starting crawler")
19 | setTimeout(() => { usePlayStore.getState().nextNode() }, 200);
20 | }
21 | }
--------------------------------------------------------------------------------
/src/engine/nodes/EngineZipNode.ts:
--------------------------------------------------------------------------------
1 | import {BasicNode} from "@/engine/nodes/BasicNode";
2 | import {NodeType} from "@/config/NodeType";
3 | import {usePlayStore} from "@/stores/editor/PlayStore";
4 | import {ZipNodeData} from "@/components/editor/pages/canvas/nodes/ZipNode";
5 | import {HtmlOutput, JsonOutput, TextOutput} from "@/config/OutputValueType";
6 | import {HtmlOutlined} from "@mui/icons-material";
7 |
8 | export class EngineZipNode implements BasicNode {
9 | id: string;
10 | nodeType: NodeType
11 | data: ZipNodeData
12 |
13 | constructor(id: string, data: ZipNodeData) {
14 | this.id = id
15 | this.nodeType = NodeType.ZIP_NODE
16 | this.data = data
17 | }
18 |
19 | async run() {
20 | const inputs = usePlayStore.getState().getInput(this.id, "input", false) as (HtmlOutput | TextOutput | JsonOutput)[][] | undefined
21 |
22 | if (inputs) {
23 |
24 | usePlayStore.getState().writeToLog(`Zipping ${inputs.length} inputs together`)
25 |
26 | inputs.sort((a, b) => a.length - b.length)
27 |
28 | const zippedInputs = inputs[0].map((_, i) => inputs.map(row => row[i]))
29 | .map(zippedList => {
30 | const jsonObject = zippedList.reduce((acc, value, index) => {
31 | acc[index.toString()] = value.value;
32 | return acc;
33 | }, {} as Record);
34 |
35 | return {
36 | value: jsonObject
37 | };
38 | }) as JsonOutput[]
39 |
40 | usePlayStore.getState().addOutgoingPipelines(this.id, zippedInputs);
41 |
42 | // End with calling the next node
43 | setTimeout(() => { usePlayStore.getState().nextNode() }, 100);
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/src/model/CrawlerProjectDto.ts:
--------------------------------------------------------------------------------
1 | import {Node, Edge} from "reactflow";
2 |
3 | export interface CrawlerProjectDto {
4 | nodes: Node[],
5 | edges: Edge[]
6 | }
--------------------------------------------------------------------------------
/src/model/NextNodeKey.ts:
--------------------------------------------------------------------------------
1 | export enum NextNodeKey {
2 | "FALSE" = 0,
3 | "TRUE" = 1,
4 | "ALWAYS" = 2,
5 | }
--------------------------------------------------------------------------------
/src/model/NodeData.ts:
--------------------------------------------------------------------------------
1 | import {ConnectionRule} from "@/config/ConnectionRules";
2 |
3 | export interface NodeData {
4 | }
5 |
6 | export interface DynamicNodeData extends NodeData {
7 | connectionRule?: ConnectionRule
8 | }
--------------------------------------------------------------------------------
/src/model/NodeMap.ts:
--------------------------------------------------------------------------------
1 | import {NextNodeKey} from "@/model/NextNodeKey";
2 | import {BasicNode} from "@/engine/nodes/BasicNode";
3 |
4 | export type NodeMap = Map
5 | export type NodeMapKey = string // Id of flow element
6 | export type NodeMapValue = { node: BasicNode, next: NodeMapNext }
7 | export type NodeMapNext = Record | null
--------------------------------------------------------------------------------
/src/stores/editor/EditorPageStore.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import DragAndDropFlow from "@/components/editor/pages/canvas/DragAndDropFlow";
3 | import create from "zustand";
4 | import EditIcon from '@mui/icons-material/Edit';
5 | import OutputPage from "@/components/editor/pages/output/OutputPage";
6 | import OutputIcon from '@mui/icons-material/Output';
7 | import {AlertColor} from "@mui/material";
8 | import HtmlSelectorPage from "@/components/editor/pages/html/HtmlSelectorPage";
9 | import ManageSearchIcon from '@mui/icons-material/ManageSearch';
10 |
11 | export type EditorPageState = {
12 | pages: Map
13 | selectedPage: string
14 | onPageChanged: (newPage: string) => void
15 | getPage: (pageId: string) => {label: string, child: React.ReactNode, icon?: React.ReactNode }
16 | isSnackBarOpen: boolean,
17 | setIsSnackBarOpen: (isOpen: boolean) => void
18 | snackBarSeverity: AlertColor,
19 | setSnackBarSeverity: (severity: AlertColor) => void
20 | snackBarText: string,
21 | setSnackBarText: (text: string) => void
22 | }
23 |
24 | export const useEditorPageState = create((set, get) => ({
25 | pages: new Map([
26 | ["canvas", {
27 | label: "Canvas",
28 | child: ,
29 | icon:
30 | }],
31 | ["html", {
32 | label: "HTML",
33 | child: ,
34 | icon:
35 | }],
36 | ["output", {
37 | label: "Output",
38 | child: ,
39 | icon:
40 | }]
41 | ]),
42 | selectedPage: "canvas",
43 | onPageChanged: (newPage: string) => {
44 | set({
45 | selectedPage: newPage
46 | })
47 | },
48 | getPage: (pageId: string) => {
49 | const page = get().pages.get(pageId);
50 | if (!page) {
51 | // Return a default value if no page is found
52 | return {
53 | label: '',
54 | child: <>>,
55 | };
56 | }
57 | return page;
58 | },
59 | isSnackBarOpen: false,
60 | setIsSnackBarOpen: (isOpen: boolean) => {
61 | set({
62 | isSnackBarOpen: isOpen
63 | })
64 | },
65 | snackBarSeverity: "warning",
66 | setSnackBarSeverity: (severity: AlertColor) => {
67 | set({
68 | snackBarSeverity: severity
69 | })
70 | },
71 | snackBarText: "",
72 | setSnackBarText: (text: string) => {
73 | set({
74 | snackBarText: text
75 | })
76 | }
77 | }));
78 |
79 | export function openSuccessSnackBar(message: string) {
80 | useEditorPageState.getState().setSnackBarSeverity("success")
81 | useEditorPageState.getState().setSnackBarText(message)
82 | useEditorPageState.getState().setIsSnackBarOpen(true)
83 | }
84 |
85 | export function openWarningSnackBar(message: string) {
86 | useEditorPageState.getState().setSnackBarSeverity("warning")
87 | useEditorPageState.getState().setSnackBarText(message)
88 | useEditorPageState.getState().setIsSnackBarOpen(true)
89 | }
90 |
91 | export default useEditorPageState;
--------------------------------------------------------------------------------
/src/stores/editor/HtmlSelectorStore.ts:
--------------------------------------------------------------------------------
1 | import create from "zustand";
2 |
3 | export type HtmlSelectorState = {
4 | url: string
5 | setUrl: (newUrl: string) => void
6 | html: string
7 | amountOfSelectedElements: number
8 | setHtml: (newHtml: string) => void
9 | setAmountOfSelectedElements: (amount: number) => void
10 | selectedSelector: string[]
11 | setSelectedSelector: (selector: string[]) => void
12 | excludedSelector: string[]
13 | setExcludedSelector: (selector: string[]) => void
14 | cssSelector: string
15 | setCssSelector: (newSelector: string) => void
16 | resetSelector: () => void
17 | }
18 |
19 | export const useHtmlSelectorStore = create((set, get) => ({
20 | url: "",
21 | html: "",
22 | amountOfSelectedElements: 0,
23 | selectedSelector: [],
24 | excludedSelector: [],
25 | cssSelector: "",
26 | setUrl: (newUrl: string) => {
27 | set({
28 | url: newUrl
29 | })
30 | },
31 | setHtml: (newHtml: string) => {
32 | set({
33 | html: newHtml
34 | })
35 | },
36 | setAmountOfSelectedElements: (amount: number) => {
37 | set({
38 | amountOfSelectedElements: amount
39 | })
40 | },
41 | setSelectedSelector: (selector: string[]) => {
42 | set({
43 | selectedSelector: selector
44 | })
45 | },
46 | setExcludedSelector: (selector: string[]) => {
47 | set({
48 | excludedSelector: selector
49 | })
50 | },
51 | setCssSelector: (newSelector: string) => {
52 | set({
53 | cssSelector: newSelector
54 | })
55 | },
56 | resetSelector: () => {
57 | set({
58 | selectedSelector: [],
59 | excludedSelector: [],
60 | cssSelector: ""
61 | })
62 | }
63 | }));
64 |
65 | export default useHtmlSelectorStore;
--------------------------------------------------------------------------------
/src/stores/editor/PlayStore.ts:
--------------------------------------------------------------------------------
1 | import create from 'zustand';
2 | import {NodeMap, NodeMapValue} from "@/model/NodeMap";
3 | import {NextNodeKey} from "@/model/NextNodeKey";
4 | import {NodeType} from "@/config/NodeType";
5 | import {getNodeMap} from "@/util/NodeMapTransformer";
6 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
7 | import {Output, OutputValueType} from "@/config/OutputValueType";
8 | import {getConnectionRule} from "@/config/ConnectionRules";
9 |
10 | function getFormattedTimestamp() {
11 | const now = new Date();
12 | const year = now.getFullYear();
13 | const month = (now.getMonth() + 1).toString().padStart(2, '0'); // JavaScript months are 0-based.
14 | const day = now.getDate().toString().padStart(2, '0');
15 | const hours = now.getHours().toString().padStart(2, '0');
16 | const minutes = now.getMinutes().toString().padStart(2, '0');
17 | const seconds = now.getSeconds().toString().padStart(2, '0');
18 |
19 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
20 | }
21 |
22 | type Pipeline = {
23 | from: string;
24 | to: string | null;
25 | toHandleId: string | null;
26 | value: Output[] | null;
27 | isActivated: boolean;
28 | };
29 |
30 | export type PlayStoreState = {
31 | nodeMap: NodeMap
32 | currentNode: NodeMapValue | null
33 | variables: Record
34 | files: {name: string, extension: string, content: string}[]
35 | log: string[]
36 | pipelines: Pipeline[]
37 | isProcessRunning: boolean
38 | isStepByStep: boolean
39 | isNextStepReady: boolean,
40 | stepByStepNextNodeKeyCache: NextNodeKey,
41 | setup: () => void
42 | stop: () => void
43 | setIsStepByStep: (isStepByStep: boolean) => void
44 | getFirstNode: () => NodeMapValue | null
45 | setCurrentNode: (newNode: NodeMapValue | null) => void
46 | nextNode: (nextNodeKey?: NextNodeKey, executeNextStep?: boolean) => void
47 | executeNextStep: () => void
48 | backtrackToNextPossibleNode: () => void
49 | getNode: (nodeId: string) => NodeMapValue | null
50 | getVariable: (name: string) => any
51 | setVariable: (name: string, value: any) => void
52 | addFile: (name: string, extension: string, content: string) => void
53 | writeToLog: (message: string) => void
54 | addOutgoingPipelines: (from: string, value?: Output[] | Output | null) => void
55 | deactivateIngoingPipelines: (to: string) => void
56 | getInput: (nodeId: string, handleId: string, flattenInput?: boolean) => Output[][] | Output[] | undefined
57 | getInputForAllHandles: (nodeId: string, flattenInput?: boolean) => Record | undefined
58 | }
59 |
60 | export const usePlayStore = create((set, get) => ({
61 | nodeMap: new Map(),
62 | currentNode: null,
63 | variables: {},
64 | files: [],
65 | log: [],
66 | pipelines: [],
67 | isProcessRunning: false,
68 | isStepByStep: false,
69 | isNextStepReady: false,
70 | stepByStepNextNodeKeyCache: NextNodeKey.ALWAYS,
71 | setup: () => {
72 | // reset store
73 | set({
74 | nodeMap: new Map(),
75 | currentNode: null,
76 | variables: {},
77 | files: [],
78 | log: [],
79 | pipelines: [],
80 | isProcessRunning: false
81 | })
82 | get().writeToLog("Setting up crawler")
83 | // set new store
84 | set({
85 | nodeMap: getNodeMap(useReactFlowStore.getState().nodes, useReactFlowStore.getState().edges),
86 | isProcessRunning: true
87 | })
88 |
89 | const firstNode = get().getFirstNode()
90 | if (firstNode !== null) {
91 | get().setCurrentNode(firstNode)
92 | } else {
93 | get().writeToLog("Error: There is no start node")
94 | get().stop()
95 | }
96 | },
97 | stop: () => {
98 | if (get().isProcessRunning) {
99 | get().writeToLog('Crawler stopped. You can see the created outputs in the "Output"-tab')
100 | set({
101 | nodeMap: new Map(),
102 | currentNode: null,
103 | isProcessRunning: false,
104 | isStepByStep: false,
105 | isNextStepReady: false
106 | })
107 | useReactFlowStore.getState().setNodeSelected(null)
108 | }
109 | },
110 | setIsStepByStep: (isStepByStep: boolean) => {
111 | set({
112 | isStepByStep: isStepByStep
113 | })
114 | },
115 | setCurrentNode: (newNode: NodeMapValue | null) => {
116 | set({
117 | currentNode: newNode
118 | })
119 | if (newNode !== null) {
120 | useReactFlowStore.getState().setNodeSelected(newNode.node.id)
121 | }
122 | },
123 | getFirstNode: (): NodeMapValue | null => {
124 | const firstNode = Array.from(get().nodeMap.values()).find(({node}) =>
125 | node.nodeType === NodeType.START_NODE
126 | );
127 | return firstNode as NodeMapValue || null
128 | },
129 | nextNode: (nextNodeKey: NextNodeKey = NextNodeKey.ALWAYS, executeNextStep: boolean = false) => {
130 | if (!executeNextStep && get().isStepByStep) {
131 | set({
132 | stepByStepNextNodeKeyCache: nextNodeKey,
133 | isNextStepReady: true
134 | })
135 | get().writeToLog("Step by step is activated. Waiting for next step...")
136 | return
137 | }
138 |
139 | if (!get().currentNode || get().currentNode?.next === null) {
140 | get().backtrackToNextPossibleNode()
141 | return
142 | }
143 |
144 | // Deactivate ingoing pipelines from current node before going to the next one
145 | get().deactivateIngoingPipelines(get().currentNode?.node.id || "")
146 |
147 | // Adding empty outgoing pipeline if there is none already
148 | const currentNodeId = get().currentNode?.node.id
149 | if (currentNodeId && !get().pipelines.find(pipeline => pipeline.from === currentNodeId)) {
150 | get().addOutgoingPipelines(currentNodeId)
151 | }
152 |
153 | const nextNode = get().currentNode?.next
154 | if (nextNode !== null && nextNode && nextNode[nextNodeKey] && nextNode[nextNodeKey][0]) {
155 | const newNode = get().nodeMap.get(nextNode[nextNodeKey][0].nodeId)
156 | if (newNode) {
157 | get().setCurrentNode(newNode)
158 | } else {
159 | get().backtrackToNextPossibleNode()
160 | }
161 | } else {
162 | get().backtrackToNextPossibleNode()
163 | }
164 | },
165 | executeNextStep: () => {
166 | const cache = get().stepByStepNextNodeKeyCache
167 | set({
168 | stepByStepNextNodeKeyCache: NextNodeKey.ALWAYS,
169 | isNextStepReady: false
170 | })
171 | get().nextNode(cache, true)
172 | },
173 | backtrackToNextPossibleNode: () => {
174 | const nextNodeId = get().pipelines.find(pipeline =>
175 | pipeline.isActivated &&
176 | pipeline.to !== get().currentNode?.node.id
177 | )?.to
178 | if (nextNodeId) {
179 | get().writeToLog(`Going back to last node with all inputs available, which is a "${get().getNode(nextNodeId)?.node.nodeType}"`)
180 | get().setCurrentNode(get().getNode(nextNodeId))
181 | } else {
182 | get().writeToLog("There are no more nodes left")
183 | get().stop()
184 | }
185 | },
186 | getNode: (nodeId: string): NodeMapValue | null => {
187 | return get().nodeMap.get(nodeId) || null
188 | },
189 | getVariable: (name: string) => {
190 | return get().variables[name]
191 | },
192 | setVariable: (name: string, value: any) => {
193 | const oldVariables = get().variables
194 | const newVariables = {
195 | ...oldVariables,
196 | [name]: value
197 | }
198 | set({
199 | variables: newVariables
200 | })
201 | },
202 | addFile: (name: string, extension: string, content: string) => {
203 | set({
204 | files: [...get().files, {
205 | name: name,
206 | extension: extension,
207 | content: content
208 | }]
209 | })
210 | },
211 | writeToLog: (message: string) => {
212 | set({
213 | log: [...get().log, `${getFormattedTimestamp()}: ${message}`]
214 | })
215 | },
216 | addOutgoingPipelines: (from: string, value: Output[] | Output | null = null) => {
217 | const next = Object.values(get().getNode(from)?.next || {}).flat()
218 | if (next.length > 0) {
219 | set({
220 | pipelines: [...get().pipelines, next.map(to => {
221 | return {
222 | from: from,
223 | to: to.nodeId,
224 | toHandleId: to.targetHandleId,
225 | value: value === null ? value : (Array.isArray(value) ? value.flat() : [value]),
226 | isActivated: true
227 | }
228 | })].flat()
229 | })
230 | } else {
231 | set({
232 | pipelines: [...get().pipelines, {
233 | from: from,
234 | to: null,
235 | toHandleId: null,
236 | value: value === null ? value : (Array.isArray(value) ? value.flat() : [value]),
237 | isActivated: false
238 | }]
239 | })
240 | }
241 | },
242 | deactivateIngoingPipelines: (to: string) => {
243 | set({
244 | pipelines: get().pipelines.map(pipeline => {
245 | if (pipeline.to === to) {
246 | pipeline.isActivated = false
247 | }
248 | return pipeline
249 | })
250 | })
251 | },
252 | getInput: (nodeId: string, handleId: string, flattenInput: boolean = true): Output[][] | Output[] | undefined => {
253 | // Get input values from the pipelines of the targetHandle
254 | const inputs: Output[][] = get().pipelines.filter(pipeline => {
255 | return pipeline.to === nodeId && pipeline.toHandleId === handleId && pipeline.value !== null
256 | }).map(pipeline => pipeline.value!)
257 |
258 | // Get amount of connected pipelines to the targetHandle
259 | const ingoingConnections = Array.from(get().nodeMap.values()).filter((value: NodeMapValue) => {
260 | if (value.next !== null) {
261 | return Object.values(value.next).some(nextValue =>
262 | nextValue.map(n => n.nodeId).includes(nodeId) &&
263 | nextValue.map(n => n.targetHandleId).includes(handleId)
264 | );
265 | }
266 | return false;
267 | }).length;
268 |
269 | let amountOfPrevNoneOutputValues = 0;
270 | // Traverse the nodeMap to find the previous nodes for the given nodeId
271 | // and count how many of them have an output value of NONE
272 | get().nodeMap.forEach((value) => {
273 | if (value.next) {
274 | Object.values(value.next).forEach(arr => {
275 | arr.forEach(connection => {
276 | if (connection.nodeId === nodeId) {
277 | const prevNode = value.node;
278 | const rule = getConnectionRule(prevNode.nodeType, prevNode.id)
279 | if (rule && rule.outputValueType === OutputValueType.NONE) {
280 | amountOfPrevNoneOutputValues++;
281 | }
282 | }
283 | });
284 | });
285 | }
286 | });
287 |
288 | // If the input array contains exactly the required amount of inputs and if none of those inputs are undefined
289 | // Then everything is okay and the value will be returned
290 | if (inputs.length === ingoingConnections - amountOfPrevNoneOutputValues && inputs.every(value => value !== undefined)) {
291 | if (flattenInput) {
292 | return inputs.flat()
293 | } else {
294 | return inputs
295 | }
296 | } else {
297 |
298 | // If any input value is missing or is undefined the crawler will backtrack to the next node that
299 | // has all input values available
300 |
301 | get().writeToLog(`One ore more inputs of the current node "${get().getNode(nodeId)?.node.nodeType}" are undefined`)
302 | get().backtrackToNextPossibleNode()
303 | return undefined
304 | }
305 | },
306 | getInputForAllHandles: (nodeId: string, flattenInput: boolean = true): Record | undefined => {
307 | const handleIds = Array.from(new Set(useReactFlowStore.getState().edges
308 | .filter(edge => edge.target === nodeId)
309 | .map(edge => {
310 | return edge.targetHandle
311 | })
312 | .filter(handleId => handleId)
313 | )) as string[]
314 |
315 | const inputs: Record = {};
316 | let allInputsDefined = true;
317 |
318 | for (const handleId of handleIds) {
319 | const input = get().getInput(nodeId, handleId, flattenInput);
320 | if (input) {
321 | inputs[handleId] = input;
322 | } else {
323 | allInputsDefined = false;
324 | break;
325 | }
326 | }
327 |
328 | if (!allInputsDefined) {
329 | return undefined;
330 | }
331 |
332 | return inputs
333 | }
334 | }))
--------------------------------------------------------------------------------
/src/stores/editor/ReactFlowStore.ts:
--------------------------------------------------------------------------------
1 | import create from 'zustand';
2 | import {
3 | addEdge,
4 | applyEdgeChanges,
5 | applyNodeChanges,
6 | Connection,
7 | Edge,
8 | EdgeChange,
9 | Node,
10 | NodeChange,
11 | NodeProps,
12 | OnConnect,
13 | OnEdgesChange,
14 | OnNodesChange
15 | } from 'reactflow';
16 | import {
17 | connectionRules,
18 | getAllowedValueTypes, getConnectionRule,
19 | getMaxConnections,
20 | getOutputValueType,
21 | isConnectionAllowed
22 | } from "@/config/ConnectionRules";
23 | import {openWarningSnackBar} from "@/stores/editor/EditorPageStore";
24 | import {ReactNode} from "react";
25 | import {getAllNodesMetadata, NodeMetadata} from "@/config/NodesMetadata";
26 | import {NodeType} from "@/config/NodeType";
27 | import {
28 | BothSelectedEdge,
29 | DefaultEdge,
30 | EdgeSelectedEdge,
31 | highlightEdges,
32 | SelectedIncomingEdge,
33 | SelectedOutgoingEdge
34 | } from "@/components/editor/pages/canvas/edges/Edges";
35 | import {selectedColor} from "@/config/colors";
36 |
37 | export const handleStyle = (isNodeSelected: boolean) => {
38 | return {
39 | width: 12,
40 | height: 12,
41 | backgroundColor: isNodeSelected ? selectedColor : "#9BA8BD"
42 | }
43 | }
44 |
45 | export type ReactFlowState = {
46 | nodes: Node[];
47 | selectedNodes: Node[]
48 | edges: Edge[];
49 | currentConnectionStartNode: Node | null
50 | isConnectionHighlightingActivated: boolean
51 | setCurrentConnectionStartNode: (node: Node | null) => void
52 | setIsConnectionHighlightingActivated: (isActivated: boolean) => void
53 | onNodesChange: OnNodesChange;
54 | onEdgesChange: OnEdgesChange;
55 | onConnect: OnConnect;
56 | updateNodeData: (nodeId: string, data: NodeData) => void;
57 | getNodeById: (nodeId: string) => Node | null;
58 | setNodeSelected: (nodeId: string | null) => void
59 | setSelectedNodes: () => void
60 | replaceEdgeAfterHandleRename: (nodeId: string, oldHandleId: string, newHandleId: string) => void
61 | removeIllegalEdgesAfterDynamicNodeChange: (nodeType: NodeType, nodeId: string) => void
62 | updateEdgesGradient: () => void
63 | }
64 |
65 | export const useReactFlowStore = create((set, get) => ({
66 | nodes: [],
67 | selectedNodes: [],
68 | edges: [],
69 | nodeTypes: getAllNodesMetadata().reduce ReactNode>>((acc, info: NodeMetadata) => {
70 | acc[info.type] = info.getNodeComponent
71 | return acc
72 | }, {}),
73 | currentConnectionStartNode: null,
74 | isConnectionHighlightingActivated: false,
75 | setCurrentConnectionStartNode: (node: Node | null) => {
76 | set({
77 | currentConnectionStartNode: node
78 | })
79 | },
80 | setIsConnectionHighlightingActivated: (isActivated: boolean) => {
81 | set({
82 | isConnectionHighlightingActivated: isActivated
83 | })
84 | get().updateEdgesGradient()
85 | },
86 | edgeTypes: {
87 | defaultEdge: DefaultEdge,
88 | selectedIncomingEdge: SelectedIncomingEdge,
89 | selectedOutgoingEdge: SelectedOutgoingEdge,
90 | bothSelectedEdge: BothSelectedEdge,
91 | edgeSelectedEdge: EdgeSelectedEdge,
92 | ...highlightEdges,
93 | },
94 | onNodesChange: (changes: NodeChange[]) => {
95 | set({
96 | nodes: applyNodeChanges(changes, get().nodes),
97 | });
98 | },
99 | onEdgesChange: (changes: EdgeChange[]) => {
100 | set({
101 | edges: applyEdgeChanges(changes, get().edges),
102 | });
103 | },
104 | onConnect: (connection: Connection) => {
105 |
106 | // TODO Überlegen, ob ich bei Nodes mit mehreren Inputs verschiedene Typen von Inputs zulassen möchte (HTML und JSON kombiniert bspw.)
107 | // Vielleicht wäre es eine sinnvolle ergänzung den Benutzer daran zu hindern ein HTML Edge an einen Node zu heften, der bereits einen JSON Edge hat
108 | // Ich kann das dem Benutzer über ein Popup erklären und fragen, ob er die alten durch den neuen austauschen möchte
109 |
110 | const source = connection.source
111 | const target = connection.target
112 |
113 | if (!source || !target) {
114 | return
115 | }
116 |
117 | const sourceNode = get().getNodeById(source)
118 | const targetNode = get().getNodeById(target)
119 |
120 | if (!sourceNode || !targetNode) {
121 | return
122 | }
123 |
124 | // Check connectivity rules
125 |
126 | if (source === target) {
127 | openWarningSnackBar("You can't connect a node to itself")
128 | return
129 | }
130 |
131 | const sourceNodeType = sourceNode.type as NodeType
132 | const targetNodeType = targetNode.type as NodeType
133 |
134 | const pipelineValueType = getOutputValueType(sourceNodeType, source)
135 |
136 | if (!isConnectionAllowed(pipelineValueType, targetNodeType, target, connection.targetHandle)) {
137 | openWarningSnackBar(`You can't connect a "${
138 | getOutputValueType(sourceNodeType, source)
139 | }" output to a "${
140 | getAllowedValueTypes(targetNodeType, target, connection.targetHandle).join("/")
141 | }" input`)
142 | return
143 | }
144 |
145 | const existingConnectionsToTarget = get().edges.filter(edge => edge.target === target && edge.targetHandle === connection.targetHandle).length
146 | const maxConnectionsToTarget = getMaxConnections(targetNodeType, target, connection.targetHandle)
147 | const isMaxConnectionsReached = maxConnectionsToTarget && existingConnectionsToTarget >= maxConnectionsToTarget
148 |
149 | if (isMaxConnectionsReached) {
150 | openWarningSnackBar("The max amount of inputs for this node is reached")
151 | return
152 | }
153 |
154 | console.log("Added edge", sourceNode, targetNode, connection, connectionRules)
155 |
156 | set({
157 | edges: addEdge({
158 | ...connection
159 | }, get().edges)
160 | });
161 | get().updateEdgesGradient()
162 | },
163 | updateNodeData: (nodeId: string, data: NodeData) => {
164 | set({
165 | nodes: get().nodes.map((node) => {
166 | if (node.id === nodeId) {
167 | node.data = data;
168 | }
169 | return node;
170 | }),
171 | });
172 | },
173 | getNodeById: (nodeId: string): Node | null => {
174 | let resultNode = null
175 | get().nodes.forEach((node) => {
176 | if (node.id === nodeId) {
177 | resultNode = node
178 | }
179 | })
180 | return resultNode;
181 | },
182 | setNodeSelected: (nodeId: string | null) => {
183 | set({
184 | nodes: get().nodes.map(node => {
185 | node.selected = nodeId !== null && node.id === nodeId
186 | return node
187 | })
188 | })
189 | get().setSelectedNodes()
190 | },
191 | setSelectedNodes: () => {
192 | set({
193 | selectedNodes: get().nodes.filter(node =>
194 | node.selected
195 | )
196 | })
197 | get().updateEdgesGradient()
198 | },
199 | replaceEdgeAfterHandleRename: (nodeId: string, oldHandleId: string, newHandleId: string) => {
200 | set({
201 | edges: get().edges.map(edge => {
202 | if (edge.target === nodeId && edge.targetHandle === oldHandleId) {
203 | return {
204 | ...edge,
205 | targetHandle: newHandleId
206 | }
207 | }
208 | return edge
209 | })
210 | })
211 | },
212 | removeIllegalEdgesAfterDynamicNodeChange: (nodeType: NodeType, nodeId: string) => {
213 | const availableHandleIds = getConnectionRule(nodeType, nodeId)?.inputRules.map(rule => rule.handleId)
214 | if (!availableHandleIds) {
215 | return
216 | }
217 | set({
218 | edges: get().edges.filter(edge => {
219 | if (edge.target !== nodeId) {
220 | return true
221 | }
222 | if (!edge.targetHandle) {
223 | return true
224 | }
225 |
226 | const sourceNode = get().getNodeById(edge.source)
227 |
228 | if (!sourceNode) {
229 | return false
230 | }
231 |
232 | const allowedInputValueTypes = getAllowedValueTypes(nodeType, nodeId, edge.targetHandle)
233 | const outputValueType = getOutputValueType(sourceNode.type as NodeType, edge.source)
234 |
235 | if (!outputValueType) {
236 | return false
237 | }
238 |
239 | return availableHandleIds.includes(edge.targetHandle) && allowedInputValueTypes.includes(outputValueType)
240 | })
241 | })
242 | },
243 | updateEdgesGradient() {
244 | const selectedNodeIds = get().selectedNodes.map(node => node.id)
245 |
246 | if (!get().isConnectionHighlightingActivated) {
247 |
248 | set({
249 | edges: get().edges.map(edge => {
250 | if (selectedNodeIds.length === 0 && edge.selected) {
251 | return {
252 | ...edge,
253 | type: "edgeSelectedEdge"
254 | }
255 | }
256 | if (selectedNodeIds.includes(edge.source) && selectedNodeIds.includes(edge.target)) {
257 | return {
258 | ...edge,
259 | type: "bothSelectedEdge"
260 | }
261 | }
262 | if (selectedNodeIds.includes(edge.source)) {
263 | return {
264 | ...edge,
265 | type: "selectedOutgoingEdge"
266 | }
267 | }
268 | if (selectedNodeIds.includes(edge.target)) {
269 | return {
270 | ...edge,
271 | type: "selectedIncomingEdge"
272 | }
273 | }
274 | return {
275 | ...edge,
276 | type: "defaultEdge"
277 | }
278 | }).sort((a, b) => {
279 | if (a.type === "edgeSelectedEdge") {
280 | return 1
281 | }
282 | if (b.type === "edgeSelectedEdge") {
283 | return -1
284 | }
285 | if (a.type === "defaultEdge") {
286 | return -1
287 | }
288 | if (b.type === "defaultEdge") {
289 | return 1
290 | }
291 | return 0
292 | })
293 | })
294 |
295 | } else {
296 |
297 | set({
298 | edges: get().edges.map(edge => {
299 |
300 | const sourceNodeType = get().getNodeById(edge.source)?.type as NodeType
301 | return {
302 | ...edge,
303 | type: sourceNodeType && getOutputValueType(sourceNodeType, edge.source)?.toString() || "defaultEdge"
304 | }
305 | })
306 | })
307 |
308 | }
309 | }
310 | }));
311 |
312 | export default useReactFlowStore;
--------------------------------------------------------------------------------
/src/util/IOUtil.ts:
--------------------------------------------------------------------------------
1 | import {ReactFlowInstance} from "reactflow";
2 | import {CrawlerProjectDto} from "@/model/CrawlerProjectDto";
3 | import {v4 as uuidv4} from 'uuid';
4 | import {Node, Edge} from "reactflow";
5 | import useReactFlowStore from "@/stores/editor/ReactFlowStore";
6 |
7 | export function onSave(fileName: string, content: string, anchorElementName: string) {
8 | const downloadableContent = "data:text/json;charset=utf-8," + encodeURIComponent(content)
9 | const anchorElement = document.getElementById(anchorElementName);
10 | if (anchorElement) {
11 | anchorElement.setAttribute("href", downloadableContent);
12 | anchorElement.setAttribute("download", fileName);
13 | anchorElement.click();
14 | }
15 | }
16 |
17 | export function loadCrawlerProject(changeEvent: any, reactFlowInstance: ReactFlowInstance) {
18 | const fileReader = new FileReader();
19 | fileReader.readAsText(changeEvent.target.files[0], "UTF-8");
20 | fileReader.onload = progressEvent => {
21 | if (progressEvent.target) {
22 | const bpmnDto = JSON.parse(String(progressEvent.target.result)) as CrawlerProjectDto
23 |
24 | // This whole process changes the id's of the nodes and adapts the edges as well to that change.
25 | // This is necessary so that the loaded nodes will be re-rendered and the loaded data is loaded into the node components
26 | const newIdPairs = bpmnDto.nodes.reduce((accumulator: Record, node) => {
27 | accumulator[node.id] = uuidv4()
28 | return accumulator;
29 | }, {});
30 | const newNodes = bpmnDto.nodes.map((node: Node) => {
31 | return { ...node, id: newIdPairs[node.id], parentNode: node.parentNode !== undefined ? newIdPairs[node.parentNode] : undefined }
32 | })
33 | const newEdges = bpmnDto.edges.map((edge: Edge) => {
34 | return { ...edge, source: newIdPairs[edge.source], target: newIdPairs[edge.target]}
35 | })
36 |
37 | reactFlowInstance.setNodes(newNodes)
38 | reactFlowInstance.setEdges(newEdges)
39 | useReactFlowStore.getState().updateEdgesGradient()
40 | }
41 | };
42 | }
--------------------------------------------------------------------------------
/src/util/NodeMapTransformer.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Gibt eine Liste mit folgenden Elementen zurück:
3 | *
4 | * key: uuid des nodes
5 | * value: {
6 | * node: node
7 | * next: uuids der nächsten nodes
8 | * }
9 | */
10 | import {Edge, Node} from "reactflow";
11 | import {NodeMapKey, NodeMapValue} from "@/model/NodeMap";
12 | import {NodeType} from "@/config/NodeType";
13 | import {NextNodeKey} from "@/model/NextNodeKey";
14 | import {NodeData} from "@/model/NodeData";
15 | import {BasicNode} from "@/engine/nodes/BasicNode";
16 | import {nodesMetadataMap} from "@/config/NodesMetadata";
17 |
18 | export function getNodeMap(nodes: Node[], edges: Edge[]): Map {
19 |
20 | const nodeMap = new Map()
21 |
22 | nodes.forEach((node: Node) => {
23 | const { id, type, data } = node
24 | const basicNode = getNodeFromType(type as NodeType, id, data)
25 | if (basicNode === null) {
26 | return
27 | }
28 | nodeMap.set(id, {
29 | node: basicNode,
30 | next: edges.filter((edge: Edge) => edge.source === id).reduce>((accumulator, edge: Edge) => {
31 | // sourceHandle is "True" or "False" when dealing with gateway nodes
32 |
33 | const newAccumulatorValue = {nodeId: edge.target, targetHandleId: edge.targetHandle} as {nodeId: NodeMapKey, targetHandleId: string}
34 |
35 | if (type === NodeType.GATEWAY_NODE && edge.sourceHandle !== null) {
36 | if (edge.sourceHandle === "True") {
37 | accumulator[NextNodeKey.TRUE] = [...accumulator[NextNodeKey.TRUE], newAccumulatorValue]
38 | } else {
39 | accumulator[NextNodeKey.FALSE] = [...accumulator[NextNodeKey.FALSE], newAccumulatorValue]
40 | }
41 | } else {
42 | accumulator[NextNodeKey.ALWAYS] = [...accumulator[NextNodeKey.ALWAYS], newAccumulatorValue]
43 | }
44 | return accumulator
45 | }, {
46 | [NextNodeKey.TRUE]: [],
47 | [NextNodeKey.FALSE]: [],
48 | [NextNodeKey.ALWAYS]: []
49 | } as Record) || null
50 | })
51 | })
52 |
53 | return nodeMap
54 | }
55 |
56 | function getNodeFromType(type: NodeType, id: string, data: NodeData): BasicNode | null {
57 |
58 | return nodesMetadataMap[type]?.getEngineNode(id, data) || null
59 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
12 | 'gradient-conic':
13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
14 | },
15 | },
16 | },
17 | plugins: [],
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------