diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 952d0b195..2a1765087 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::auth::Claims; use crate::{Error, Result}; +use anyhow::Context; use rand::{distributions::Alphanumeric, Rng}; use serde_json::Number; use std::collections::HashSet; @@ -381,7 +382,7 @@ pub async fn list_projects( #[axum_macros::debug_handler] pub async fn duplicate_project( Extension(pool): Extension, - Extension(_client): Extension, + Extension(client): Extension, claims: Claims, Json(project): Json, ) -> Result> { @@ -394,7 +395,7 @@ pub async fn duplicate_project( } // Create project - let duplicate = Project { + let mut duplicate = Project { id: Uuid::new_v4(), title: project.title, description: project.description, @@ -410,30 +411,40 @@ pub async fn duplicate_project( geometries: project.geometries, }; - // // TODO: make static - // let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); - - // for asset in &project.assets { - // let url = Url::parse(asset.href.as_str()).context("Failed to parse asset url")?; - // let path = &url.path()[1..]; - // let name_opt = path.split('/').last(); - // if !path.is_empty() && name_opt.is_some() { - // let new_path = format!("assets/{}/{}", project.id, name_opt.unwrap()); - // client - // .copy_object() - // .copy_source(format!("{}/{}", &bucket, &path)) - // .bucket(&bucket) - // .key(&new_path) - // .send() - // .await - // .context("Failed to copy object")?; - // assets.push(Asset { - // href: format!("https://download.swissgeol.ch/{new_path}"), - // }); - // } - // } - - // duplicate.assets = assets; + let mut assets: Vec = Vec::new(); + let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); + + for asset in &project.assets { + let generated_file_name: String = generate_asset_name(); + let asset_key = format!("assets/saved/{}", asset.key); + let dest_key = format!("assets/saved/{}", generated_file_name); + // Check if the file exists in the source directory + let source_exists = client + .head_object() + .bucket(&bucket) + .key(&asset_key) + .send() + .await + .is_ok(); + + if source_exists { + client + .copy_object() + .copy_source(format!("{}/{}", &bucket, &asset_key)) + .bucket(&bucket) + .key(&dest_key) + .send() + .await + .context("Failed to copy object")?; + + assets.push(Asset { + name: asset.name.clone(), + key: generated_file_name, + }); + } + } + + duplicate.assets = assets; let result = sqlx::query_scalar!( "INSERT INTO projects (id, project) VALUES ($1, $2) RETURNING id", @@ -452,12 +463,7 @@ pub async fn upload_asset( mut multipart: Multipart, ) -> Result> { let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); - let rand_string: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(40) - .map(char::from) - .collect(); - let generated_file_name: String = format!("{}_{}.kml", Utc::now().timestamp(), rand_string); + let generated_file_name: String = generate_asset_name(); let temp_name = format!("assets/temp/{}", generated_file_name); while let Some(field) = multipart.next_field().await.unwrap() { if field.name() == Some("file") { @@ -549,3 +555,12 @@ async fn delete_assets(client: Client, project_assets: &Vec) { } } } + +fn generate_asset_name() -> String { + let rand_string: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(40) + .map(char::from) + .collect(); + return format!("{}_{}.kml", Utc::now().timestamp(), rand_string); +} diff --git a/ui/locales/app.en.json b/ui/locales/app.en.json index 303356ccd..b8fac3522 100644 --- a/ui/locales/app.en.json +++ b/ui/locales/app.en.json @@ -277,7 +277,7 @@ "tbx_point_depth_label": "Depth (m BGL)", "tbx_profile": "Topo Profile", "tbx_profile_btn": "Show height profile", - "tbx_project_geometries": "Project Geometries", + "tbx_project_geometries": "Project", "tbx_remove_btn_label": "Remove", "tbx_request_aborted_warning": "Request aborted", "tbx_save_editing_btn_label": "Save", diff --git a/ui/src/elements/dashboard/helpers.ts b/ui/src/elements/dashboard/helpers.ts new file mode 100644 index 000000000..ffb604c83 --- /dev/null +++ b/ui/src/elements/dashboard/helpers.ts @@ -0,0 +1,6 @@ +import {CreateProject, Project, Topic} from './ngm-dashboard'; + +export function isProject(projectOrTopic: Project | CreateProject | Topic | undefined): projectOrTopic is Project { + const project = projectOrTopic; + return !!project?.owner && !!project?.created; +} \ No newline at end of file diff --git a/ui/src/elements/dashboard/ngm-dashboard.ts b/ui/src/elements/dashboard/ngm-dashboard.ts index 12ce14c4a..49181ab2e 100644 --- a/ui/src/elements/dashboard/ngm-dashboard.ts +++ b/ui/src/elements/dashboard/ngm-dashboard.ts @@ -22,6 +22,7 @@ import AuthStore from '../../store/auth'; import '../hide-overflow'; import './ngm-project-edit'; import './ngm-project-topic-overview'; +import {isProject} from './helpers'; type TextualAttribute = string | TranslatedText; @@ -349,8 +350,8 @@ export class NgmDashboard extends LitElementI18n { } async onProjectSave(project: Project | CreateProject) { - if (this.projectMode === 'edit') { - await apiClient.updateProject(project); + if (this.projectMode === 'edit' && isProject(project)) { + await apiClient.updateProject(project); this.projectMode = 'view'; } else if (this.projectMode === 'create' && this.projectToCreate) { try { diff --git a/ui/src/elements/dashboard/ngm-project-edit.ts b/ui/src/elements/dashboard/ngm-project-edit.ts index b1e2ed260..5b8f0bbb0 100644 --- a/ui/src/elements/dashboard/ngm-project-edit.ts +++ b/ui/src/elements/dashboard/ngm-project-edit.ts @@ -14,6 +14,7 @@ import {showSnackbarError} from '../../notifications'; import './ngm-project-geoms-section'; import './ngm-project-assets-section'; import {MemberToAdd} from './ngm-add-member-form'; +import {isProject} from './helpers'; @customElement('ngm-project-edit') export class NgmProjectEdit extends LitElementI18n { @@ -110,9 +111,10 @@ export class NgmProjectEdit extends LitElementI18n { -
- ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString((project).modified)} ${i18next.t('dashboard_by_swisstopo_title')}`} -
+ ${this.createMode || !isProject(project) ? '' : html` +
+ ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString(project.modified)} ${i18next.t('dashboard_by_swisstopo_title')}`} +
`}
this.topicOrProject).owner?.email; - const project: Project | undefined = ownerEmail ? this.topicOrProject : undefined; + const project = isProject(this.topicOrProject) ? this.topicOrProject : undefined; + const ownerEmail = project?.owner?.email; const owner = ownerEmail || i18next.t('swisstopo'); const date = this.topicOrProject?.modified ? this.topicOrProject?.modified : this.topicOrProject?.created; const backgroundImage = this.topicOrProject.image?.length ? `url('${this.topicOrProject.image}')` : 'none'; @@ -133,14 +134,13 @@ export class NgmProjectTopicOverview extends LitElementI18n { ${i18next.t('dashboard_share_topic')}
this.duplicateToProject()}> ${i18next.t('duplicate_to_project')}
${i18next.t('dashboard_share_topic_email')} - ${( this.topicOrProject)?.owner?.email !== this.userEmail ? '' : html` + ${isProject(this.topicOrProject) && this.topicOrProject.owner.email !== this.userEmail ? '' : html`
this.deleteWarningModal.show = true}> @@ -181,12 +181,21 @@ export class NgmProjectTopicOverview extends LitElementI18n { } toCreateProject(topicOrProject: Topic | Project): CreateProject { + const title = isProject(topicOrProject) ? + `${i18next.t('tbx_copy_of_label')} ${topicOrProject.title}` : + translated(topicOrProject.title); + let description: string | undefined; + if (isProject(topicOrProject)) { + description = topicOrProject.description; + } else if (topicOrProject.description) { + description = translated(topicOrProject.description); + } return { - color: DEFAULT_PROJECT_COLOR, - description: topicOrProject.description ? translated(topicOrProject.description) : undefined, - title: translated(topicOrProject.title), - geometries: topicOrProject.geometries, // not a copy - assets: topicOrProject.assets, // not a copy + title, + description, + color: isProject(topicOrProject) ? topicOrProject.color : DEFAULT_PROJECT_COLOR, + geometries: isProject(topicOrProject) ? topicOrProject.geometries : [], // not a copy for topic + assets: isProject(topicOrProject) ? topicOrProject.assets : [], // not a copy for topic views: topicOrProject.views.map(view => ({ id: crypto.randomUUID(), title: translated(view.title), diff --git a/ui/src/elements/view-menu.ts b/ui/src/elements/view-menu.ts index 97bffcc1b..5ae61ad7b 100644 --- a/ui/src/elements/view-menu.ts +++ b/ui/src/elements/view-menu.ts @@ -9,6 +9,7 @@ import $ from '../jquery'; import type {Project, Topic, View} from './dashboard/ngm-dashboard'; import {getPermalink} from '../permalink'; +import {isProject} from './dashboard/helpers'; @customElement('view-menu') export class ViewMenu extends LitElementI18n { @@ -41,7 +42,8 @@ export class ViewMenu extends LitElementI18n { } async saveViewToProject() { - const project = this.selectedProject; + if (!isProject(this.selectedProject)) return; + const project = this.selectedProject; if (this.viewIndex && this.userEmail && project.owner) { if ([project.owner.email, ...project.editors].includes(this.userEmail)) { const view: View = { diff --git a/ui/src/store/dashboard.ts b/ui/src/store/dashboard.ts index 7107841a5..2e2fada9f 100644 --- a/ui/src/store/dashboard.ts +++ b/ui/src/store/dashboard.ts @@ -2,6 +2,7 @@ import {BehaviorSubject, Subject} from 'rxjs'; import type {Project, Topic} from '../elements/dashboard/ngm-dashboard'; import {NgmGeometry} from '../toolbox/interfaces'; import AuthStore from './auth'; +import {isProject} from '../elements/dashboard/helpers'; export type TopicParam = { topicId: string, viewId?: string | null } @@ -32,9 +33,10 @@ export default class DashboardStore { static setViewIndex(value: number | undefined): void { const projectOrTopic = this.selectedTopicOrProjectSubject.value; if (value !== undefined && projectOrTopic) { - const owner = ( projectOrTopic).owner?.email === AuthStore.userEmail; - const editor = !!( projectOrTopic).editors?.find(e => e.email === AuthStore.userEmail); - this.setProjectMode(owner || editor ? 'viewEdit' : 'viewOnly'); + const owner = isProject(projectOrTopic) && projectOrTopic.owner.email === AuthStore.userEmail; + const editor = isProject(projectOrTopic) && !!projectOrTopic.editors?.find(e => e.email === AuthStore.userEmail); + const mode = owner || editor ? 'viewEdit' : 'viewOnly'; + if (this.projectMode.value !== mode) this.setProjectMode(mode); } else { this.setProjectMode(undefined); } diff --git a/ui/src/toolbox/ngm-geometries-list.ts b/ui/src/toolbox/ngm-geometries-list.ts index 32cae9ee1..ef0749e0f 100644 --- a/ui/src/toolbox/ngm-geometries-list.ts +++ b/ui/src/toolbox/ngm-geometries-list.ts @@ -8,7 +8,7 @@ import $ from '../jquery.js'; import './ngm-geometries-simple-list'; import i18next from 'i18next'; import DashboardStore from '../store/dashboard'; -import {Project} from '../elements/dashboard/ngm-dashboard'; +import {isProject} from '../elements/dashboard/helpers'; @customElement('ngm-geometries-list') export default class NgmGeometriesList extends LitElementI18n { @@ -70,14 +70,16 @@ export default class NgmGeometriesList extends LitElementI18n { render() { const projectMode = DashboardStore.projectMode.value; const projectEditMode = projectMode === 'viewEdit' || projectMode === 'edit'; - const isProjectSelected = !!( DashboardStore.selectedTopicOrProject.value)?.owner; + const selectedProject = DashboardStore.selectedTopicOrProject.value; return html` { if (!this.geometryController || !this.geometryControllerNoEdit) return; const geometries = DashboardStore.selectedTopicOrProject.value?.geometries || []; - if (currentMode === 'edit' || currentMode === 'viewEdit') { + const currentModeEdit = currentMode === 'edit' || currentMode === 'viewEdit'; + const prevModeEdit = previousMode === 'edit' || previousMode === 'viewEdit'; + if (currentModeEdit) { this.geometryController!.setGeometries(geometries); this.geometryControllerNoEdit.setGeometries([]); } else if (currentMode === 'viewOnly') { @@ -131,7 +133,7 @@ export class NgmToolbox extends LitElementI18n { } else { this.geometryControllerNoEdit.setGeometries([]); } - if (previousMode === 'edit' || previousMode === 'viewEdit') { + if (!currentModeEdit && prevModeEdit) { this.geometryController.setGeometries(LocalStorageController.getStoredAoi()); } });