diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 13ac3d4..00314d9 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -54,6 +54,10 @@ body { } } +.monaco-editor .highlight-line { + @apply bg-yellow-200/30 border-l-4 border-yellow-400 transition-colors; +} + :root { /* Allotment Styling */ --focus-border: var(--color-brand); diff --git a/src/main/frontend/app/routes/builder/builder-structure.tsx b/src/main/frontend/app/routes/builder/builder-structure.tsx index 34bd78d..8eff1bd 100644 --- a/src/main/frontend/app/routes/builder/builder-structure.tsx +++ b/src/main/frontend/app/routes/builder/builder-structure.tsx @@ -54,6 +54,7 @@ export default function BuilderStructure() { setIsLoading: state.setIsLoading, })), ) + const project = useProjectStore.getState().project const [searchTerm, setSearchTerm] = useState('') const tree = useRef(null) const dataProviderReference = useRef(new BuilderFilesDataProvider([])) @@ -73,12 +74,14 @@ export default function BuilderStructure() { try { const loaded: ConfigWithAdapters[] = await Promise.all( configurationNames.map(async (configName) => { - const adapterNames = await getAdapterNamesFromConfiguration(configName) + if (!project) return + const adapterNames = await getAdapterNamesFromConfiguration(project.name, configName) // Fetch listener name for each adapter const adapters = await Promise.all( adapterNames.map(async (adapterName) => { - const listenerName = await getAdapterListenerType(configName, adapterName) + const listenerName = await getAdapterListenerType(project.name, configName, adapterName) + console.log(listenerName) return { adapterName, listenerName } }), ) diff --git a/src/main/frontend/app/routes/builder/canvas/flow.tsx b/src/main/frontend/app/routes/builder/canvas/flow.tsx index 6364b70..ca35439 100644 --- a/src/main/frontend/app/routes/builder/canvas/flow.tsx +++ b/src/main/frontend/app/routes/builder/canvas/flow.tsx @@ -27,10 +27,11 @@ import { convertAdapterXmlToJson, getAdapterFromConfiguration } from '~/routes/b import { exportFlowToXml } from '~/routes/builder/flow-to-xml-parser' import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' +import { useProjectStore } from "~/stores/project-store"; export type FlowNode = FrankNode | ExitNode | StickyNote | GroupNode | Node -const NodeContextMenuContext = createContext<(visible: boolean) => void>(() => {}) +const NodeContextMenuContext = createContext<(visible: boolean) => void>(() => { }) export const useNodeContextMenu = () => useContext(NodeContextMenuContext) const selector = (state: FlowState) => ({ @@ -68,6 +69,7 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: const { nodes, edges, viewport, onNodesChange, onEdgesChange, onConnect, onReconnect } = useFlowStore( useShallow(selector), ) + const project = useProjectStore.getState().project const sourceInfoReference = useRef<{ nodeId: string | null @@ -430,7 +432,8 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: if (tab.flowJson && Object.keys(tab.flowJson).length > 0) { restoreFlowFromTab(tab.value) } else if (tab.configurationName && tab.value) { - const adapter = await getAdapterFromConfiguration(tab.configurationName, tab.value) + if (!project) return + const adapter = await getAdapterFromConfiguration(project.name, tab.configurationName, tab.value) if (!adapter) return const adapterJson = await convertAdapterXmlToJson(adapter) flowStore.setEdges(adapterJson.edges) diff --git a/src/main/frontend/app/routes/builder/xml-to-json-parser.ts b/src/main/frontend/app/routes/builder/xml-to-json-parser.ts index 562c012..8cce50c 100644 --- a/src/main/frontend/app/routes/builder/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/builder/xml-to-json-parser.ts @@ -8,21 +8,22 @@ interface IdCounter { current: number } -export async function getXmlString(filename: string): Promise { +export async function getXmlString(projectName: string, filename: string): Promise { try { - const response = await fetch(`/configurations/${filename}`) + const response = await fetch(`/projects/${projectName}/${filename}`); if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) + throw new Error(`HTTP error! Status: ${response.status}`); } - const data = await response.json() - return data.xmlContent + + const data = await response.json(); + return data.xmlContent; } catch (error) { - throw new Error(`Failed to fetch XML file: ${error}`) + throw new Error(`Failed to fetch XML file for ${projectName}/${filename}: ${error}`); } } -export async function getAdapterNamesFromConfiguration(filename: string): Promise { - const xmlString = await getXmlString(filename) +export async function getAdapterNamesFromConfiguration(projectName: string, filename: string): Promise { + const xmlString = await getXmlString(projectName, filename) return new Promise((resolve, reject) => { const adapterNames: string[] = [] @@ -50,8 +51,8 @@ export async function getAdapterNamesFromConfiguration(filename: string): Promis }) } -export async function getAdapterFromConfiguration(filename: string, adapterName: string): Promise { - const xmlString = await getXmlString(filename) +export async function getAdapterFromConfiguration(projectname: string, filename: string, adapterName: string): Promise { + const xmlString = await getXmlString(projectname, filename) const parser = new DOMParser() const xmlDoc = parser.parseFromString(xmlString, 'text/xml') @@ -65,8 +66,12 @@ export async function getAdapterFromConfiguration(filename: string, adapterName: return null } -export async function getAdapterListenerType(filename: string, adapterName: string): Promise { - const adapterElement = await getAdapterFromConfiguration(filename, adapterName) +export async function getAdapterListenerType( + projectName: string, + filename: string, + adapterName: string, +): Promise { + const adapterElement = await getAdapterFromConfiguration(projectName, filename, adapterName) if (!adapterElement) return null // Look through all child elements inside the adapter const children = adapterElement.querySelectorAll('*') diff --git a/src/main/frontend/app/routes/editor/editor-files.tsx b/src/main/frontend/app/routes/editor/editor-files.tsx index 79d4889..7bb29d0 100644 --- a/src/main/frontend/app/routes/editor/editor-files.tsx +++ b/src/main/frontend/app/routes/editor/editor-files.tsx @@ -48,6 +48,7 @@ export default function EditorFiles() { setIsLoading: state.setIsLoading, })), ) + const project = useProjectStore.getState().project const [searchTerm, setSearchTerm] = useState('') const tree = useRef(null) const dataProviderReference = useRef(new BuilderFilesDataProvider([])) @@ -68,7 +69,8 @@ export default function EditorFiles() { try { const loaded: ConfigWithAdapters[] = await Promise.all( configurationNames.map(async (configName) => { - const adapterNames = await getAdapterNamesFromConfiguration(configName) + if (!project) return + const adapterNames = await getAdapterNamesFromConfiguration(project.name, configName) return { configName, adapterNames } }), ) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index c2061c2..1e5b26a 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,5 +1,5 @@ -import Tabs, {type TabsList} from '~/components/tabs/tabs' -import Editor from '@monaco-editor/react' +import Tabs, { type TabsList } from '~/components/tabs/tabs' +import Editor, { type OnMount } from '@monaco-editor/react' import EditorFiles from '~/routes/editor/editor-files' import SidebarHeader from '~/components/sidebars-layout/sidebar-header' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' @@ -7,12 +7,27 @@ import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' import useTabStore from '~/stores/tab-store' import { useTheme } from '~/hooks/use-theme' -import {useEffect, useState} from "react"; +import { useEffect, useRef, useState } from 'react' +import { getXmlString } from '~/routes/builder/xml-to-json-parser' +import variables from '../../../environment/environment' +import { useFFDoc } from '@frankframework/ff-doc/react' +import { useProjectStore } from '~/stores/project-store' export default function CodeEditor() { const theme = useTheme() + const FRANK_DOC_URL = variables.frankDocJsonUrl + const { elements } = useFFDoc(FRANK_DOC_URL) + const project = useProjectStore.getState().project const [tabs, setTabs] = useState(useTabStore.getState().tabs as TabsList) const [activeTab, setActiveTab] = useState(useTabStore.getState().activeTab) + const [xmlContent, setXmlContent] = useState('') + const editorReference = useRef[0] | null>(null) + const decorationIdsReference = useRef([]) + const [isSaving, setIsSaving] = useState(false) + + const handleEditorMount: OnMount = (editor, monacoInstance) => { + editorReference.current = editor + } useEffect(() => { const unsubTabs = useTabStore.subscribe((state) => { @@ -30,6 +45,152 @@ export default function CodeEditor() { } }, []) + useEffect(() => { + async function fetchXml() { + try { + const configName = useTabStore.getState().getTab(activeTab)?.configurationName + if (!configName || !project) return + const xmlString = await getXmlString(project.name, configName) + setXmlContent(xmlString) + } catch (error) { + console.error('Failed to load XML:', error) + } + } + + fetchXml() + }, [activeTab]) + + useEffect(() => { + if (!xmlContent || !activeTab || !editorReference.current) return + + const editor = editorReference.current + + // Wait for the editor to have a model + const model = editor.getModel() + if (!model) return + + const lines = xmlContent.split('\n') + const matchIndex = lines.findIndex((line) => line.includes(' { + decorationIdsReference.current = editor.deltaDecorations(decorationIdsReference.current, []) + }, 2000) + + // Optional cleanup if component unmounts before timeout + return () => clearTimeout(timeout) + }, [xmlContent, activeTab]) + + useEffect(() => { + // Handles all the suggestions + if (!editorReference.current) return + const monacoInstance = (globalThis as any).monaco + if (!monacoInstance) return + + // Element suggestions + const elementProvider = monacoInstance.languages.registerCompletionItemProvider('xml', { + triggerCharacters: ['<'], + provideCompletionItems: () => { + if (!elements) return { suggestions: [] } + return { + suggestions: Object.values(elements).map((element: any) => { + // Mandatory attributes + const mandatoryAttributes = Object.entries(element.attributes || {}) + .filter(([_, attribute]) => attribute.mandatory) + .map(([name]) => `${name}="\${${name}}"`) + .join(' ') + + // Snippet for tag + mandatory attributes + const mandatoryAttributesWithSpace = mandatoryAttributes ? ` ${mandatoryAttributes}` : '' + const openingTag = `${element.name}${mandatoryAttributesWithSpace}>` + const closingTag = ` { + const line = model.getLineContent(position.lineNumber) + const textBeforeCursor = line.slice(0, position.column - 1) + + // Don't show suggestions if cursor is inside quotes + const quotesBefore = (textBeforeCursor.match(/"/g) || []).length + if (quotesBefore % 2 === 1) { + // Odd number of quotes -> cursor is inside an attribute value + return { suggestions: [] } + } + + const tagMatch = textBeforeCursor.match(/<(\w+)/) + if (!tagMatch) return { suggestions: [] } + + const tagName = tagMatch[1] + if (!elements) return + const element = elements[tagName] + if (!element || !element.attributes) return { suggestions: [] } + + const attributeSuggestions = Object.entries(element.attributes).flatMap( + ([attributeName, attributeValue]: [string, any]) => { + // Suggest enum values if defined + const enumValues = attributeValue?.enum ? Object.keys(attributeValue.enum) : [] + + return enumValues.length > 0 + ? enumValues.map((value, index) => ({ + label: `${attributeName}="${value}"`, + kind: monacoInstance.languages.CompletionItemKind.Enum, + insertText: `${attributeName}="${value}"`, + documentation: attributeValue?.enum[value]?.description || '', + })) + : { + label: attributeName, + kind: monacoInstance.languages.CompletionItemKind.Property, + insertText: `${attributeName}="\${1}"`, + insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: attributeValue?.description || '', + } + }, + ) + + return { suggestions: attributeSuggestions } + }, + }) + + // Cleanup + return () => { + elementProvider.dispose() + attributeProvider.dispose() + } + }, [editorReference.current, elements]) + const handleSelectTab = (key: string) => { useTabStore.getState().setActiveTab(key) } @@ -49,6 +210,32 @@ export default function CodeEditor() { } } + const handleSave = async () => { + if (!project || !activeTab) return + const configName = useTabStore.getState().getTab(activeTab)?.configurationName + if (!configName) return + + const editor = editorReference.current + const updatedXml = editor?.getValue?.() + if (!updatedXml) return + + setIsSaving(true) + + try { + const response = await fetch(`/projects/${project.name}/${configName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ xmlContent: updatedXml }), + }) + + if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`) + } catch (error) { + console.error('Failed to save XML:', error) + } finally { + setIsSaving(false) + } + } + return ( <> @@ -61,15 +248,41 @@ export default function CodeEditor() {
+ -
Path: {activeTab}
-
- -
+ {activeTab ? ( + <> +
Path: {activeTab}
+
+ +
+ + ) : ( +
+
+

No file selected

+

Select an adapter from the file structure on the left to start editing.

+
+
+ )} <> -
Preview
+
+ +
) diff --git a/src/main/frontend/app/stores/tab-store.ts b/src/main/frontend/app/stores/tab-store.ts index bb98ead..00ea8d9 100644 --- a/src/main/frontend/app/stores/tab-store.ts +++ b/src/main/frontend/app/stores/tab-store.ts @@ -1,6 +1,5 @@ import { create } from 'zustand' import { subscribeWithSelector } from 'zustand/middleware' -import FolderIcon from '/icons/solar/Folder.svg?react' interface TabData { value: string @@ -21,8 +20,8 @@ interface TabStoreState { const useTabStore = create()( subscribeWithSelector((set, get) => ({ - tabs: { tab1: { value: 'tab1', configurationName: 'Config1', icon: FolderIcon } }, - activeTab: 'tab1', + tabs: {}, + activeTab: '', setTabData: (tabId, data) => set((state) => ({ tabs: { diff --git a/src/main/frontend/pnpm-lock.yaml b/src/main/frontend/pnpm-lock.yaml index 3b7aecf..d515002 100644 --- a/src/main/frontend/pnpm-lock.yaml +++ b/src/main/frontend/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 3.1.0(acorn@8.14.1)(rollup@4.41.1) '@monaco-editor/react': specifier: 4.7.0-rc.0 - version: 4.7.0-rc.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 4.7.0-rc.0(monaco-editor@0.54.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-router/node': specifier: ^7.4.1 version: 7.4.1(react-router@7.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.8.2) @@ -1871,6 +1871,9 @@ packages: dom-helpers@3.4.0: resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2700,6 +2703,11 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2873,8 +2881,8 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + monaco-editor@0.54.0: + resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==} morgan@1.10.0: resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} @@ -4529,10 +4537,10 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0-rc.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@monaco-editor/react@4.7.0-rc.0(monaco-editor@0.54.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@monaco-editor/loader': 1.5.0 - monaco-editor: 0.52.2 + monaco-editor: 0.54.0 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5760,6 +5768,8 @@ snapshots: dependencies: '@babel/runtime': 7.27.0 + dompurify@3.1.7: {} + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -6760,6 +6770,8 @@ snapshots: markdown-extensions@2.0.0: {} + marked@14.0.0: {} + math-intrinsics@1.1.0: {} mdast-util-from-markdown@2.0.2: @@ -7108,7 +7120,10 @@ snapshots: minipass@7.1.2: {} - monaco-editor@0.52.2: {} + monaco-editor@0.54.0: + dependencies: + dompurify: 3.1.7 + marked: 14.0.0 morgan@1.10.0: dependencies: diff --git a/src/main/frontend/vite.config.ts b/src/main/frontend/vite.config.ts index a155717..73fb9a9 100644 --- a/src/main/frontend/vite.config.ts +++ b/src/main/frontend/vite.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ target: 'http://localhost:8080', // Spring Boot backend changeOrigin: true, }, + '/projects': { + target: 'http://localhost:8080', + changeOrigin: true, + }, }, }, }) diff --git a/src/main/java/org/frankframework/flow/configuration/Configuration.java b/src/main/java/org/frankframework/flow/configuration/Configuration.java new file mode 100644 index 0000000..01ed073 --- /dev/null +++ b/src/main/java/org/frankframework/flow/configuration/Configuration.java @@ -0,0 +1,26 @@ +package org.frankframework.flow.configuration; + +public class Configuration { + private String filename; + private String xmlContent; + + public Configuration(String filename) { + this.filename = filename; + this.xmlContent = ""; + } + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + } + public String getXmlContent() { + return this.xmlContent; + } + + public void setXmlContent(String xmlContent) { + this.xmlContent = xmlContent; + } +} diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationsController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationsController.java deleted file mode 100644 index e86c03a..0000000 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationsController.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.frankframework.flow.configuration; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController() -@RequestMapping("/configurations") -public class ConfigurationsController { - private List configurations = new ArrayList<>(); - - public ConfigurationsController() { - seedConfigurations(); - } - - private void seedConfigurations() { - for (int i = 1; i <= 10; i++) { - ConfigurationDTO config = new ConfigurationDTO(); - config.name = "Config" + i; - config.xmlContent = "1.0." + i; - configurations.add(config); - } - } - - @PostMapping - public ConfigurationDTO create(@RequestBody ConfigurationDTO configuration) { - configurations.add(configuration); - return configuration; - } - - @GetMapping("/{filename}") - public ResponseEntity getById(@PathVariable String filename) { - String resourcePath = "configuration/" + filename; - ClassPathResource resource = new ClassPathResource(resourcePath); - if (!resource.exists()) { - return ResponseEntity.notFound().build(); - } - - try (InputStream inputStream = resource.getInputStream()) { - String xmlContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - ConfigurationDTO configuration = new ConfigurationDTO(); - configuration.name = filename; - configuration.xmlContent = xmlContent; - return ResponseEntity.ok(configuration); - } catch (IOException exception) { - return ResponseEntity.internalServerError().build(); - } - } - - @PutMapping("/{name}") - public ConfigurationDTO update(@PathVariable String name, @RequestBody ConfigurationDTO configuration) { - for (ConfigurationDTO config : configurations) { - if (config.name.equals(name)) { - config = configuration; - return config; - } - } - return null; - } - - @DeleteMapping("/{name}") - public ConfigurationDTO delete(@PathVariable String name) { - for (ConfigurationDTO config : configurations) { - if (config.name.equals(name)) { - configurations.remove(config); - return config; - } - } - return null; - } - - -} diff --git a/src/main/java/org/frankframework/flow/project/Project.java b/src/main/java/org/frankframework/flow/project/Project.java index dc0915f..3014bf4 100644 --- a/src/main/java/org/frankframework/flow/project/Project.java +++ b/src/main/java/org/frankframework/flow/project/Project.java @@ -1,5 +1,7 @@ package org.frankframework.flow.project; +import org.frankframework.flow.configuration.Configuration; + import java.util.ArrayList; import org.frankframework.flow.projectsettings.FilterType; @@ -7,12 +9,12 @@ public class Project { private String name; - private ArrayList filenames; + private ArrayList configurations; private ProjectSettings projectSettings; public Project(String name) { this.name = name; - this.filenames = new ArrayList<>(); + this.configurations = new ArrayList<>(); this.projectSettings = new ProjectSettings(); } @@ -23,12 +25,21 @@ public void setName(String name) { this.name = name; } - public ArrayList getFilenames() { - return filenames; + public ArrayList getConfigurations() { + return configurations; + } + + public void addConfiguration(Configuration configuration) { + this.configurations.add(configuration); } - public void addFilenames(String filename) { - this.filenames.add(filename); + public void setConfigurationXml(String filename, String xmlContent) { + for (Configuration c : this.configurations) { + if (c.getFilename().equals(filename)) { + c.setXmlContent(xmlContent); + return; + } + } } public ProjectSettings getProjectSettings(){ diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index c506e1b..3e95c1a 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -1,11 +1,17 @@ package org.frankframework.flow.project; +import org.frankframework.flow.configuration.Configuration; +import org.frankframework.flow.configuration.ConfigurationDTO; + +import org.springframework.http.HttpStatus; import org.frankframework.flow.projectsettings.FilterType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -29,7 +35,11 @@ public ResponseEntity> getAllProjects() { for (Project project : projects) { ProjectDTO projectDTO = new ProjectDTO(); projectDTO.name = project.getName(); - projectDTO.filenames = project.getFilenames(); + ArrayList filenames = new ArrayList<>(); + for (Configuration c : project.getConfigurations()) { + filenames.add(c.getFilename()); + } + projectDTO.filenames = filenames; projectDTO.filters = project.getProjectSettings().getFilters(); projectDTOList.add(projectDTO); } @@ -45,7 +55,11 @@ public ResponseEntity getProject(@PathVariable String projectname) { } ProjectDTO projectDTO = new ProjectDTO(); projectDTO.name = project.getName(); - projectDTO.filenames = project.getFilenames(); + ArrayList filenames = new ArrayList<>(); + for (Configuration c : project.getConfigurations()) { + filenames.add(c.getFilename()); + } + projectDTO.filenames = filenames; projectDTO.filters = project.getProjectSettings().getFilters(); return ResponseEntity.ok(projectDTO); } catch (Exception e) { @@ -53,6 +67,49 @@ public ResponseEntity getProject(@PathVariable String projectname) { } } + @GetMapping("/{projectName}/{filename}") + public ResponseEntity getConfiguration( + @PathVariable String projectName, + @PathVariable String filename) { + + Project project = projectService.getProject(projectName); + if (project == null) { + return ResponseEntity.notFound().build(); + } + + // Find configuration by filename + for (var config : project.getConfigurations()) { + if (config.getFilename().equals(filename)) { + ConfigurationDTO dto = new ConfigurationDTO(); + dto.name = config.getFilename(); + dto.xmlContent = config.getXmlContent(); + return ResponseEntity.ok(dto); + } + } + + return ResponseEntity.notFound().build(); // No matching config found + } + + @PutMapping("/{projectName}/{filename}") + public ResponseEntity updateConfiguration( + @PathVariable String projectName, + @PathVariable String filename, + @RequestBody ConfigurationDTO configurationDTO) { + try { + boolean updated = projectService.updateConfigurationXml( + projectName, filename, configurationDTO.xmlContent); + + if (!updated) { + return ResponseEntity.notFound().build(); // Project or config not found + } + + return ResponseEntity.ok().build(); + } catch (Exception e) { + e.printStackTrace(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + @PostMapping("/{projectname}") public ResponseEntity createProject(@PathVariable String projectname) { try { @@ -84,7 +141,11 @@ public ResponseEntity enableFilter( // Return updated DTO ProjectDTO dto = new ProjectDTO(); dto.name = project.getName(); - dto.filenames = project.getFilenames(); + ArrayList filenames = new ArrayList<>(); + for (Configuration c : project.getConfigurations()) { + filenames.add(c.getFilename()); + } + dto.filenames = filenames; dto.filters = project.getProjectSettings().getFilters(); return ResponseEntity.ok(dto); @@ -116,7 +177,11 @@ public ResponseEntity disableFilter( // Return updated DTO ProjectDTO dto = new ProjectDTO(); dto.name = project.getName(); - dto.filenames = project.getFilenames(); + ArrayList filenames = new ArrayList<>(); + for (Configuration c : project.getConfigurations()) { + filenames.add(c.getFilename()); + } + dto.filenames = filenames; dto.filters = project.getProjectSettings().getFilters(); return ResponseEntity.ok(dto); diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 9ec8474..bf01278 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -1,17 +1,27 @@ package org.frankframework.flow.project; import org.frankframework.flow.projectsettings.FilterType; +import org.frankframework.flow.configuration.Configuration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; @Service public class ProjectService { - private ArrayList projects; + private final ArrayList projects = new ArrayList<>(); + private static final String BASE_PATH = "classpath:project/"; + private final ResourcePatternResolver resolver; - public ProjectService(){ - projects = new ArrayList<>(); + @Autowired + public ProjectService(ResourcePatternResolver resolver) { + this.resolver = resolver; initiateProjects(); } @@ -34,18 +44,63 @@ public ArrayList getProjects(){ return projects; } - private void initiateProjects(){ - Project testProject = new Project("testproject"); - for (int i = 0; i < 3; i++){ - testProject.addFilenames(String.format("Configuration%d.xml", i+1)); + public boolean updateConfigurationXml(String projectName, String filename, String xmlContent) { + Project project = getProject(projectName); + if (project == null) { + return false; // Project not found + } + + for (Configuration config : project.getConfigurations()) { + if (config.getFilename().equals(filename)) { + config.setXmlContent(xmlContent); + return true; // Successfully updated + } + } + + return false; // Configuration not found + } + + /** + * Dynamically scan all project folders under /resources/project/ + * Each subdirectory = a project + * Each .xml file = a configuration + */ + private void initiateProjects() { + try { + // Find all XML files recursively under /project/ + Resource[] xmlResources = resolver.getResources(BASE_PATH + "**/*.xml"); + + for (Resource resource : xmlResources) { + String path = resource.getURI().toString(); + + // Example path: file:/.../resources/project/testproject/Configuration1.xml + // Extract the project name between "project/" and the next "/" + String[] parts = path.split("/project/"); + if (parts.length < 2) continue; + + String relativePath = parts[1]; // e.g. "testproject/Configuration1.xml" + String projectName = relativePath.substring(0, relativePath.indexOf("/")); + + // Get or create the Project object + Project project = getProject(projectName); + if (project == null) { + project = createProject(projectName); + } + + // Load XML content + String filename = resource.getFilename(); + String xmlContent = Files.readString(resource.getFile().toPath(), StandardCharsets.UTF_8); + + // Create Configuration and add to Project + Configuration configuration = new Configuration(filename); + configuration.setXmlContent(xmlContent); + project.addConfiguration(configuration); + } + + System.out.println("Loaded " + projects.size() + " projects successfully."); + } catch (IOException e) { + System.err.println("Error initializing projects: " + e.getMessage()); + e.printStackTrace(); } - projects.add(testProject); - - Project testProject2 = new Project("testproject_2"); - testProject2.addFilenames("Configuration3.xml"); - testProject2.enableFilter(FilterType.JDBC); - testProject2.enableFilter(FilterType.ADAPTER); - testProject2.enableFilter(FilterType.CMIS); - projects.add(testProject2); } }