diff --git a/.gitignore b/.gitignore index d7df1028..a5e6c5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ internal/appinfo/update_config.json *.asc dist/ +.gocache diff --git a/app.go b/app.go index b52fd7c4..961de889 100644 --- a/app.go +++ b/app.go @@ -86,12 +86,6 @@ type RequestOptions struct { PostResponseScript string `json:"postResponseScript,omitempty"` } -type OpenAPIImportCollectionResult struct { - Warnings []string `json:"warnings"` - BasePath string `json:"basePath"` - Servers []string `json:"servers"` -} - func (a *App) GetAppInfo() appinfo.AppInfo { return appinfo.GetAppInfo(wailsJSON) } @@ -446,44 +440,33 @@ func (a *App) SetSelectedEnvironment(name string) error { // Collection Management Methods -// ImportPostmanCollection imports a Postman v2.1 collection file into Solo. -func (a *App) ImportPostmanCollection(path string) error { - imp := importer.NewPostmanImporter() - - coll, err := imp.Import(path) - if err != nil { - return fmt.Errorf("failed to import collection: %w", err) - } - - // Save the imported collection using the existing manager. - // UpdateCollection will write the entire JSON object to disk using its Name. - if err := a.collectionManager.UpdateCollection(*coll); err != nil { - return fmt.Errorf("failed to save imported collection: %w", err) +// ImportPostmanCollections imports Postman v2.1 collection files into Solo. +func (a *App) ImportPostmanCollections(paths []string, overwriteExisting bool) (collection.BatchImportResult, error) { + if a.collectionManager == nil { + return collection.BatchImportResult{}, fmt.Errorf("collection manager not initialized") } - return nil + imp := importer.NewPostmanImporter() + return a.collectionManager.ImportBatch(paths, overwriteExisting, func(path string) (*collection.Collection, []string, error) { + coll, err := imp.Import(path) + return coll, nil, err + }) } -// ImportOpenAPICollection imports an OpenAPI 3.x or Swagger 2.x collection -// (JSON or YAML) into Solo. -// Returns warning messages and source metadata used by the frontend to build baseUrl. -func (a *App) ImportOpenAPICollection(path string) (OpenAPIImportCollectionResult, error) { - imp := importer.NewOpenAPIImporter() - - result, err := imp.Import(path) - if err != nil { - return OpenAPIImportCollectionResult{}, fmt.Errorf("failed to import OpenAPI collection: %w", err) - } - - if err := a.collectionManager.UpdateCollection(*result.Collection); err != nil { - return OpenAPIImportCollectionResult{}, fmt.Errorf("failed to save imported OpenAPI collection: %w", err) +// ImportOpenAPICollections imports OpenAPI 3.x or Swagger 2.x collection files into Solo. +func (a *App) ImportOpenAPICollections(paths []string, overwriteExisting bool) (collection.BatchImportResult, error) { + if a.collectionManager == nil { + return collection.BatchImportResult{}, fmt.Errorf("collection manager not initialized") } - return OpenAPIImportCollectionResult{ - Warnings: result.Warnings, - BasePath: result.BasePath, - Servers: result.Servers, - }, nil + imp := importer.NewOpenAPIImporter() + return a.collectionManager.ImportBatch(paths, overwriteExisting, func(path string) (*collection.Collection, []string, error) { + result, err := imp.Import(path) + if err != nil { + return nil, nil, err + } + return result.Collection, result.Warnings, nil + }) } // ImportCurlRequest parses a cURL command string and adds the resulting request @@ -586,34 +569,34 @@ func (a *App) ExportEnvironment(environmentName string) error { return os.WriteFile(path, data, 0644) } -// ImportSoloCollection imports a Solo-native collection JSON file. -// If overwrite is false and a collection with the same name already exists, -// returns an error of the form "collection already exists". -func (a *App) ImportSoloCollection(path string, overwrite bool) error { +func (a *App) loadSoloCollectionForImport(path string) (*collection.Collection, []string, error) { data, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("failed to read file: %w", err) + return nil, nil, fmt.Errorf("failed to read file: %w", err) } var coll collection.Collection if err := json.Unmarshal(data, &coll); err != nil { - return fmt.Errorf("invalid %s collection file: %w", tools.APP_NAME, err) + return nil, nil, fmt.Errorf("invalid %s collection file: %w", tools.APP_NAME, err) } if coll.Name == "" { - return fmt.Errorf("collection file has no name field") + return nil, nil, fmt.Errorf("collection file has no name field") } - if !overwrite { - existing, _ := a.collectionManager.LoadCollection(coll.Name) - if existing != nil { - return fmt.Errorf("collection %s already exists", coll.Name) - } + return &coll, nil, nil +} + +// ImportSoloCollections imports Solo-native collection JSON files into Solo. +func (a *App) ImportSoloCollections(paths []string, overwriteExisting bool) (collection.BatchImportResult, error) { + if a.collectionManager == nil { + return collection.BatchImportResult{}, fmt.Errorf("collection manager not initialized") } - if err := a.collectionManager.UpdateCollection(coll); err != nil { - return fmt.Errorf("failed to save collection: %w", err) + importOne := func(path string) (*collection.Collection, []string, error) { + return a.loadSoloCollectionForImport(path) } - return nil + + return a.collectionManager.ImportBatch(paths, overwriteExisting, importOne) } // ImportSoloEnvironment imports a Solo-native environment JSON file. @@ -727,20 +710,17 @@ func (a *App) ExportLogsZip() (bool, error) { return true, nil } -// ImportBrunoCollection imports a Bruno collection from a directory. -func (a *App) ImportBrunoCollection(path string) error { - imp := importer.NewBrunoImporter() - - coll, err := imp.Import(path) - if err != nil { - return fmt.Errorf("failed to import Bruno collection: %w", err) - } - - if err := a.collectionManager.UpdateCollection(*coll); err != nil { - return fmt.Errorf("failed to save imported Bruno collection: %w", err) +// ImportBrunoCollections imports Bruno collection directories into Solo. +func (a *App) ImportBrunoCollections(paths []string, overwriteExisting bool) (collection.BatchImportResult, error) { + if a.collectionManager == nil { + return collection.BatchImportResult{}, fmt.Errorf("collection manager not initialized") } - return nil + imp := importer.NewBrunoImporter() + return a.collectionManager.ImportBatch(paths, overwriteExisting, func(path string) (*collection.Collection, []string, error) { + coll, err := imp.Import(path) + return coll, nil, err + }) } // ImportPostmanEnvironment imports a Postman environment JSON file. @@ -1292,6 +1272,21 @@ func (a *App) SelectFile(title, patterns, displayName string) (string, error) { }) } +// SelectFiles opens a native file dialog to select multiple files. +// It takes a title for the dialog and a pattern for file filtering (e.g., "*.pem;*.crt"). +func (a *App) SelectFiles(title, patterns, displayName string) ([]string, error) { + slog.Debug("Opening multiple files dialog", "title", title, "patterns", patterns) + return runtime.OpenMultipleFilesDialog(a.ctx, runtime.OpenDialogOptions{ + Title: title, + Filters: []runtime.FileFilter{ + { + DisplayName: displayName, + Pattern: patterns, // e.g., "*.pem;*.crt;*.key" + }, + }, + }) +} + // ── Git helpers ────────────────────────────────────────────────────────────── // resolveGitCollectionDir resolves a collectionId to its local git repo dir diff --git a/docs/releases/0.3.0.md b/docs/releases/0.3.0.md new file mode 100644 index 00000000..09b5c29b --- /dev/null +++ b/docs/releases/0.3.0.md @@ -0,0 +1,29 @@ +# Solo release 0.3.0 + +## Highlights + +## ✨ New Features + +- Add batch collection import for Postman, Bruno, OpenAPI/Swagger, and Solo-native collection formats ([#156](https://github.com/raml-dev/solo/pull/156)). +- Add multi-file picker support for file-based collection imports. Bruno remains folder-based through the picker, with multi-directory import supported through drag-and-drop ([#156](https://github.com/raml-dev/solo/pull/156)). +- Add per-source batch import results with imported, failed, warning, and conflict states ([#156](https://github.com/raml-dev/solo/pull/156)). +- Add a conflict review flow for batch imports so users can choose which existing collections to overwrite instead of overwriting implicitly ([#156](https://github.com/raml-dev/solo/pull/156)). +- Add `SelectFiles` to the Wails API for native multi-file selection ([#156](https://github.com/raml-dev/solo/pull/156)). + +## 🐞 Bug fixes + +- Fix Bruno collection import for `params:query` sections ([#156](https://github.com/raml-dev/solo/pull/156)). +- Fix Bruno path parameter handling by substituting `params:path` values into request URLs ([#156](https://github.com/raml-dev/solo/pull/156)). +- Fix Bruno query parameter duplication when a parameter is already present in the URL ([#156](https://github.com/raml-dev/solo/pull/156)). +- Fix the environment manager modal layout so it no longer opens fullscreen and matches the Settings modal sizing ([#156](https://github.com/raml-dev/solo/pull/156)). +- Ignore local `.gocache` directories in Git. + +## ⬆️ Dependency updates + +- Updated `vite` from `8.0.5` to `8.0.16`. +- Updated `dompurify` from `3.4.0` to `3.4.11`. +- Updated Go indirect dependencies: + - `golang.org/x/crypto` from `0.51.0` to `0.55.0` + - `golang.org/x/net` from `0.54.0` to `0.58.0` + - `golang.org/x/sys` from `0.44.0` to `0.47.0` + - `golang.org/x/text` from `0.37.0` to `0.41.0` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3024791..1f48c659 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1579,16 +1579,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1760,9 +1760,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1963,9 +1963,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2633,9 +2633,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3071,9 +3071,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3218,9 +3218,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3238,7 +3238,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index f298e037..061b96be 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a0d58cdeee5fe9a475016d1d05aad5c9 \ No newline at end of file +1b75c4f1294551a5ad3586f8db6f4c74 \ No newline at end of file diff --git a/frontend/src/lib/components/Collections/CollectionList.svelte b/frontend/src/lib/components/Collections/CollectionList.svelte index 4fce63f6..30f2e12b 100644 --- a/frontend/src/lib/components/Collections/CollectionList.svelte +++ b/frontend/src/lib/components/Collections/CollectionList.svelte @@ -44,7 +44,11 @@ SyncGitCollection } from "$wails/go/main/App"; import { collection } from "$wails/go/models"; + import Alert from "flowbite-svelte/Alert.svelte"; + import Badge from "flowbite-svelte/Badge.svelte"; import Button from "flowbite-svelte/Button.svelte"; + import Card from "flowbite-svelte/Card.svelte"; + import Checkbox from "flowbite-svelte/Checkbox.svelte"; import Input from "flowbite-svelte/Input.svelte"; import Label from "flowbite-svelte/Label.svelte"; import Modal from "flowbite-svelte/Modal.svelte"; @@ -100,12 +104,13 @@ const deleteFolderModal = modalStack.createModal("collections-delete-folder"); const deleteRequestModal = modalStack.createModal("collections-delete-request"); const importCollectionModal = modalStack.createModal("collections-import"); + const importReviewModal = modalStack.createModal("collections-import-review"); const collectionVariablesModal = modalStack.createModal("collections-variables"); - const soloCollectionOverwriteModal = modalStack.createModal("collections-solo-overwrite"); const exportCollectionModal = modalStack.createModal("collections-export"); let exportCollectionTargetName = $state(null); let gitImportActionState: { loading: boolean; disabled: boolean; submit: () => void } | null = $state(null); + let importReviewDismissed = $state(false); let newCollectionName = $state(""); let renameCollectionName = $state(""); @@ -138,10 +143,6 @@ } }); - $effect(() => { - soloCollectionOverwriteModal.open = !!collectionImportStoreState.soloCollectionOverwriteName; - }); - $effect(() => { if ( collectionVariablesModal.open && @@ -651,6 +652,7 @@ } function closeImportModal() { + importReviewModal.open = false; importCollectionModal.open = false; collectionImportStore.resetLocalImport(); curlInput = ""; @@ -661,31 +663,71 @@ function openImportModal() { collectionImportStore.resetLocalImport(); gitImportActionState = null; + importReviewDismissed = false; importCollectionModal.open = true; } async function runPendingLocalImport() { + importReviewDismissed = false; await collectionImportStore.runPendingLocalImport(); - if ( - !collectionImportStoreState.pendingLocalImport && - !collectionImportStoreState.soloCollectionOverwriteName - ) { + if (!collectionImportStoreState.pendingLocalImport) { importCollectionModal.open = false; } } - async function confirmSoloCollectionOverwrite() { - await collectionImportStore.confirmSoloCollectionOverwrite(); + function closeImportReviewModal() { + importReviewDismissed = true; + importReviewModal.open = false; + } + + async function runSelectedConflictOverwrites() { + await collectionImportStore.runSelectedConflictOverwrites(); - if ( - !collectionImportStoreState.pendingLocalImport && - !collectionImportStoreState.soloCollectionOverwriteName - ) { + if (!collectionImportStoreState.pendingLocalImport) { + importReviewModal.open = false; importCollectionModal.open = false; } } + async function handleLocalImportSelection( + format: CollectionLocalImportFormat, + droppedPaths?: string[] + ) { + if (!droppedPaths) { + collectionImportStoreState.selectedLocalFormat = format; + await collectionImportStore.pickLocalImportPath(); + return; + } + + await collectionImportStore.setPendingLocalImportFromDrop(format, droppedPaths); + } + + function getImportIssueLabel(item: collection.BatchImportItemResult): string { + if (item.name) return item.name; + if (item.path) return item.path; + return "Unknown source"; + } + + function getImportIssueStatus(item: collection.BatchImportItemResult): string { + if (item.conflict) return "Conflict"; + if (!item.success) return "Failed"; + return "Warning"; + } + + function getImportIssueBadgeColor(item: collection.BatchImportItemResult): "red" | "yellow" { + return item.conflict || (item.warnings ?? []).length > 0 ? "yellow" : "red"; + } + + function getImportIssueDetails(item: collection.BatchImportItemResult): string { + if (item.error) return item.error; + return (item.warnings ?? []).join("; "); + } + + function isConflictSelected(path: string): boolean { + return collectionImportStoreState.selectedOverwriteConflictPaths.includes(path); + } + function isContextMenuOpen(): boolean { return ( collectionTreeUIState.collectionContextMenu.open || @@ -744,8 +786,8 @@ modalStack.destroyModal(deleteFolderModal.id); modalStack.destroyModal(deleteRequestModal.id); modalStack.destroyModal(importCollectionModal.id); + modalStack.destroyModal(importReviewModal.id); modalStack.destroyModal(collectionVariablesModal.id); - modalStack.destroyModal(soloCollectionOverwriteModal.id); modalStack.destroyModal(exportCollectionModal.id); collectionTreeUI.closeCollectionContextMenu(); collectionTreeUI.closeRequestContextMenu(); @@ -768,6 +810,36 @@ ? collectionImportStoreState.pendingLocalImport : null ); + let localImportResultItems = $derived( + collectionImportStoreState.lastLocalImportResult?.results ?? [] + ); + let localImportConflictItems = $derived( + localImportResultItems.filter((item) => !item.success && item.conflict) + ); + let localImportIssueItems = $derived( + localImportResultItems.filter((item) => !item.success || (item.warnings ?? []).length > 0) + ); + let localImportSuccessCount = $derived( + localImportResultItems.filter((item) => item.success).length + ); + let localImportFailureCount = $derived( + localImportResultItems.filter((item) => !item.success && !item.conflict).length + ); + let localImportWarningCount = $derived( + localImportResultItems.reduce((count, item) => count + (item.warnings ?? []).length, 0) + ); + let hasPendingConflictResolution = $derived( + activePendingLocalImport !== null && localImportConflictItems.length > 0 + ); + let localImportActionLabel = $derived.by(() => { + if (collectionImportStoreState.localImportLoading) return "Importing..."; + return "Import"; + }); + let localActionDisabled = $derived( + !activePendingLocalImport || + activePendingLocalImport.paths.length === 0 || + collectionImportStoreState.localImportLoading + ); let collectionVariablesTarget = $derived( collectionVariablesTargetName ? collections.find( @@ -782,6 +854,17 @@ let requestContextTarget = $derived(requestContextMenuState.request); let folderContextTarget = $derived(folderContextMenuState.folder); + $effect(() => { + if ( + importCollectionModal.open && + !importReviewDismissed && + !collectionImportStoreState.localImportLoading && + localImportIssueItems.length > 0 + ) { + importReviewModal.open = true; + } + }); + function getCollectionContextMenuTriggerId(): string { return `collection-context-menu-trigger-${collectionContextMenuState.openKey}`; } @@ -1277,8 +1360,8 @@ bind:open={importCollectionModal.open} onClose={closeImportModal} showCurlSection - localActionLabel={collectionImportStoreState.localImportLoading ? "Importing..." : "Import"} - localActionDisabled={!activePendingLocalImport || collectionImportStoreState.localImportLoading} + localActionLabel={localImportActionLabel} + {localActionDisabled} onLocalAction={runPendingLocalImport} curlActionLabel="Import Request" curlActionDisabled={!curlInput.trim() || (!curlTargetCollection && !curlCreatingNew)} @@ -1297,7 +1380,7 @@ {/if} {/snippet} @@ -1366,6 +1449,141 @@ {/if} +{#if importCollectionModal.open && localImportIssueItems.length > 0} + + {#if $topModalId === importReviewModal.id} + + {/if} + +
+
+

+ Review the import result. You can close this dialog to go back to the import form. +

+ +
+
+ {localImportSuccessCount} imported + {#if localImportConflictItems.length > 0} + + {localImportConflictItems.length} + conflict{localImportConflictItems.length === 1 ? "" : "s"} + + {/if} + {#if localImportFailureCount > 0} + {localImportFailureCount} failed + {/if} + {#if localImportWarningCount > 0} + + {localImportWarningCount} warning{localImportWarningCount === 1 ? "" : "s"} + + {/if} +
+ + {#if hasPendingConflictResolution} +
+ + +
+ {/if} +
+
+ + {#if !hasPendingConflictResolution} + No overwrite decision is needed for these results. + {/if} + +
+ {#each localImportIssueItems as item (item.path)} + +
+
+
+ + {getImportIssueStatus(item)} + + + {getImportIssueLabel(item)} + +
+ + {#if item.path} +

+ {item.path} +

+ {/if} + + {#if getImportIssueDetails(item)} +

+ {getImportIssueDetails(item)} +

+ {/if} +
+ + {#if item.conflict} +
+ { + collectionImportStore.setConflictOverwriteSelection( + item.path, + !isConflictSelected(item.path) + ); + }} + /> + Overwrite +
+ {/if} +
+
+ {/each} +
+
+ + {#snippet footer()} +
+ + {#if hasPendingConflictResolution} + + {/if} +
+ {/snippet} +
+{/if} + {#if collectionVariablesModal.open && collectionVariablesTarget} {/if} -{#if soloCollectionOverwriteModal.open} - { - collectionImportStore.cancelSoloCollectionOverwrite(); - soloCollectionOverwriteModal.open = false; - }} - size="xl" - > - {#if $topModalId === soloCollectionOverwriteModal.id} - - {/if} -

Collection "{collectionImportStoreState.soloCollectionOverwriteName}" already exists.

-

Do you want to overwrite it?

- {#snippet footer()} -
- -
- {/snippet} -
-{/if} - void handleExportCollection(format)} diff --git a/frontend/src/lib/components/Environment/EnvironmentManager.svelte b/frontend/src/lib/components/Environment/EnvironmentManager.svelte index 4ae9fcb2..97194c63 100644 --- a/frontend/src/lib/components/Environment/EnvironmentManager.svelte +++ b/frontend/src/lib/components/Environment/EnvironmentManager.svelte @@ -446,7 +446,7 @@ {#snippet importDropdown(triggeredBy: string, isOpen: boolean | undefined, onClose: () => void)} - + { openImportModal(); @@ -616,7 +616,7 @@ handleLocalEnvironmentImport(format, paths?.[0])} /> {/snippet} diff --git a/frontend/src/lib/components/MainLayout.svelte b/frontend/src/lib/components/MainLayout.svelte index 3d23bfb0..084aceaa 100644 --- a/frontend/src/lib/components/MainLayout.svelte +++ b/frontend/src/lib/components/MainLayout.svelte @@ -13,6 +13,7 @@ import CogSolid from "flowbite-svelte-icons/CogSolid.svelte"; import GlobeSolid from "flowbite-svelte-icons/GlobeSolid.svelte"; import Button from "flowbite-svelte/Button.svelte"; + import CloseButton from "flowbite-svelte/CloseButton.svelte"; import Modal from "flowbite-svelte/Modal.svelte"; import { onDestroy } from "svelte"; @@ -81,10 +82,15 @@ {#if environmentManagerModal.open} + {#snippet header()} +
+ (environmentManagerModal.open = false)} /> +
+ {/snippet} {#if $topModalId === environmentManagerModal.id} {/if} @@ -97,7 +103,7 @@ title="Settings" bind:open={settingsModal.open} size="xl" - classes={{ body: "h-[600px] overflow-hidden p-4" }} + classes={{ body: "h-[min(80dvh,44rem)] overflow-hidden p-4" }} > {#if $topModalId === settingsModal.id} diff --git a/frontend/src/lib/components/imports/ImportModal.svelte b/frontend/src/lib/components/imports/ImportModal.svelte index 29ade8ae..f860efb9 100644 --- a/frontend/src/lib/components/imports/ImportModal.svelte +++ b/frontend/src/lib/components/imports/ImportModal.svelte @@ -77,7 +77,9 @@ { selectedSection = "local"; diff --git a/frontend/src/lib/components/imports/LocalImportPane.svelte b/frontend/src/lib/components/imports/LocalImportPane.svelte index e94e6220..7b6822b7 100644 --- a/frontend/src/lib/components/imports/LocalImportPane.svelte +++ b/frontend/src/lib/components/imports/LocalImportPane.svelte @@ -8,8 +8,7 @@ import type { LocalImportFormatOption } from "$src/lib/components/imports/importTypes"; import { collectionImportStore, - collectionImportStoreState, - type CollectionLocalImportFormat + collectionImportStoreState } from "$src/lib/stores/collectionImportStore.svelte"; import Button from "flowbite-svelte/Button.svelte"; import Input from "flowbite-svelte/Input.svelte"; @@ -19,7 +18,7 @@ interface Props { formats: LocalImportFormatOption[]; selectedFormat: TFormat; - onImport: (format: TFormat, droppedPath?: string) => Promise; + onImport: (format: TFormat, droppedPaths?: string[]) => Promise; } let { formats, selectedFormat = $bindable(), onImport }: Props = $props(); @@ -30,6 +29,14 @@ ? collectionImportStoreState.pendingLocalImport : null ); + const selectedSourceLabel = $derived.by(() => { + const paths = activePendingLocalImport?.paths ?? []; + + if (paths.length === 0) return ""; + if (paths.length === 1) return paths[0]; + + return `${paths.length} sources selected`; + }); function getChangeButtonLabel() { if (!selectedOption) return "Change selection..."; @@ -40,15 +47,16 @@ if (!selectedOption) return; if (paths.length > 0) { - await onImport(selectedOption.key, paths[0]); + await onImport(selectedOption.key, paths); } else { await onImport(selectedOption.key); } } async function handlePickPath() { - collectionImportStoreState.selectedLocalFormat = selectedFormat as CollectionLocalImportFormat; - await collectionImportStore.pickLocalImportPath(); + if (!selectedOption) return; + + await onImport(selectedOption.key); } @@ -67,7 +75,7 @@
Promise; - run: (path: string) => Promise; + pick: () => Promise; + run: (paths: string[], overwriteExisting: boolean) => Promise; errorTitle: string; } +const maxFailureDetailToasts = 3; +const maxWarningDetailToasts = 3; + const initialState: CollectionImportState = { selectedLocalFormat: "postman", pendingLocalImport: null, localImportLoading: false, - pendingSoloCollectionPath: null, - soloCollectionOverwriteName: null + lastLocalImportResult: null, + selectedOverwriteConflictPaths: [] }; -function parseCollectionNameFromError(message: string): string | null { - const match = message.match(/collection\s+(\S+)\s+already exists/i); - return match ? match[1] : null; +function getImportSummaryLabel(format: CollectionLocalImportFormat): string { + switch (format) { + case "postman": + return "Postman collection import"; + case "bruno": + return "Bruno collection import"; + case "openapi": + return "OpenAPI collection import"; + case "solo": + return "Solo collection import"; + } } -async function executePostmanImport(path: string): Promise { - await ImportPostmanCollection(path); - await collectionStore.loadCollections(); - notifications.success("Postman collection imported"); - return "completed"; +function getItemLabel(item: collection.BatchImportItemResult): string { + return item.name || item.path || "Unknown source"; } -async function executeBrunoImport(path: string): Promise { - await ImportBrunoCollection(path); - await collectionStore.loadCollections(); - notifications.success("Bruno collection imported"); - return "completed"; -} +async function finishBatchImport( + format: CollectionLocalImportFormat, + result: collection.BatchImportResult, + overwriteExisting: boolean +): Promise { + const items = result.results ?? []; + const successes = items.filter((item) => item.success); + const conflicts = items.filter((item) => !item.success && item.conflict); + const failures = items.filter((item) => !item.success && !item.conflict); + const warnings = items.flatMap((item) => + (item.warnings ?? []).map((warning) => ({ + source: getItemLabel(item), + warning + })) + ); + const total = items.length; + + collectionImportStoreState.lastLocalImportResult = result; + collectionImportStoreState.selectedOverwriteConflictPaths = conflicts.map((item) => item.path); + + if (successes.length > 0) { + await collectionStore.loadCollections(); + } -async function executeOpenAPIImport(path: string): Promise { - const { warnings } = await ImportOpenAPICollection(path); - await collectionStore.loadCollections(); + const summaryLabel = getImportSummaryLabel(format); + const detailParts = [ + `${successes.length}/${total} imported`, + conflicts.length > 0 + ? `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}` + : null, + failures.length > 0 ? `${failures.length} failed` : null, + warnings.length > 0 ? `${warnings.length} warning${warnings.length === 1 ? "" : "s"}` : null + ].filter(Boolean); + + if (conflicts.length > 0 && !overwriteExisting) { + notifications.warning(`${summaryLabel} needs review`, detailParts.join(", ")); + } else if (failures.length === 0) { + notifications.success(summaryLabel, detailParts.join(", ")); + } else if (successes.length > 0) { + notifications.warning(`${summaryLabel} finished with issues`, detailParts.join(", ")); + } else { + notifications.error(`${summaryLabel} failed`, detailParts.join(", ")); + } + + for (const item of failures.slice(0, maxFailureDetailToasts)) { + notifications.error(`Failed to import ${getItemLabel(item)}`, item.error || "Unknown error"); + } - for (const warning of warnings ?? []) { - notifications.warning(warning); + if (failures.length > maxFailureDetailToasts) { + notifications.error( + `${failures.length - maxFailureDetailToasts} more import failures`, + "Review the source files and try again." + ); } - notifications.success("OpenAPI collection imported"); - return "completed"; + for (const { source, warning } of warnings.slice(0, maxWarningDetailToasts)) { + notifications.warning(`${source}: ${warning}`); + } + + if (warnings.length > maxWarningDetailToasts) { + notifications.warning( + `${warnings.length - maxWarningDetailToasts} more import warnings`, + "Additional warnings were omitted from notifications." + ); + } + + return conflicts.length > 0 || failures.length > 0 || warnings.length > 0 + ? "needs_review" + : "completed"; } -async function executeSoloCollectionImport( - path: string, - overwrite: boolean +async function executePostmanImport( + paths: string[], + overwriteExisting: boolean ): Promise { - try { - await ImportSoloCollection(path, overwrite); - await collectionStore.loadCollections(); - notifications.success("Collection imported successfully"); - return "completed"; - } catch (err) { - const message = String(err ?? "Failed to import collection"); - const existingName = parseCollectionNameFromError(message); - - if (!overwrite && existingName) { - collectionImportStoreState.pendingSoloCollectionPath = path; - collectionImportStoreState.soloCollectionOverwriteName = existingName; - return "awaiting_overwrite"; - } + return finishBatchImport( + "postman", + await ImportPostmanCollections(paths, overwriteExisting), + overwriteExisting + ); +} - throw new Error(message); - } +async function executeBrunoImport( + paths: string[], + overwriteExisting: boolean +): Promise { + return finishBatchImport( + "bruno", + await ImportBrunoCollections(paths, overwriteExisting), + overwriteExisting + ); +} + +async function executeOpenAPIImport( + paths: string[], + overwriteExisting: boolean +): Promise { + return finishBatchImport( + "openapi", + await ImportOpenAPICollections(paths, overwriteExisting), + overwriteExisting + ); +} + +async function executeSoloCollectionImport( + paths: string[], + overwriteExisting: boolean +): Promise { + return finishBatchImport( + "solo", + await ImportSoloCollections(paths, overwriteExisting), + overwriteExisting + ); } const LOCAL_IMPORT_HANDLERS: Record = { postman: { - pick: () => SelectFile("Select Postman Collection", "*.json", "JSON Files"), + pick: () => SelectFiles("Select Postman Collections", "*.json", "JSON Files"), run: executePostmanImport, errorTitle: "Failed to import Postman collection" }, bruno: { - pick: () => SelectDirectory("Select Bruno Collection Folder"), + pick: async () => { + const path = await SelectDirectory("Select Bruno Collection Folder"); + return path ? [path] : []; + }, run: executeBrunoImport, errorTitle: "Failed to import Bruno collection" }, openapi: { pick: () => - SelectFile( - "Select OpenAPI / Swagger Document", + SelectFiles( + "Select OpenAPI / Swagger Documents", "*.json;*.yaml;*.yml", "OpenAPI / Swagger Files" ), @@ -121,21 +206,29 @@ const LOCAL_IMPORT_HANDLERS: Record SelectFile("Select Solo Collection", "*.json", "JSON Files"), - run: (path) => executeSoloCollectionImport(path, false), + pick: () => SelectFiles("Select Solo Collections", "*.json", "JSON Files"), + run: executeSoloCollectionImport, errorTitle: "Failed to import collection" } }; -function setPendingLocalImport(format: CollectionLocalImportFormat, path: string) { - if (!path) { +function normalizeImportPaths(paths?: string | string[]): string[] { + if (!paths) { + return []; + } + + return (Array.isArray(paths) ? paths : [paths]).filter((path) => path.length > 0); +} + +function setPendingLocalImport(format: CollectionLocalImportFormat, paths: string[]) { + if (paths.length === 0) { return; } collectionImportStoreState.selectedLocalFormat = format; - collectionImportStoreState.pendingLocalImport = { format, path }; - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; + collectionImportStoreState.pendingLocalImport = { format, paths }; + collectionImportStoreState.lastLocalImportResult = null; + collectionImportStoreState.selectedOverwriteConflictPaths = []; } export const collectionImportStoreState = $state({ ...initialState }); @@ -145,33 +238,65 @@ export const collectionImportStore = { collectionImportStoreState.selectedLocalFormat = initialState.selectedLocalFormat; collectionImportStoreState.pendingLocalImport = null; collectionImportStoreState.localImportLoading = false; - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; + collectionImportStoreState.lastLocalImportResult = null; + collectionImportStoreState.selectedOverwriteConflictPaths = []; }, async pickLocalImportPath() { const format = collectionImportStoreState.selectedLocalFormat; - const path = await LOCAL_IMPORT_HANDLERS[format].pick(); + const paths = await LOCAL_IMPORT_HANDLERS[format].pick(); - if (!path) { + if (paths.length === 0) { return; } - setPendingLocalImport(format, path); + setPendingLocalImport(format, paths); }, - async setPendingLocalImportFromDrop(format: CollectionLocalImportFormat, path?: string) { - if (!path) { + async setPendingLocalImportFromDrop( + format: CollectionLocalImportFormat, + pathOrPaths?: string | string[] + ) { + const paths = normalizeImportPaths(pathOrPaths); + if (paths.length === 0) { return; } - setPendingLocalImport(format, path); + setPendingLocalImport(format, paths); }, clearPendingLocalImport() { collectionImportStoreState.pendingLocalImport = null; - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; + collectionImportStoreState.lastLocalImportResult = null; + collectionImportStoreState.selectedOverwriteConflictPaths = []; + }, + + setConflictOverwriteSelection(path: string, selected: boolean) { + const selectedPaths = collectionImportStoreState.selectedOverwriteConflictPaths; + const alreadySelected = selectedPaths.includes(path); + + if (selected && !alreadySelected) { + collectionImportStoreState.selectedOverwriteConflictPaths = [...selectedPaths, path]; + return; + } + + if (!selected && alreadySelected) { + collectionImportStoreState.selectedOverwriteConflictPaths = selectedPaths.filter( + (selectedPath) => selectedPath !== path + ); + } + }, + + selectAllConflictOverwrites() { + collectionImportStoreState.selectedOverwriteConflictPaths = ( + collectionImportStoreState.lastLocalImportResult?.results ?? [] + ) + .filter((item) => item.conflict) + .map((item) => item.path); + }, + + clearConflictOverwriteSelection() { + collectionImportStoreState.selectedOverwriteConflictPaths = []; }, async runPendingLocalImport() { @@ -184,11 +309,11 @@ export const collectionImportStore = { let shouldKeepLoadingUntilReset = false; collectionImportStoreState.localImportLoading = true; - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; + collectionImportStoreState.lastLocalImportResult = null; + collectionImportStoreState.selectedOverwriteConflictPaths = []; try { - const result = await handler.run(pendingImport.path); + const result = await handler.run(pendingImport.paths, false); if (result === "completed") { collectionImportStoreState.pendingLocalImport = null; shouldKeepLoadingUntilReset = true; @@ -202,30 +327,27 @@ export const collectionImportStore = { } }, - cancelSoloCollectionOverwrite() { - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; - }, - - async confirmSoloCollectionOverwrite() { - const path = collectionImportStoreState.pendingSoloCollectionPath; - if (!path) { + async runSelectedConflictOverwrites() { + const pendingImport = collectionImportStoreState.pendingLocalImport; + if (!pendingImport || collectionImportStoreState.selectedOverwriteConflictPaths.length === 0) { return; } + const handler = LOCAL_IMPORT_HANDLERS[pendingImport.format]; let shouldKeepLoadingUntilReset = false; collectionImportStoreState.localImportLoading = true; - collectionImportStoreState.pendingSoloCollectionPath = null; - collectionImportStoreState.soloCollectionOverwriteName = null; try { - const result = await executeSoloCollectionImport(path, true); + const result = await handler.run( + collectionImportStoreState.selectedOverwriteConflictPaths, + true + ); if (result === "completed") { collectionImportStoreState.pendingLocalImport = null; shouldKeepLoadingUntilReset = true; } } catch (err) { - notifications.error("Failed to import collection", String(err)); + notifications.error(handler.errorTitle, String(err)); } finally { if (!shouldKeepLoadingUntilReset) { collectionImportStoreState.localImportLoading = false; diff --git a/go.mod b/go.mod index 78eeab66..dcc9a013 100644 --- a/go.mod +++ b/go.mod @@ -50,10 +50,10 @@ require ( github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/yuin/gopher-lua v1.1.2 - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect ) replace golang.org/x/image => github.com/matstech/image v0.0.0-20260519125732-ad443cf087e0 diff --git a/go.sum b/go.sum index 98a04e44..27594495 100644 --- a/go.sum +++ b/go.sum @@ -79,21 +79,21 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/collection/batch_import.go b/internal/collection/batch_import.go new file mode 100644 index 00000000..04556afb --- /dev/null +++ b/internal/collection/batch_import.go @@ -0,0 +1,142 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +package collection + +import ( + "errors" + "fmt" + "os" + "sync" +) + +const maxConcurrentCollectionImports = 4 + +type BatchImportResult struct { + Results []BatchImportItemResult `json:"results"` +} + +type BatchImportItemResult struct { + Path string `json:"path"` + Name string `json:"name,omitempty"` + Success bool `json:"success"` + Conflict bool `json:"conflict,omitempty"` + Error string `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type batchImportParsedResult struct { + collection *Collection + warnings []string + err error +} + +func (cm *CollectionManager) ImportBatch( + paths []string, + overwriteExisting bool, + importOne func(path string) (*Collection, []string, error), +) (BatchImportResult, error) { + if cm == nil { + return BatchImportResult{}, fmt.Errorf("collection manager not initialized") + } + if _, err := cm.GetConfigPath(); err != nil { + return BatchImportResult{}, fmt.Errorf("collection manager configuration unusable: %w", err) + } + if importOne == nil { + return BatchImportResult{}, fmt.Errorf("collection import function not provided") + } + if len(paths) == 0 { + return BatchImportResult{}, fmt.Errorf("no collection import paths provided") + } + + parsed := parseBatchCollectionImports(paths, importOne) + result := BatchImportResult{ + Results: make([]BatchImportItemResult, len(paths)), + } + + for index, item := range parsed { + result.Results[index].Path = paths[index] + if item.err != nil { + result.Results[index].Error = item.err.Error() + continue + } + if item.collection == nil { + result.Results[index].Error = "import returned no collection" + continue + } + + result.Results[index].Name = item.collection.Name + result.Results[index].Warnings = item.warnings + + if item.collection.Name != "" { + exists, err := cm.collectionExists(item.collection.Name) + if err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + err = nil + } + } + if err != nil { + result.Results[index].Error = err.Error() + continue + } + if exists && !overwriteExisting { + result.Results[index].Conflict = true + result.Results[index].Error = fmt.Sprintf("collection %s already exists", item.collection.Name) + continue + } + } + + if err := cm.UpdateCollection(*item.collection); err != nil { + result.Results[index].Error = err.Error() + continue + } + + result.Results[index].Success = true + } + + return result, nil +} + +func parseBatchCollectionImports( + paths []string, + importOne func(path string) (*Collection, []string, error), +) []batchImportParsedResult { + parsed := make([]batchImportParsedResult, len(paths)) + + limit := maxConcurrentCollectionImports + if len(paths) < limit { + limit = len(paths) + } + + jobs := make(chan int) + var wg sync.WaitGroup + wg.Add(limit) + for range limit { + go func() { + defer wg.Done() + for index := range jobs { + path := paths[index] + if path == "" { + parsed[index].err = fmt.Errorf("path is empty") + continue + } + + coll, warnings, err := importOne(path) + parsed[index] = batchImportParsedResult{ + collection: coll, + warnings: warnings, + err: err, + } + } + }() + } + + for index := range paths { + jobs <- index + } + close(jobs) + wg.Wait() + + return parsed +} diff --git a/internal/collection/batch_import_test.go b/internal/collection/batch_import_test.go new file mode 100644 index 00000000..65e68725 --- /dev/null +++ b/internal/collection/batch_import_test.go @@ -0,0 +1,268 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +package collection + +import ( + "fmt" + "strings" + "sync/atomic" + "testing" +) + +func TestImportBatchGlobalErrors(t *testing.T) { + importOne := func(path string) (*Collection, []string, error) { + coll := NewCollection(path) + return &coll, nil, nil + } + + tests := []struct { + name string + manager *CollectionManager + paths []string + importOne func(string) (*Collection, []string, error) + wantError string + }{ + { + name: "nil manager", + manager: nil, + paths: []string{"valid"}, + importOne: importOne, + wantError: "collection manager not initialized", + }, + { + name: "nil import function", + manager: setupTestManager(t), + paths: []string{"valid"}, + importOne: nil, + wantError: "collection import function not provided", + }, + { + name: "empty path list", + manager: setupTestManager(t), + paths: []string{}, + importOne: importOne, + wantError: "no collection import paths provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.manager != nil { + defer cleanupTestDir(tt.manager.config) + } + + result, err := tt.manager.ImportBatch(tt.paths, false, tt.importOne) + if err == nil { + t.Fatalf("expected global error containing %q, got nil", tt.wantError) + } + if !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected global error containing %q, got %q", tt.wantError, err.Error()) + } + if len(result.Results) != 0 { + t.Fatalf("expected no per-item results for global error, got %+v", result.Results) + } + }) + } +} + +func TestImportBatchPerItemResults(t *testing.T) { + cm := setupTestManager(t) + defer cleanupTestDir(cm.config) + + var importCalls atomic.Int32 + importOne := func(path string) (*Collection, []string, error) { + importCalls.Add(1) + + switch path { + case "first": + coll := NewCollection("imported-first") + coll.Requests = []Request{{Name: "first-request"}} + return &coll, []string{"first warning"}, nil + case "bad": + return nil, nil, fmt.Errorf("bad import") + case "nil-collection": + return nil, nil, nil + case "save-error": + return &Collection{Name: ""}, nil, nil + case "last": + coll := NewCollection("imported-last") + coll.Requests = []Request{{Name: "last-request"}} + return &coll, nil, nil + default: + return nil, nil, fmt.Errorf("unexpected path %s", path) + } + } + + paths := []string{"first", "", "bad", "nil-collection", "save-error", "last"} + result, err := cm.ImportBatch(paths, false, importOne) + if err != nil { + t.Fatalf("expected no global error, got %v", err) + } + + if got := int(importCalls.Load()); got != len(paths)-1 { + t.Fatalf("expected empty path to skip import function; got %d calls", got) + } + + if len(result.Results) != len(paths) { + t.Fatalf("expected %d results, got %d", len(paths), len(result.Results)) + } + + for index, path := range paths { + if result.Results[index].Path != path { + t.Fatalf("result %d path = %q, want %q", index, result.Results[index].Path, path) + } + } + + expected := []struct { + path string + name string + success bool + errorSubstr string + warnings []string + }{ + {path: "first", name: "imported-first", success: true, warnings: []string{"first warning"}}, + {path: "", errorSubstr: "path is empty"}, + {path: "bad", errorSubstr: "bad import"}, + {path: "nil-collection", errorSubstr: "import returned no collection"}, + {path: "save-error", errorSubstr: "collection name is not specified"}, + {path: "last", name: "imported-last", success: true}, + } + + for index, want := range expected { + got := result.Results[index] + if got.Path != want.path { + t.Fatalf("result %d path = %q, want %q", index, got.Path, want.path) + } + if got.Name != want.name { + t.Fatalf("result %d name = %q, want %q", index, got.Name, want.name) + } + if got.Success != want.success { + t.Fatalf("result %d success = %v, want %v", index, got.Success, want.success) + } + if want.errorSubstr == "" && got.Error != "" { + t.Fatalf("result %d error = %q, want empty", index, got.Error) + } + if want.errorSubstr != "" && !strings.Contains(got.Error, want.errorSubstr) { + t.Fatalf("result %d error = %q, want substring %q", index, got.Error, want.errorSubstr) + } + if len(got.Warnings) != len(want.warnings) { + t.Fatalf("result %d warnings = %+v, want %+v", index, got.Warnings, want.warnings) + } + for warningIndex, warning := range want.warnings { + if got.Warnings[warningIndex] != warning { + t.Fatalf("result %d warning %d = %q, want %q", index, warningIndex, got.Warnings[warningIndex], warning) + } + } + } + + first, err := cm.LoadCollection("imported-first") + if err != nil { + t.Fatalf("expected first successful import to be saved: %v", err) + } + if len(first.Requests) != 1 || first.Requests[0].Name != "first-request" { + t.Fatalf("unexpected first saved collection requests: %+v", first.Requests) + } + + last, err := cm.LoadCollection("imported-last") + if err != nil { + t.Fatalf("expected later successful import to be saved despite earlier failures: %v", err) + } + if len(last.Requests) != 1 || last.Requests[0].Name != "last-request" { + t.Fatalf("unexpected last saved collection requests: %+v", last.Requests) + } + + for _, name := range []string{"bad", "nil-collection", "save-error"} { + if _, err := cm.LoadCollection(name); err == nil { + t.Fatalf("expected failed import %q not to be saved", name) + } + } +} + +func TestImportBatchExistingCollectionConflict(t *testing.T) { + cm := setupTestManager(t) + defer cleanupTestDir(cm.config) + + existing := NewCollection("existing") + existing.Requests = []Request{{Name: "original-request"}} + if err := cm.UpdateCollection(existing); err != nil { + t.Fatalf("failed to seed existing collection: %v", err) + } + + importOne := func(path string) (*Collection, []string, error) { + coll := NewCollection("existing") + coll.Requests = []Request{{Name: "imported-request"}} + return &coll, nil, nil + } + + result, err := cm.ImportBatch([]string{"conflicting-path"}, false, importOne) + if err != nil { + t.Fatalf("expected no global error, got %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("expected one result, got %+v", result.Results) + } + + item := result.Results[0] + if item.Success { + t.Fatalf("expected conflicting import not to succeed") + } + if !item.Conflict { + t.Fatalf("expected conflicting import to be marked as conflict") + } + if item.Name != "existing" { + t.Fatalf("item name = %q, want existing", item.Name) + } + if !strings.Contains(item.Error, "collection existing already exists") { + t.Fatalf("item error = %q, want existing collection error", item.Error) + } + + loaded, err := cm.LoadCollection("existing") + if err != nil { + t.Fatalf("failed to load existing collection: %v", err) + } + if len(loaded.Requests) != 1 || loaded.Requests[0].Name != "original-request" { + t.Fatalf("expected original collection to remain unchanged, got %+v", loaded.Requests) + } +} + +func TestImportBatchOverwriteExistingCollection(t *testing.T) { + cm := setupTestManager(t) + defer cleanupTestDir(cm.config) + + existing := NewCollection("existing") + existing.Requests = []Request{{Name: "original-request"}} + if err := cm.UpdateCollection(existing); err != nil { + t.Fatalf("failed to seed existing collection: %v", err) + } + + importOne := func(path string) (*Collection, []string, error) { + coll := NewCollection("existing") + coll.Requests = []Request{{Name: "imported-request"}} + return &coll, nil, nil + } + + result, err := cm.ImportBatch([]string{"conflicting-path"}, true, importOne) + if err != nil { + t.Fatalf("expected no global error, got %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("expected one result, got %+v", result.Results) + } + + item := result.Results[0] + if !item.Success { + t.Fatalf("expected overwrite import to succeed, got error %q", item.Error) + } + if item.Conflict { + t.Fatalf("expected overwrite import not to be marked as conflict") + } + + loaded, err := cm.LoadCollection("existing") + if err != nil { + t.Fatalf("failed to load overwritten collection: %v", err) + } + if len(loaded.Requests) != 1 || loaded.Requests[0].Name != "imported-request" { + t.Fatalf("expected collection to be overwritten, got %+v", loaded.Requests) + } +} diff --git a/internal/importer/bruno_importer.go b/internal/importer/bruno_importer.go index c02ac14b..b8814182 100644 --- a/internal/importer/bruno_importer.go +++ b/internal/importer/bruno_importer.go @@ -116,6 +116,7 @@ func parseBruFile(filePath string, basePath string) (*collection.Request, []stri isHTTPRequest := false queryMap := make(map[string]interface{}) + pathParamMap := make(map[string]string) formUrlEncodedMap := make(map[string]interface{}) multipartFormMap := make(map[string]interface{}) @@ -136,10 +137,14 @@ func parseBruFile(filePath string, basePath string) (*collection.Request, []stri currentSection = "headers" isBodyBlock = false continue - } else if strings.HasPrefix(line, "query {") { + } else if strings.HasPrefix(line, "query {") || strings.HasPrefix(line, "params:query {") { currentSection = "query" isBodyBlock = false continue + } else if strings.HasPrefix(line, "params:path {") { + currentSection = "params:path" + isBodyBlock = false + continue } else if strings.HasPrefix(line, "body:json {") { currentSection = "body" req.BodyType = "json" @@ -204,6 +209,8 @@ func parseBruFile(filePath string, basePath string) (*collection.Request, []stri req.Headers[key] = value case "query": queryMap[key] = value + case "params:path": + pathParamMap[key] = value case "body:form-urlencoded": formUrlEncodedMap[key] = value case "body:multipart-form": @@ -212,13 +219,12 @@ func parseBruFile(filePath string, basePath string) (*collection.Request, []stri } // Post-processing + for key, value := range pathParamMap { + req.Url = strings.ReplaceAll(req.Url, ":"+key, value) + } + if len(queryMap) > 0 { - qs := objectToQueryString(queryMap) - if strings.Contains(req.Url, "?") { - req.Url += "&" + qs - } else { - req.Url += "?" + qs - } + req.Url = appendMissingQueryParams(req.Url, queryMap) } if len(formUrlEncodedMap) > 0 { @@ -281,6 +287,46 @@ func indexFolderByName(folders []collection.Folder, name string) int { return -1 } +func appendMissingQueryParams(rawURL string, queryMap map[string]interface{}) string { + missing := make(map[string]interface{}, len(queryMap)) + for key, value := range queryMap { + if !urlHasQueryParam(rawURL, key) { + missing[key] = value + } + } + if len(missing) == 0 { + return rawURL + } + + qs := objectToQueryString(missing) + if strings.Contains(rawURL, "?") { + return rawURL + "&" + qs + } + + return rawURL + "?" + qs +} + +func urlHasQueryParam(rawURL, key string) bool { + queryStart := strings.Index(rawURL, "?") + if queryStart == -1 { + return false + } + + query := rawURL[queryStart+1:] + if fragmentStart := strings.Index(query, "#"); fragmentStart != -1 { + query = query[:fragmentStart] + } + + for part := range strings.SplitSeq(query, "&") { + paramKey, _, _ := strings.Cut(part, "=") + if paramKey == key { + return true + } + } + + return false +} + func findFolderByPath(folders *[]collection.Folder, folderNames []string) *collection.Folder { currentFolders := folders var current *collection.Folder diff --git a/internal/importer/bruno_importer_test.go b/internal/importer/bruno_importer_test.go index 136cb4a7..dd0557a1 100644 --- a/internal/importer/bruno_importer_test.go +++ b/internal/importer/bruno_importer_test.go @@ -185,3 +185,118 @@ get { t.Fatalf("Expected request 'Repository Info', got '%s'", coll.Folders[0].Requests[0].Name) } } + +func TestBrunoImporter_Import_ParsesBrunoParamsSections(t *testing.T) { + importer := NewBrunoImporter() + testDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(testDir, "bruno.json"), []byte(`{"name":"Bruno Params","type":"collection","version":"1"}`), 0644); err != nil { + t.Fatalf("Failed to write bruno.json: %v", err) + } + + if err := os.MkdirAll(filepath.Join(testDir, "MusicBrainz"), 0755); err != nil { + t.Fatalf("Failed to create MusicBrainz directory: %v", err) + } + + if err := os.WriteFile(filepath.Join(testDir, "MusicBrainz", "Artist Finder.bru"), []byte(`meta { + name: Artist Finder + type: http + seq: 1 +} + +get { + url: https://musicbrainz.org/ws/2/artist?query=Green&limit=5 + body: none + auth: none +} + +params:query { + query: Green + limit: 5 +} +`), 0644); err != nil { + t.Fatalf("Failed to write Artist Finder.bru: %v", err) + } + + if err := os.MkdirAll(filepath.Join(testDir, "TestDirParams"), 0755); err != nil { + t.Fatalf("Failed to create TestDirParams directory: %v", err) + } + + if err := os.WriteFile(filepath.Join(testDir, "TestDirParams", "TestParams.bru"), []byte(`meta { + name: TestParams + type: http + seq: 1 +} + +get { + url: https://musicbrainz.org/ws/2/artist/:artistId?fmt=json + body: none + auth: none +} + +params:query { + fmt: json +} + +params:path { + artistId: 084308bd-1654-436f-ba03-df6697104e19 +} +`), 0644); err != nil { + t.Fatalf("Failed to write TestParams.bru: %v", err) + } + + coll, err := importer.Import(testDir) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + testDirParams := findFolderByName(coll.Folders, "TestDirParams") + if testDirParams == nil { + t.Fatal("Expected folder 'TestDirParams' not found") + } + + req := findRequestByName(testDirParams.Requests, "TestParams") + if req == nil { + t.Fatal("Request 'TestParams' not found") + } + + expectedURL := "https://musicbrainz.org/ws/2/artist/084308bd-1654-436f-ba03-df6697104e19?fmt=json" + if req.Url != expectedURL { + t.Fatalf("Expected URL %q, got %q", expectedURL, req.Url) + } + + musicBrainzFolder := findFolderByName(coll.Folders, "MusicBrainz") + if musicBrainzFolder == nil { + t.Fatal("Expected folder 'MusicBrainz' not found") + } + + artistFinder := findRequestByName(musicBrainzFolder.Requests, "Artist Finder") + if artistFinder == nil { + t.Fatal("Request 'Artist Finder' not found") + } + + expectedArtistURL := "https://musicbrainz.org/ws/2/artist?query=Green&limit=5" + if artistFinder.Url != expectedArtistURL { + t.Fatalf("Expected URL %q, got %q", expectedArtistURL, artistFinder.Url) + } +} + +func findFolderByName(folders []collection.Folder, name string) *collection.Folder { + for i := range folders { + if folders[i].Name == name { + return &folders[i] + } + } + + return nil +} + +func findRequestByName(requests []collection.Request, name string) *collection.Request { + for i := range requests { + if requests[i].Name == name { + return &requests[i] + } + } + + return nil +}