diff --git a/docs/UI.md b/docs/UI.md index 4820bc4..4b2960c 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -59,6 +59,18 @@ Sprint 1 시연에서 가장 중요한 기준은 `도면 불러오기 -> 검토/ - 앱 데이터 경로에 최근 프로젝트 인덱스 저장 - 다음 앱 실행 시 Project Navigator 목록에 표시 +저장 위치 정책: + +- 기본 위치는 `<사용자 Documents>/SafeCrowd Projects/<프로젝트 이름>`이며, 새 프로젝트 화면에서 프로젝트 이름을 입력하면 폴더 경로가 이 규칙으로 자동 제안된다. +- 자동 제안 경로가 이미 비어 있지 않은 폴더와 충돌하면 ` (2)`, ` (3)` 같은 접미를 붙여 사용 가능한 경로를 찾는다. +- 사용자가 Browse로 직접 고른 경로는 사용자의 의사로 보고, 이후 프로젝트 이름을 바꿔도 자동 갱신하지 않는다. +- 폴더명은 OS 금지 문자(`\\ / : * ? " < > |`)를 `_`로 치환해 정규화한다. +- 저장 시점에 다음 조건을 만족하지 않으면 거부한다. + - 드라이브 또는 볼륨의 루트가 아니어야 한다. + - 심볼릭 링크 또는 Windows 정션이 아니어야 한다. + - 비어 있거나, SafeCrowd 관리 파일(`safecrowd-project.json`, `layout.dxf`, `layout-review.json`, `workspace-state.json`)만 존재해야 한다. +- 최근 프로젝트 인덱스는 `QStandardPaths::AppDataLocation` 아래에 저장한다. `QApplication`의 organization name과 application name을 모두 `SafeCrowd`로 고정하므로, Windows 기준 경로는 `%APPDATA%/SafeCrowd/SafeCrowd/recent-projects.json`이 된다. + ## 3. 화면별 기능 기준 ### 3.1 Project Navigator diff --git a/src/application/NewProjectWidget.cpp b/src/application/NewProjectWidget.cpp index 522decc..ed9051d 100644 --- a/src/application/NewProjectWidget.cpp +++ b/src/application/NewProjectWidget.cpp @@ -1,12 +1,16 @@ #include "application/NewProjectWidget.h" +#include #include #include #include #include #include #include +#include +#include #include +#include #include #include "application/UiStyle.h" @@ -14,6 +18,60 @@ namespace safecrowd::application { namespace { +constexpr auto kProjectsRootName = "SafeCrowd Projects"; + +// String-only; saveProject creates the folder lazily on actual save. +QString defaultProjectsRoot() { + auto base = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (base.isEmpty()) { + base = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); + } + if (base.isEmpty()) { + return QString(); + } + return QDir(base).filePath(kProjectsRootName); +} + +QString sanitizeFolderName(const QString& name) { + static const QRegularExpression invalid(R"([\\/:*?"<>|])"); + auto cleaned = name.trimmed(); + cleaned.replace(invalid, "_"); + cleaned = cleaned.simplified(); + return cleaned; +} + +bool folderIsAvailableForSuggestion(const QString& path) { + QDir dir(path); + if (!dir.exists()) { + return true; + } + const auto entries = dir.entryInfoList( + QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); + return entries.isEmpty(); +} + +QString suggestProjectFolder(const QString& projectName) { + const auto root = defaultProjectsRoot(); + if (root.isEmpty()) { + return QString(); + } + const auto sanitized = sanitizeFolderName(projectName); + if (sanitized.isEmpty()) { + return root; + } + const auto base = QDir(root).filePath(sanitized); + if (folderIsAvailableForSuggestion(base)) { + return base; + } + for (int suffix = 2; suffix < 1000; ++suffix) { + const auto candidate = QDir(root).filePath(QStringLiteral("%1 (%2)").arg(sanitized).arg(suffix)); + if (folderIsAvailableForSuggestion(candidate)) { + return candidate; + } + } + return base; // fall back; saveProject will surface a clear error +} + QPushButton* createOutlinedButton(const QString& text, QWidget* parent) { auto* button = new QPushButton(text, parent); button->setFont(ui::font(ui::FontRole::Body)); @@ -124,9 +182,32 @@ NewProjectWidget::NewProjectWidget(QWidget* parent) } }); + connect(projectNameEdit_, &QLineEdit::textChanged, this, [this](const QString& name) { + if (folderEditedByUser_) { + return; + } + const auto suggestion = suggestProjectFolder(name); + const QSignalBlocker blocker(folderPathEdit_); + folderPathEdit_->setText(suggestion); + }); + connect(folderBrowseButton, &QPushButton::clicked, this, [this]() { - const auto path = QFileDialog::getExistingDirectory(this, "Select Project Folder"); + QString startDir = folderPathEdit_->text().trimmed(); + if (startDir.isEmpty()) { + startDir = defaultProjectsRoot(); + } + if (!startDir.isEmpty()) { + QDir candidate(startDir); + while (!candidate.exists() && !candidate.isRoot() && candidate.cdUp()) { + } + if (candidate.exists()) { + startDir = candidate.absolutePath(); + } + } + + const auto path = QFileDialog::getExistingDirectory(this, "Select Project Folder", startDir); if (!path.isEmpty()) { + folderEditedByUser_ = true; folderPathEdit_->setText(path); } }); diff --git a/src/application/NewProjectWidget.h b/src/application/NewProjectWidget.h index 6867d00..e5d7a77 100644 --- a/src/application/NewProjectWidget.h +++ b/src/application/NewProjectWidget.h @@ -23,6 +23,7 @@ class NewProjectWidget : public QWidget { QLineEdit* projectNameEdit_{nullptr}; QLineEdit* layoutPathEdit_{nullptr}; QLineEdit* folderPathEdit_{nullptr}; + bool folderEditedByUser_{false}; std::function doneHandler_{}; std::function cancelHandler_{}; }; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 152995a..4e99489 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -202,6 +202,83 @@ bool canDeleteProjectFolder(const QString& folderPath, QString* errorMessage) { return true; } +// Verifies the *location* of a folder being used to save a SafeCrowd project. +// Rejects symlinks/junctions and drive/volume roots. Used by the save path; +// the delete path keeps its own checks for now. +bool validateProjectFolderLocation(const QString& folderPath, QString* errorMessage) { + const auto setError = [errorMessage](const QString& message) { + if (errorMessage != nullptr) { + *errorMessage = message; + } + }; + + if (folderPath.isEmpty()) { + setError("Project folder path is empty."); + return false; + } + + const QFileInfo folderInfo(QDir(folderPath).absolutePath()); + if (folderInfo.isSymLink() || folderInfo.isJunction()) { + setError(QString("Refusing to save into a symbolic link or junction: %1").arg(folderPath)); + return false; + } + + if (QDir(folderInfo.absoluteFilePath()).isRoot()) { + setError(QString("Refusing to save into a drive root: %1").arg(folderPath)); + return false; + } + + const QStorageInfo storage(folderInfo.absoluteFilePath()); + if (storage.isValid() && !storage.rootPath().isEmpty() + && QFileInfo(storage.rootPath()).absoluteFilePath() == folderInfo.absoluteFilePath()) { + setError(QString("Refusing to save into a volume root: %1").arg(folderPath)); + return false; + } + + return true; +} + +// Verifies that `folderPath` is safe to receive a SafeCrowd project save. +// The folder may not yet exist (it will be created); if it exists, it must +// be either empty or contain only SafeCrowd-managed files. +bool canSaveIntoProjectFolder(const QString& folderPath, QString* errorMessage) { + const auto setError = [errorMessage](const QString& message) { + if (errorMessage != nullptr) { + *errorMessage = message; + } + }; + + if (!validateProjectFolderLocation(folderPath, errorMessage)) { + return false; + } + + QDir folder(folderPath); + if (!folder.exists()) { + return true; + } + + const auto entries = folder.entryInfoList( + QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot, + QDir::Name); + for (const auto& entry : entries) { + if (entry.isSymLink() || entry.isJunction()) { + setError(QString( + "Refusing to save into a folder that contains a link not created by SafeCrowd: %1") + .arg(entry.fileName())); + return false; + } + if (!entry.isFile() || !isProjectManagedEntry(entry.fileName())) { + setError(QString( + "Refusing to save into a folder that contains a file or folder not created by SafeCrowd: %1\n\n" + "Please choose an empty folder, or a folder that already contains a SafeCrowd project.") + .arg(entry.fileName())); + return false; + } + } + + return true; +} + bool copyLayoutIntoProject(ProjectMetadata& metadata, QString* errorMessage) { const auto sourcePath = QFileInfo(metadata.layoutPath).absoluteFilePath(); const auto targetPath = QDir(metadata.folderPath).filePath(kLayoutFileName); @@ -1250,6 +1327,10 @@ bool ProjectPersistence::saveProject(ProjectMetadata metadata, QString* errorMes return false; } + if (!canSaveIntoProjectFolder(metadata.folderPath, errorMessage)) { + return false; + } + QDir folder(metadata.folderPath); if (!folder.exists() && !QDir().mkpath(metadata.folderPath)) { if (errorMessage != nullptr) { diff --git a/src/application/main.cpp b/src/application/main.cpp index 631fef2..262d198 100644 --- a/src/application/main.cpp +++ b/src/application/main.cpp @@ -6,6 +6,10 @@ #include "engine/EngineRuntime.h" int main(int argc, char* argv[]) { + QApplication::setOrganizationName("SafeCrowd"); + QApplication::setOrganizationDomain("safecrowd.local"); + QApplication::setApplicationName("SafeCrowd"); + QApplication app(argc, argv); app.setStyleSheet(safecrowd::application::ui::appStyleSheet());