diff --git a/src/ProjectMgmt/Domain/Service/ProjectService.php b/src/ProjectMgmt/Domain/Service/ProjectService.php index 241d631b..149a16a5 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -79,6 +79,33 @@ public function create( return $project; } + public function cloneProject(Project $sourceProject, string $name): Project + { + return $this->create( + $sourceProject->getOrganizationId(), + $name, + $sourceProject->getGitUrl(), + $sourceProject->getGithubToken(), + $sourceProject->getContentEditingLlmModelProvider(), + $sourceProject->getContentEditingLlmModelProviderApiKey(), + $sourceProject->getProjectType(), + $sourceProject->getAgentImage(), + $sourceProject->getAgentBackgroundInstructions(), + $sourceProject->getAgentStepInstructions(), + $sourceProject->getAgentOutputInstructions(), + $sourceProject->getRemoteContentAssetsManifestUrls(), + $sourceProject->getS3BucketName(), + $sourceProject->getS3Region(), + $sourceProject->getS3AccessKeyId(), + $sourceProject->getS3SecretAccessKey(), + $sourceProject->getS3IamRoleArn(), + $sourceProject->getS3KeyPrefix(), + $sourceProject->isKeysVisible(), + $sourceProject->getPhotoBuilderLlmModelProvider(), + $sourceProject->getPhotoBuilderLlmModelProviderApiKey(), + ); + } + /** * @param list|null $remoteContentAssetsManifestUrls */ diff --git a/src/ProjectMgmt/Presentation/Controller/ProjectController.php b/src/ProjectMgmt/Presentation/Controller/ProjectController.php index 0ad6e7dc..5518db16 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -8,6 +8,7 @@ use App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface; +use App\ProjectMgmt\Domain\Entity\Project; use App\ProjectMgmt\Domain\Service\ProjectService; use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto; use App\ProjectMgmt\Facade\Enum\ProjectType; @@ -233,6 +234,65 @@ public function create(Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.list'); } + #[Route( + path: '/projects/{id}/clone', + name: 'project_mgmt.presentation.clone', + methods: [Request::METHOD_GET], + requirements: ['id' => '[a-f0-9-]{36}'] + )] + public function showCloneForm(string $id): Response + { + $organizationId = $this->getActiveOrganizationId(); + if ($organizationId === null) { + $this->addFlash('error', $this->translator->trans('flash.error.no_organization')); + + return $this->redirectToRoute('account.presentation.dashboard'); + } + + $sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId); + + return $this->render('@project_mgmt.presentation/project_clone_form.twig', [ + 'sourceProject' => $sourceProject, + 'suggestedProjectName' => $this->buildCloneProjectName($sourceProject->getName()), + ]); + } + + #[Route( + path: '/projects/{id}/clone', + name: 'project_mgmt.presentation.clone_submit', + methods: [Request::METHOD_POST], + requirements: ['id' => '[a-f0-9-]{36}'] + )] + public function cloneSubmit(string $id, Request $request): Response + { + $organizationId = $this->getActiveOrganizationId(); + if ($organizationId === null) { + $this->addFlash('error', $this->translator->trans('flash.error.no_organization')); + + return $this->redirectToRoute('account.presentation.dashboard'); + } + + $sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId); + + if (!$this->isCsrfTokenValid('project_clone_' . $id, $request->request->getString('_csrf_token'))) { + $this->addFlash('error', $this->translator->trans('flash.error.invalid_csrf')); + + return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]); + } + + $name = trim($request->request->getString('name')); + if ($name === '') { + $this->addFlash('error', $this->translator->trans('flash.error.project_clone_name_required')); + + return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]); + } + + $clonedProject = $this->projectService->cloneProject($sourceProject, $name); + $this->addFlash('success', $this->translator->trans('flash.success.project_cloned', ['%name%' => $clonedProject->getName()])); + + return $this->redirectToRoute('project_mgmt.presentation.list'); + } + #[Route( path: '/projects/{id}/edit', name: 'project_mgmt.presentation.edit', @@ -687,6 +747,22 @@ private function nullIfEmpty(string $value): ?string return $value === '' ? null : $value; } + private function buildCloneProjectName(string $sourceProjectName): string + { + return $this->translator->trans('project.clone.default_name', ['%name%' => $sourceProjectName]); + } + + private function getAccessibleProjectOrThrowNotFound(string $projectId, string $organizationId): Project + { + $project = $this->projectService->findById($projectId); + + if ($project === null || $project->isDeleted() || $project->getOrganizationId() !== $organizationId) { + throw $this->createNotFoundException('Project not found.'); + } + + return $project; + } + /** * Parse remote content assets manifest URLs from request (textarea, one URL per line). * Returns only valid http/https URLs; invalid lines are skipped. diff --git a/src/ProjectMgmt/Presentation/Resources/templates/project_clone_form.twig b/src/ProjectMgmt/Presentation/Resources/templates/project_clone_form.twig new file mode 100644 index 00000000..1aaab8d5 --- /dev/null +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_clone_form.twig @@ -0,0 +1,63 @@ +{% extends '@common.presentation/base_appshell.html.twig' %} + +{% block title %}{{ 'project.clone.title'|trans }}{% endblock %} + +{% block content %} +
+
+

{{ 'project.clone.heading'|trans }}

+

+ {{ 'project.clone.description'|trans({'%name%': sourceProject.name}) }} +

+
+ + {% for message in app.flashes('error') %} +
+

{{ message }}

+
+ {% endfor %} + +
+ + +
+ + +
+ +
+

+ {{ 'project.clone.source_project'|trans }} + {{ sourceProject.name }} +

+

+ {{ sourceProject.gitUrl }} +

+
+ +
+ + {{ 'common.cancel'|trans }} + + +
+
+
+{% endblock content %} diff --git a/src/ProjectMgmt/Presentation/Resources/templates/project_list.twig b/src/ProjectMgmt/Presentation/Resources/templates/project_list.twig index c122bf3e..3d8eb432 100644 --- a/src/ProjectMgmt/Presentation/Resources/templates/project_list.twig +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_list.twig @@ -114,6 +114,11 @@ class="inline-flex items-center px-3 py-1.5 rounded-md border border-dark-300 dark:border-dark-600 text-dark-700 dark:text-dark-300 text-sm font-medium hover:bg-dark-100 dark:hover:bg-dark-700"> {{ 'project.list.edit_details'|trans }} + + {{ 'project.list.clone_project'|trans }} + {% if item.workspace %}
client = static::createClient(); + $container = static::getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + $this->entityManager = $entityManager; + + /** @var AccountDomainService $accountDomainService */ + $accountDomainService = $container->get(AccountDomainService::class); + $this->accountDomainService = $accountDomainService; + + /** @var AccountFacadeInterface $accountFacade */ + $accountFacade = $container->get(AccountFacadeInterface::class); + $this->accountFacade = $accountFacade; + } + + public function testProjectListShowsCloneAction(): void + { + $user = $this->createTestUser('clone-list-' . uniqid() . '@example.com', 'password123'); + $organizationId = $this->getOrganizationIdForUser($user); + $project = $this->createBasicProject($organizationId, 'Project To Clone'); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $this->loginAsUser($this->client, $user); + $crawler = $this->client->request('GET', '/en/projects'); + + self::assertResponseIsSuccessful(); + self::assertGreaterThan( + 0, + $crawler->filter('a[href="/en/projects/' . $projectId . '/clone"][data-test-class="project-list-clone-link"]')->count() + ); + } + + public function testCloneFormShowsSuggestedNameForSourceProject(): void + { + $user = $this->createTestUser('clone-form-' . uniqid() . '@example.com', 'password123'); + $organizationId = $this->getOrganizationIdForUser($user); + $project = $this->createBasicProject($organizationId, 'Source Website'); + + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $this->loginAsUser($this->client, $user); + $crawler = $this->client->request('GET', '/en/projects/' . $projectId . '/clone'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('[data-test-id="project-clone-form"]'); + self::assertSame('Copy of Source Website', $crawler->filter('[data-test-id="project-clone-name"]')->attr('value')); + } + + public function testCloneCopiesProjectSettingsAndDoesNotCopyWorkspace(): void + { + $user = $this->createTestUser('clone-happy-' . uniqid() . '@example.com', 'password123'); + $organizationId = $this->getOrganizationIdForUser($user); + $sourceProject = $this->createConfiguredProject($organizationId, 'Configured Source Project'); + + $sourceProjectId = $sourceProject->getId(); + self::assertNotNull($sourceProjectId); + + $sourceWorkspace = new Workspace($sourceProjectId); + $sourceWorkspace->setStatus(WorkspaceStatus::IN_REVIEW); + $sourceWorkspace->setBranchName('feature/original-work'); + $sourceWorkspace->setPullRequestUrl('https://github.com/org/repo/pull/123'); + $this->entityManager->persist($sourceWorkspace); + $this->entityManager->flush(); + + $this->loginAsUser($this->client, $user); + $crawler = $this->client->request('GET', '/en/projects/' . $sourceProjectId . '/clone'); + self::assertResponseIsSuccessful(); + + $form = $crawler->filter('[data-test-id="project-clone-form"]')->form([ + 'name' => 'Cloned Project', + ]); + $this->client->submit($form); + + self::assertResponseRedirects('/en/projects'); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'cloned successfully'); + + $this->entityManager->clear(); + + /** @var Project|null $clonedProject */ + $clonedProject = $this->entityManager->getRepository(Project::class)->findOneBy([ + 'organizationId' => $organizationId, + 'name' => 'Cloned Project', + ]); + + self::assertNotNull($clonedProject); + self::assertNotSame($sourceProjectId, $clonedProject->getId()); + self::assertSame($organizationId, $clonedProject->getOrganizationId()); + self::assertSame($sourceProject->getGitUrl(), $clonedProject->getGitUrl()); + self::assertSame($sourceProject->getGithubToken(), $clonedProject->getGithubToken()); + self::assertSame($sourceProject->getContentEditingLlmModelProvider(), $clonedProject->getContentEditingLlmModelProvider()); + self::assertSame($sourceProject->getContentEditingLlmModelProviderApiKey(), $clonedProject->getContentEditingLlmModelProviderApiKey()); + self::assertSame($sourceProject->getAgentImage(), $clonedProject->getAgentImage()); + self::assertSame($sourceProject->getAgentBackgroundInstructions(), $clonedProject->getAgentBackgroundInstructions()); + self::assertSame($sourceProject->getAgentStepInstructions(), $clonedProject->getAgentStepInstructions()); + self::assertSame($sourceProject->getAgentOutputInstructions(), $clonedProject->getAgentOutputInstructions()); + self::assertSame($sourceProject->getRemoteContentAssetsManifestUrls(), $clonedProject->getRemoteContentAssetsManifestUrls()); + self::assertSame($sourceProject->getS3BucketName(), $clonedProject->getS3BucketName()); + self::assertSame($sourceProject->getS3Region(), $clonedProject->getS3Region()); + self::assertSame($sourceProject->getS3AccessKeyId(), $clonedProject->getS3AccessKeyId()); + self::assertSame($sourceProject->getS3SecretAccessKey(), $clonedProject->getS3SecretAccessKey()); + self::assertSame($sourceProject->getS3IamRoleArn(), $clonedProject->getS3IamRoleArn()); + self::assertSame($sourceProject->getS3KeyPrefix(), $clonedProject->getS3KeyPrefix()); + self::assertSame($sourceProject->isKeysVisible(), $clonedProject->isKeysVisible()); + self::assertSame($sourceProject->getPhotoBuilderLlmModelProvider(), $clonedProject->getPhotoBuilderLlmModelProvider()); + self::assertSame($sourceProject->getPhotoBuilderLlmModelProviderApiKey(), $clonedProject->getPhotoBuilderLlmModelProviderApiKey()); + self::assertFalse($clonedProject->isDeleted()); + + $clonedProjectId = $clonedProject->getId(); + self::assertNotNull($clonedProjectId); + self::assertNull( + $this->entityManager->getRepository(Workspace::class)->findOneBy(['projectId' => $clonedProjectId]) + ); + } + + public function testCloneReturnsNotFoundForProjectFromAnotherOrganization(): void + { + $ownerUser = $this->createTestUser('clone-owner-' . uniqid() . '@example.com', 'password123'); + $ownerOrganizationId = $this->getOrganizationIdForUser($ownerUser); + $sourceProject = $this->createBasicProject($ownerOrganizationId, 'Owner Project'); + + $otherUser = $this->createTestUser('clone-other-' . uniqid() . '@example.com', 'password123'); + $this->loginAsUser($this->client, $otherUser); + + $projectId = $sourceProject->getId(); + self::assertNotNull($projectId); + + $this->client->request('GET', '/en/projects/' . $projectId . '/clone'); + self::assertResponseStatusCodeSame(404); + + $this->client->request('POST', '/en/projects/' . $projectId . '/clone', [ + '_csrf_token' => 'dummy-token', + 'name' => 'Cross Org Clone', + ]); + self::assertResponseStatusCodeSame(404); + } + + public function testCloneReturnsNotFoundForDeletedSourceProject(): void + { + $user = $this->createTestUser('clone-deleted-' . uniqid() . '@example.com', 'password123'); + $organizationId = $this->getOrganizationIdForUser($user); + $sourceProject = $this->createBasicProject($organizationId, 'Deleted Source'); + $sourceProject->markAsDeleted(); + $this->entityManager->flush(); + + $projectId = $sourceProject->getId(); + self::assertNotNull($projectId); + + $this->loginAsUser($this->client, $user); + $this->client->request('GET', '/en/projects/' . $projectId . '/clone'); + self::assertResponseStatusCodeSame(404); + + $this->client->request('POST', '/en/projects/' . $projectId . '/clone', [ + '_csrf_token' => 'dummy-token', + 'name' => 'Clone Attempt', + ]); + self::assertResponseStatusCodeSame(404); + } + + public function testCloneWithEmptyNameShowsValidationAndDoesNotCreateProject(): void + { + $user = $this->createTestUser('clone-empty-name-' . uniqid() . '@example.com', 'password123'); + $organizationId = $this->getOrganizationIdForUser($user); + $sourceProject = $this->createBasicProject($organizationId, 'Source Name Validation'); + + $projectId = $sourceProject->getId(); + self::assertNotNull($projectId); + + $this->loginAsUser($this->client, $user); + $crawler = $this->client->request('GET', '/en/projects/' . $projectId . '/clone'); + self::assertResponseIsSuccessful(); + + $form = $crawler->filter('[data-test-id="project-clone-form"]')->form([ + 'name' => ' ', + ]); + $this->client->submit($form); + + self::assertResponseRedirects('/en/projects/' . $projectId . '/clone'); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'Please enter a name for the cloned project.'); + + $this->entityManager->clear(); + self::assertNull($this->entityManager->getRepository(Project::class)->findOneBy([ + 'organizationId' => $organizationId, + 'name' => ' ', + ])); + } + + private function createTestUser(string $email, string $plainPassword): AccountCore + { + return $this->accountDomainService->register($email, $plainPassword); + } + + private function getOrganizationIdForUser(AccountCore $user): string + { + $userId = $user->getId(); + self::assertNotNull($userId); + + $organizationId = $this->accountFacade->getCurrentlyActiveOrganizationIdForAccountCore($userId); + self::assertNotNull($organizationId, 'User should have an organization after registration'); + + return $organizationId; + } + + private function createBasicProject(string $organizationId, string $name): Project + { + $project = new Project( + $organizationId, + $name, + 'https://github.com/test/repo.git', + 'ghp_testtoken123', + LlmModelProvider::OpenAI, + 'sk-test-key-123' + ); + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return $project; + } + + private function createConfiguredProject(string $organizationId, string $name): Project + { + $project = new Project( + $organizationId, + $name, + 'https://github.com/source/repository.git', + 'ghp_source_token', + LlmModelProvider::OpenAI, + 'sk-source-api-key', + ProjectType::DEFAULT, + 'python:3.12-slim', + 'Background instructions', + 'Step instructions', + 'Output instructions', + ['https://cdn.example.com/manifest.json'] + ); + $project->setS3BucketName('source-bucket'); + $project->setS3Region('eu-central-1'); + $project->setS3AccessKeyId('AKIA_SOURCE'); + $project->setS3SecretAccessKey('source-secret'); + $project->setS3IamRoleArn('arn:aws:iam::123456789012:role/SourceRole'); + $project->setS3KeyPrefix('assets/source'); + $project->setKeysVisible(false); + $project->setPhotoBuilderLlmModelProvider(LlmModelProvider::OpenAI); + $project->setPhotoBuilderLlmModelProviderApiKey('sk-photo-builder'); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return $project; + } +} diff --git a/tests/Unit/ProjectMgmt/ProjectServiceTest.php b/tests/Unit/ProjectMgmt/ProjectServiceTest.php index b99c5913..6a2686f6 100644 --- a/tests/Unit/ProjectMgmt/ProjectServiceTest.php +++ b/tests/Unit/ProjectMgmt/ProjectServiceTest.php @@ -63,6 +63,61 @@ public function testCreateStoresRemoteContentAssetsManifestUrlsWhenProvided(): v self::assertSame($manifestUrls, $project->getRemoteContentAssetsManifestUrls()); } + public function testCloneProjectCopiesConfigurationWithoutCopyingIdentityFields(): void + { + $sourceProject = $this->createProject( + 'source-project-id', + 'Source Project', + 'https://github.com/org/source.git', + 'gh-source-token' + ); + $sourceProject->setAgentImage('python:3.12-slim'); + $sourceProject->setAgentBackgroundInstructions('Background instructions'); + $sourceProject->setAgentStepInstructions('Step instructions'); + $sourceProject->setAgentOutputInstructions('Output instructions'); + $sourceProject->setRemoteContentAssetsManifestUrls(['https://cdn.example.com/manifest.json']); + $sourceProject->setS3BucketName('bucket-name'); + $sourceProject->setS3Region('eu-central-1'); + $sourceProject->setS3AccessKeyId('AKIAEXAMPLE'); + $sourceProject->setS3SecretAccessKey('secret-example'); + $sourceProject->setS3IamRoleArn('arn:aws:iam::123456789012:role/SiteBuilder'); + $sourceProject->setS3KeyPrefix('uploads/project'); + $sourceProject->setKeysVisible(false); + $sourceProject->setPhotoBuilderLlmModelProvider(LlmModelProvider::OpenAI); + $sourceProject->setPhotoBuilderLlmModelProviderApiKey('photo-builder-key'); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once())->method('persist'); + $entityManager->expects($this->once())->method('flush'); + + $service = new ProjectService($entityManager); + $clonedProject = $service->cloneProject($sourceProject, 'Cloned Project'); + + self::assertNotSame($sourceProject, $clonedProject); + self::assertSame('org-123', $clonedProject->getOrganizationId()); + self::assertSame('Cloned Project', $clonedProject->getName()); + self::assertSame($sourceProject->getGitUrl(), $clonedProject->getGitUrl()); + self::assertSame($sourceProject->getGithubToken(), $clonedProject->getGithubToken()); + self::assertSame($sourceProject->getContentEditingLlmModelProvider(), $clonedProject->getContentEditingLlmModelProvider()); + self::assertSame($sourceProject->getContentEditingLlmModelProviderApiKey(), $clonedProject->getContentEditingLlmModelProviderApiKey()); + self::assertSame($sourceProject->getAgentImage(), $clonedProject->getAgentImage()); + self::assertSame($sourceProject->getAgentBackgroundInstructions(), $clonedProject->getAgentBackgroundInstructions()); + self::assertSame($sourceProject->getAgentStepInstructions(), $clonedProject->getAgentStepInstructions()); + self::assertSame($sourceProject->getAgentOutputInstructions(), $clonedProject->getAgentOutputInstructions()); + self::assertSame($sourceProject->getRemoteContentAssetsManifestUrls(), $clonedProject->getRemoteContentAssetsManifestUrls()); + self::assertSame($sourceProject->getS3BucketName(), $clonedProject->getS3BucketName()); + self::assertSame($sourceProject->getS3Region(), $clonedProject->getS3Region()); + self::assertSame($sourceProject->getS3AccessKeyId(), $clonedProject->getS3AccessKeyId()); + self::assertSame($sourceProject->getS3SecretAccessKey(), $clonedProject->getS3SecretAccessKey()); + self::assertSame($sourceProject->getS3IamRoleArn(), $clonedProject->getS3IamRoleArn()); + self::assertSame($sourceProject->getS3KeyPrefix(), $clonedProject->getS3KeyPrefix()); + self::assertSame($sourceProject->isKeysVisible(), $clonedProject->isKeysVisible()); + self::assertSame($sourceProject->getPhotoBuilderLlmModelProvider(), $clonedProject->getPhotoBuilderLlmModelProvider()); + self::assertSame($sourceProject->getPhotoBuilderLlmModelProviderApiKey(), $clonedProject->getPhotoBuilderLlmModelProviderApiKey()); + self::assertNull($clonedProject->getId()); + self::assertFalse($clonedProject->isDeleted()); + } + public function testUpdateUpdatesAllProjectAttributes(): void { $project = $this->createProject('proj-1', 'Old Name', 'https://old.git', 'old-token'); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index a3d032b0..b9aa77e9 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -77,6 +77,7 @@ project: view_conversation: "Konversation ansehen" view_conversation_for_review: "Konversation wg. Review ansehen" edit_details: "Details bearbeiten" + clone_project: "Projekt klonen" reset_work_area: "Arbeitsbereich zurücksetzen" reset_confirm: "Arbeitsbereich zurücksetzen? Dies beendet alle aktiven Sitzungen und erstellt beim nächsten Mal einen neuen Arbeitsbereich." delete_confirm: "Dieses Projekt löschen? Es wird aus der Liste entfernt." @@ -86,6 +87,15 @@ project: restore: "Wiederherstellen" delete_permanently: "Endgültig löschen" delete_permanently_confirm: "Dieses Projekt endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden und entfernt alle zugehörigen Daten." + clone: + title: "Projekt klonen" + heading: "Projekt klonen" + description: 'Erstellen Sie ein neues Projekt, indem Sie alle Einstellungen von "%name%" übernehmen.' + default_name: "Kopie von %name%" + project_name: "Neuer Projektname" + project_name_placeholder: "Kopie von Meine Website" + source_project: "Quellprojekt:" + submit: "Projekt klonen" form: title_add: "Projekt hinzufügen" title_edit: "Projekt bearbeiten" @@ -377,6 +387,7 @@ flash: error: invalid_csrf: "Ungültiges CSRF-Token." all_fields_required: "Alle Felder sind erforderlich." + project_clone_name_required: "Bitte geben Sie einen Namen für das geklonte Projekt ein." select_llm_provider: "Bitte wählen Sie einen LLM-Modellanbieter." invalid_docker_image: "Ungültiges Docker-Image-Format. Erwartetes Format: name:tag" no_workspace: "Für dieses Projekt existiert kein Arbeitsbereich." @@ -393,6 +404,7 @@ flash: workspace_busy: "%email% arbeitet gerade an diesem Arbeitsbereich." success: project_created: "Projekt erfolgreich erstellt." + project_cloned: 'Projekt "%name%" erfolgreich geklont.' project_updated: "Projekt erfolgreich aktualisiert." project_deleted: 'Projekt "%name%" erfolgreich gelöscht.' project_permanently_deleted: 'Projekt "%name%" endgültig gelöscht.' diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b4d68c09..a0747a26 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -77,6 +77,7 @@ project: view_conversation: "View conversation" view_conversation_for_review: "Review conversation" edit_details: "Edit details" + clone_project: "Clone project" reset_work_area: "Reset work area" reset_confirm: "Reset this work area? This will end any active sessions and create a fresh work area next time." delete_confirm: "Delete this project? It will be removed from the list." @@ -86,6 +87,15 @@ project: restore: "Restore" delete_permanently: "Delete permanently" delete_permanently_confirm: "Permanently delete this project? This action cannot be undone and will remove all associated data." + clone: + title: "Clone project" + heading: "Clone project" + description: 'Create a new project by copying all settings from "%name%".' + default_name: "Copy of %name%" + project_name: "New project name" + project_name_placeholder: "Copy of My Website" + source_project: "Source project:" + submit: "Clone project" form: title_add: "Add project" title_edit: "Edit project" @@ -377,6 +387,7 @@ flash: error: invalid_csrf: "Invalid CSRF token." all_fields_required: "All fields are required." + project_clone_name_required: "Please enter a name for the cloned project." select_llm_provider: "Please select an LLM model provider." invalid_docker_image: "Invalid Docker image format. Expected format: name:tag" no_workspace: "No workspace exists for this project." @@ -393,6 +404,7 @@ flash: workspace_busy: "%email% is currently working on this workspace." success: project_created: "Project created successfully." + project_cloned: 'Project "%name%" cloned successfully.' project_updated: "Project updated successfully." project_deleted: 'Project "%name%" deleted successfully.' project_permanently_deleted: 'Project "%name%" permanently deleted.'