diff --git a/src/components/specific/files/files-manager/FilesManager.vue b/src/components/specific/files/files-manager/FilesManager.vue index d92904645..f150fbe69 100644 --- a/src/components/specific/files/files-manager/FilesManager.vue +++ b/src/components/specific/files/files-manager/FilesManager.vue @@ -11,6 +11,7 @@ :initialSearchText="searchText" @update:searchText="searchText = $event" @upload-files="uploadFiles" + @open-naming-template="openNamingTemplateManager" />
@@ -90,6 +91,7 @@ @manage-access="openAccessManager" @open-tag-manager="openTagManager" @open-versioning-manager="openVersioningManager" + @open-naming-template="openNamingTemplateManager" @open-visa-manager="openVisaManager" @remove-model="removeModel" @row-drop="({ event, data }) => uploadFiles(event, data)" @@ -143,6 +145,14 @@ :folder="folderToManage" @close="closeSidePanel" /> + row.data); - documentList = folderFiles.filter(f => !isFolder(f)); + documentList = folderFiles.filter((f) => !isFolder(f)); } if (selectedFileTab.value.id === "files") { documentList = filesTable.value.displayedListFiles; @@ -498,11 +530,13 @@ export default { const showVersioningManager = ref(false); const showVisaManager = ref(false); const showTagManager = ref(false); + const showNamingTemplateManager = ref(false); const managers = { visa: showVisaManager, versioning: showVersioningManager, access: showAccessManager, tag: showTagManager, + namingTemplate: showNamingTemplateManager, }; const setManagerVisibility = (manager, value) => { Object.values(managers).forEach((ref) => (ref.value = false)); @@ -567,6 +601,33 @@ export default { }, 100); }; + const openNamingTemplateManager = (folder) => { + folderToManage.value = folder; + setManagerVisibility("namingTemplate", true); + console.log("Opening naming template manager for folder:", folder); + console.log(folderToManage.value); + openSidePanel(); + stopCurrentFilesWatcher = watch( + () => currentFiles.value, + (files) => { + const newFolder = files.find((file) => file.id === folder.id); + if (newFolder) { + folderToManage.value = newFolder; + } else { + closeNamingTemplateManager(); + } + }, + ); + }; + const closeNamingTemplateManager = () => { + stopCurrentFilesWatcher(); + closeSidePanel(); + setTimeout(() => { + showNamingTemplateManager.value = false; + folderToManage.value = null; + }, 100); + }; + const openTagManager = (file) => { openSidePanel(); if (file.file_name) { @@ -575,6 +636,7 @@ export default { showAccessManager.value = false; showVisaManager.value = false; showVersioningManager.value = false; + showNamingTemplateManager.value = false; } }; const closeTagManager = () => { @@ -806,7 +868,10 @@ export default { searchText, selectedFileTab, selection, + rules, + projectPk, showAccessManager, + showNamingTemplateManager, showDeleteModal, showTagManager, showVersioningManager, @@ -816,6 +881,7 @@ export default { closeAccessManager, closeDeleteModal, closeSidePanel, + closeNamingTemplateManager, closeTagManager, closeVersioningManager, closeVisaManager, @@ -829,6 +895,8 @@ export default { goVisasView, isFullTotal, moveFiles, + onAssignmentSaved, + startEditRule, onFileSelected, openAccessManager, openFileDeleteModal, @@ -838,6 +906,7 @@ export default { onTabChange, openTagManager, openVersioningManager, + openNamingTemplateManager, openVisaManager, removeModel, removeModels, diff --git a/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue b/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue index f2228ba58..888cd01d2 100644 --- a/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue +++ b/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue @@ -16,6 +16,7 @@ + @@ -157,7 +158,7 @@ export default { required: true, }, }, - emits: ["open-subscription-modal", "update:searchText", "upload-files"], + emits: ["open-subscription-modal", "update:searchText", "upload-files", "open-naming-template"], setup(props, { emit }) { const { t } = useI18n(); const { isUserOrga, isProjectAdmin, isProjectGuest, hasAdminPerm } = useUser(); @@ -165,11 +166,7 @@ export default { const shouldSubscribe = inject("shouldSubscribe"); - - const { - downloadFiles: download, - projectFileStructure, - } = useFiles(); + const { downloadFiles: download, projectFileStructure } = useFiles(); const downloadFiles = async (files) => { await download(props.project, files); @@ -188,7 +185,14 @@ export default { { name: t("FilesManager.gedDownload"), action: () => downloadFiles([projectFileStructure.value]), - } + }, + { + name: t("FilesManager.namingConvention"), + action: () => { + emit("open-naming-template", props.currentFolder); + dropdown.value.displayed = false; + }, + }, ); } @@ -228,17 +232,17 @@ export default { const isLargeLayout = computed( () => (isProjectAdmin(props.project) && !isXXXL.value) || - (!isProjectAdmin(props.project) && !isMidXXL.value) + (!isProjectAdmin(props.project) && !isMidXXL.value), ); const isMediumLayout = computed( () => (isProjectAdmin(props.project) && !isXL.value && isXXXL.value) || - (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value) + (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value), ); - const searchText = ref(props.initialSearchText || ''); + const searchText = ref(props.initialSearchText || ""); watch(searchText, (newValue) => { - emit('update:searchText', newValue); + emit("update:searchText", newValue); }); return { diff --git a/src/components/specific/files/files-manager/files-naming-panel/FilesNamingPanel.vue b/src/components/specific/files/files-manager/files-naming-panel/FilesNamingPanel.vue new file mode 100644 index 000000000..c64441d4b --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/FilesNamingPanel.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/SPEC.md b/src/components/specific/files/files-manager/files-naming-panel/SPEC.md new file mode 100644 index 000000000..57c7a1417 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/SPEC.md @@ -0,0 +1,214 @@ +# Naming Convention (Naming Template) — Spécification technique + +## Vue d'ensemble + +Feature permettant aux administrateurs d'un projet BIMData de définir des **règles de nommage** pour les fichiers déposés dans les dossiers de la GED. Les règles peuvent être appliquées en mode souple (avertissement) ou strict (blocage). + +--- + +## Architecture + +``` +src/ +├── composables/ +│ └── useNamingConvention.js ← Hook principal à brancher dans l'upload +├── services/ +│ └── namingConventionService.js ← Logique pure (regex, validation, suggestions) +├── stores/ +│ └── namingConventionStore.js ← State Pinia, CRUD règles, check upload +└── components/naming/ + ├── NamingConventionPanel.vue ← Panneau création/édition d'une règle + ├── NamingSegmentRow.vue ← Ligne d'un segment dans le formulaire + ├── NamingRulesList.vue ← Liste des règles + assignation dossier + ├── NamingViolationModal.vue ← Modale bloquante (mode strict) + ├── NamingViolationBanner.vue ← Bannière non-bloquante (mode souple) + └── GedFileUpload.integration.vue ← Exemple d'intégration dans la GED +``` + +--- + +## Modèle de données + +### NamingRule + +```ts +interface NamingRule { + id: string; + name: string; // "Convention métier projet X" + separator: "-" | "." | "_"; + segments: NamingSegment[]; + mode: "soft" | "strict"; + recursive: boolean; // s'applique aux sous-dossiers + folder_ids: string[]; // dossiers cibles + pattern: string; // ex: "a.1.a" (généré auto) + creator_email?: string; + created_at: string; + updated_at: string; + active: boolean; +} +``` + +### NamingSegment + +```ts +type NamingSegment = + | { type: "n_chars"; config: { min?: number; max?: number } } + | { type: "bounded"; config: { min: number; max: number } } + | { type: "list"; config: { listId?: string; values: string[] } } +``` + +### CustomList + +```ts +interface CustomList { + id: string; + name: string; + values: string[]; // ["WIP", "VAL", "ARCH", ...] +} +``` + +--- + +## Règles de validation + +Le service `namingConventionService.js` construit un regex depuis la définition d'une règle : + +| Segment | Pattern regex généré | Exemple | +|---------------|------------------------------|------------------| +| N caractères | `[\w\-]{min,max}` | `[\w\-]{2,8}` | +| Valeurs bornées | Alternation numérique | `(1\|2\|...\|50)` | +| Liste | Alternation des valeurs | `(WIP\|VAL\|ARCH)` | + +Le séparateur est intercalé entre chaque segment. +L'extension de fichier (`.ifc`, `.pdf`…) est tolérée en suffixe. + +**Exemple** : Règle `a.1.a` avec séparateur `.` : +- Segments : [N chars, Bounded(1-99), N chars] +- Regex : `^[\w\-]+\.(1|2|...|99)\.[\w\-]+(\.[a-zA-Z0-9]+)?$` +- ✅ `20191002.1.Piping.ifc` +- ❌ `20191002Mechanical Piping.ifc` + +--- + +## Flux d'upload (intégration GED) + +``` +User sélectionne fichier(s) + ↓ +useNamingConvention.checkBeforeUpload(files) + ↓ +store.checkFilesBeforeUpload(files, folder) + → récupère les règles effectives du dossier (directes + récursives) + → valide chaque fichier contre chaque règle + ↓ + ┌───────────────────────────────┐ + │ Aucune violation ? │ → upload immédiat + ├───────────────────────────────┤ + │ Violations soft seulement ? │ → NamingViolationBanner (non-bloquant) + │ │ upload continue, user peut renommer après + ├───────────────────────────────┤ + │ Violations strictes ? │ → NamingViolationModal (bloquant) + │ │ upload suspendu, Promise en attente + │ │ User renomme → confirm → upload reprend + │ │ User annule → Promise rejected + └───────────────────────────────┘ +``` + +--- + +## Routes API (à implémenter dans platform-back) + +| Méthode | Route | Description | +|---------|-------|-------------| +| `GET` | `/cloud/:cloudPk/project/:projectPk/naming-rules` | Liste des règles | +| `POST` | `/cloud/:cloudPk/project/:projectPk/naming-rules` | Créer une règle | +| `PATCH` | `/cloud/:cloudPk/project/:projectPk/naming-rules/:id` | Modifier une règle | +| `DELETE`| `/cloud/:cloudPk/project/:projectPk/naming-rules/:id` | Supprimer une règle | +| `GET` | `/cloud/:cloudPk/project/:projectPk/naming-lists` | Listes personnalisées | +| `POST` | `/cloud/:cloudPk/project/:projectPk/naming-lists` | Créer une liste | + +> **Note** : En attendant l'implémentation backend, le store utilise `localStorage` comme stub. Remplacer les blocs commentés `// When platform-back implements this endpoint` par les vrais appels API. + +--- + +## Composants UI — résumé + +### `NamingConventionPanel.vue` +Panneau latéral de création/édition d'une règle. +- **Étape 1** : Nom de la règle +- **Étape 2** : Séparateur (tiret / point / underscore) +- **Étape 3** : Segments (N chars / Borné / Liste) avec aperçu live du pattern +- **Mode** : checkbox stricte + checkbox récursive +- Emit : `saved(rule)`, `close` + +### `NamingSegmentRow.vue` +Ligne d'un segment dans le builder. Drag-handle pour réordonner. +- N chars : inputs min/max +- Borné : inputs min/max numériques +- Liste : select parmi les `CustomList` du projet + +### `NamingRulesList.vue` +Panneau latéral listant les règles existantes. +- Recherche texte +- Sélection par radio pour assigner au dossier courant +- Badge "strict" sur les règles en mode strict +- Bouton édition inline +- Section d'assignation/désassignation au dossier courant + +### `NamingViolationModal.vue` +Modale **bloquante** (mode strict). +- Affiche les fichiers non-conformes groupés par règle +- Champ de renommage inline par fichier +- Validation live du nouveau nom contre la règle +- Bouton "Renommer" actif uniquement quand tous les fichiers sont résolus + +### `NamingViolationBanner.vue` +Bannière **non-bloquante** (mode souple). +- Résumé cliquable avec compteur de violations +- Liste expandable des fichiers non-conformes +- Renommage inline optionnel +- Dismiss pour ignorer + +--- + +## Intégration dans la GED existante + +1. **Importer le composable** dans votre composant d'upload GED : + ```js + import { useNamingConvention } from "@/composables/useNamingConvention.js"; + const naming = useNamingConvention(() => currentFolder.value); + ``` + +2. **Intercepter les fichiers** avant l'upload : + ```js + const { files } = await naming.checkBeforeUpload(selectedFiles); + await uploadFiles(files); // avec les noms potentiellement renommés + ``` + +3. **Afficher les composants UI** dans le template : + ```html + + + ``` + +4. **Exposer le panneau de gestion** via un bouton "Conventions de nommage" dans la toolbar GED. + +5. **Initialiser les règles** au mount : + ```js + store.fetchRules(cloudPk, projectPk); + ``` + +--- + +## Tests + +Fichier : `src/services/namingConventionService.test.js` + +Couverture : +- `buildRuleRegex` : N chars, borné, liste, extension, règle complexe +- `validateFileName` : valid / invalid / extension +- `buildHumanReadablePattern` : tous types de segments +- `suggestRenames` : génération de suggestions, préservation extension +- Flow complet soft vs strict (simulation store) + +Run : `npx jest src/services/namingConventionService.test.js` diff --git a/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.scss b/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.scss new file mode 100644 index 000000000..d72dea929 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.scss @@ -0,0 +1,158 @@ +.naming-panel { + height: 100%; + display: flex; + flex-direction: column; + + &__header { + margin-bottom: 12px; + .go-back { + margin-right: 6px; + } + } + + &__body { + padding-right: 6px; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 24px; + } + + &__section { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__step { + display: flex; + align-items: center; + gap: 6px; + } + + &__step-num { + padding: 0 3px; + min-width: 22px; + height: 22px; + border-radius: 50px; + background: var(--color-primary); + color: var(--color-white); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + } + + &__separators { + gap: 8px; + flex-wrap: wrap; + } + + &__sep-option { + padding: 0px 12px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + white-space: nowrap; + border-radius: 4px; + color: var(--color-granite); + border: 1px solid var(--color-silver); + font-size: 12px; + user-select: none; + transition: all 0.15s ease; + cursor: pointer; + + &.active { + border-color: var(--color-primary); + background: var(--color-primary-lighter); + color: var(--color-primary); + font-weight: 600; + } + + &:hover:not(.active) { + border-color: var(--color-granite-light); + background: var(--color-primary-lighter); + } + } + + &__preview { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--color-granite); + + code { + font-family: monospace; + font-weight: 600; + color: var(--color-primary); + font-size: 13px; + } + } + + &__segments { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__add-btns { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + &__mode-toggle { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__checkbox-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-weight: 500; + } + + &__mode-hint { + margin: 0; + font-size: 12px; + color: var(--color-granite); + line-height: 1.5; + } + + &__footer { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 8px; + } +} + +.btn-add-seg { + padding: 0px 12px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + white-space: nowrap; + border-radius: 4px; + border: 1px dashed var(--color-silver); + background: transparent; + color: var(--color-primary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: var(--color-primary); + background: var(--color-primary-lighter); + transition: all 0.15s; + } +} diff --git a/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.vue b/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.vue new file mode 100644 index 000000000..1f332d2bc --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/files-naming-convention/FilesNamingConvention.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.scss b/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.scss new file mode 100644 index 000000000..3232582bf --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.scss @@ -0,0 +1,180 @@ +.rules-list { + height: 100%; + + &__header { + margin-bottom: 12px; + font-weight: 600; + } + + &__title { + font-weight: 600; + } + + &__actions { + gap: 12px; + } + + &__empty { + gap: 6px; + color: var(--color-granite); + text-align: center; + h2 { + margin: 0; + color: var(--color-primary); + } + } + + &__content { + height: calc(100% - 32px - 12px); + & > div { + max-height: calc(100% - 100px - 12px); + height: auto; + } + } + + &__items { + height: calc(100% - 32px - 63px - 12px - 12px); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + } + + &__assign { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__assign-actions { + display: flex; + align-items: center; + gap: 8px; + } + &__checkbox-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-weight: 500; + } + + &__recursive-label { + font-weight: 500; + } + + &__recursive-hint { + margin: 0; + font-size: 12px; + color: var(--color-granite); + line-height: 1.4; + } + + &__save-btn { + align-self: flex-end; + } +} + +.rule-item { + padding: 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; + border: 1px solid var(--color-silver-light); + + &:hover { + background: var(--color-silver-light); + } + + &--selected { + background: var(--color-primary-lighter); + border-color: var(--color-primary); + } + + &--strict .rule-item__name { + color: var(--color-primary); + } + + &__info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + } + + &__header { + gap: 6px; + &:deep() { + .bimdata-radio { + gap: 6px; + align-items: flex-start; + .bimdata-radio__text { + min-height: 23px; + display: inline-flex; + align-content: center; + flex-wrap: wrap; + text-align: left; + } + } + } + } + + &__name { + font-weight: 500; + font-size: var(--font-size-s); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } + + &__badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 10px; + font-weight: 600; + flex-shrink: 0; + + &--strict { + background: var(--color-high-lighter); + color: var(--color-high); + } + } + + &__edit { + background: none; + border: none; + cursor: pointer; + color: var(--color-granite-light); + padding: 2px; + display: flex; + align-items: center; + flex-shrink: 0; + } + + &__creator { + font-size: 12px; + color: var(--color-granite); + } + + &__pattern { + padding: 3px 6px; + font-size: 11px; + border-radius: 4px; + color: var(--color-neutral); + background: var(--color-neutral-lighter); + white-space: nowrap; + } +} + +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.2s ease; +} +.slide-up-enter-from, +.slide-up-leave-to { + transform: translateY(10px); + opacity: 0; +} diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.vue b/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.vue new file mode 100644 index 000000000..ceb56c23f --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-rules-list/NamingRulesList.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.scss b/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.scss new file mode 100644 index 000000000..ab79faeff --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.scss @@ -0,0 +1,109 @@ +.seg-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px; + background: rgb(252, 252, 252); + border-radius: 6px; + border: 1px solid var(--color-silver-light); + + &__drag { + cursor: grab; + color: var(--color-granite-light); + flex-shrink: 0; + } + + &__sep { + font-family: monospace; + font-size: 14px; + font-weight: 700; + color: var(--color-granite); + flex-shrink: 0; + min-width: 14px; + text-align: center; + } + + &__type { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + flex-shrink: 0; + white-space: nowrap; + + &--n_chars { + background: var(--color-neutral-lighter); + color: var(--color-neutral); + } + &--bounded { + background: var(--color-warning-lighter); + color: var(--color-warning); + } + &--list { + background: var(--color-success-lighter); + color: var(--color-success); + } + } + + &__config { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + } + + &__input { + flex: 1; + min-width: 0; + &--sm { + width: auto; + } + } + + &__range-sep { + color: var(--color-granite); + font-size: 12px; + } + + &__select { + flex: 1; + padding: 5px 8px; + border: 1px solid var(--color-silver); + border-radius: 4px; + font-size: 12px; + background: white; + color: var(--color-granite-dark); + + &:focus { + outline: none; + border-color: var(--color-primary); + } + } + + &__manage-list { + background: none; + border: none; + cursor: pointer; + color: var(--color-granite); + padding: 4px; + display: flex; + align-items: center; + + &:hover { + color: var(--color-primary); + } + } + + &__actions { + display: flex; + gap: 2px; + flex-shrink: 0; + } + + &__action { + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } +} diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.vue b/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.vue new file mode 100644 index 000000000..448cbe92c --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-segment-row/NamingSegmentRow.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.scss b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.scss new file mode 100644 index 000000000..af7c5ac13 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.scss @@ -0,0 +1,164 @@ +.violation-banner { + background: #fff8e1; + border: 1px solid #ffd54f; + border-radius: 6px; + overflow: hidden; + margin-bottom: 12px; + + &__summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + user-select: none; + + &:hover { + background: #fff3cd; + } + } + + &__icon { + color: #f57c00; + flex-shrink: 0; + } + + &__text { + flex: 1; + font-size: var(--font-size-s); + font-weight: 500; + color: #5d4037; + } + + &__expand { + background: none; + border: none; + cursor: pointer; + color: #795548; + padding: 4px; + display: flex; + align-items: center; + transition: color 0.1s; + + svg { + transition: transform 0.2s ease; + } + } + + &__dismiss { + background: none; + border: none; + cursor: pointer; + color: #795548; + padding: 4px; + display: flex; + align-items: center; + + &:hover { + color: var(--color-red); + } + } + + &__list { + padding: 0 12px 12px; + display: flex; + flex-direction: column; + gap: 6px; + border-top: 1px solid #ffd54f; + padding-top: 10px; + } + + &__item { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-s); + color: var(--color-granite-dark); + + &--renamed { + color: var(--color-granite); + text-decoration: line-through; + } + } + + &__filename { + font-weight: 500; + } + + &__rule-ref { + font-size: 12px; + color: var(--color-granite); + } + + &__check { + color: var(--color-success); + flex-shrink: 0; + } + + &__rename-btn { + margin-left: auto; + background: none; + border: 1px solid var(--color-primary); + color: var(--color-primary); + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: all 0.1s; + + &:hover { + background: var(--color-primary); + color: white; + } + } + + &__rename-form { + background: white; + border: 1px solid var(--color-silver-light); + border-radius: 6px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; + } + + &__rename-label { + font-size: 12px; + color: var(--color-granite); + font-style: italic; + } + + &__rename-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + } + + &__all-ok { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + background: var(--color-success-lighter); + border-radius: 4px; + font-size: var(--font-size-s); + color: var(--color-success-dark, #2e7d32); + font-weight: 500; + } +} + +// Transition +.expand-enter-active, +.expand-leave-active { + transition: all 0.2s ease; + overflow: hidden; +} +.expand-enter-from, +.expand-leave-to { + max-height: 0; + opacity: 0; + padding-top: 0; + padding-bottom: 0; +} diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.vue b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.vue new file mode 100644 index 000000000..248f23025 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-banner/NamingViolationBanner.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.scss b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.scss new file mode 100644 index 000000000..be41d7959 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.scss @@ -0,0 +1,205 @@ +.violation-modal { + &__header { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: var(--font-size-m); + } + + &__body { + display: flex; + flex-direction: column; + gap: 20px; + padding: 8px 0; + } + + &__illustration { + display: flex; + justify-content: center; + padding: 16px 0 8px; + } + + &__file-icon { + position: relative; + color: var(--color-granite-light); + + .violation-modal__warning-badge { + position: absolute; + bottom: -4px; + right: -4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--color-warning); + color: white; + display: flex; + align-items: center; + justify-content: center; + } + } + + &__message { + text-align: center; + font-size: var(--font-size-s); + color: var(--color-granite-dark); + line-height: 1.6; + } + + &__warning { + color: var(--color-granite); + font-size: 13px; + } + + &__rule-group { + border: 1px solid var(--color-silver-light); + border-radius: 8px; + overflow: hidden; + } + + &__rule-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-silver-lighter); + border-bottom: 1px solid var(--color-silver-light); + } + + &__rule-name { + font-weight: 600; + font-size: var(--font-size-s); + color: var(--color-granite-dark); + } + + &__rule-pattern { + font-family: monospace; + font-size: 12px; + color: var(--color-primary); + background: var(--color-primary-lighter); + padding: 2px 6px; + border-radius: 4px; + } + + &__files { + display: flex; + flex-direction: column; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + } +} + +.file-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--color-silver-lighter); + transition: background 0.1s; + + &:last-child { + border-bottom: none; + } + + &--valid { + background: var(--color-success-lighter); + } + + &--invalid { + background: var(--color-red-lighter); + } + + &--editing { + background: var(--color-silver-lighter); + } + + &__icon { + flex-shrink: 0; + color: var(--color-granite); + } + + &__name { + flex: 1; + font-size: var(--font-size-s); + color: var(--color-granite-dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__status { + flex-shrink: 0; + + &--ok { + color: var(--color-success); + } + &--warn { + color: var(--color-warning); + } + } + + &__edit-btn { + background: none; + border: none; + cursor: pointer; + color: var(--color-granite-light); + padding: 4px; + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s; + border-radius: 4px; + + .file-row:hover & { + opacity: 1; + } + &:hover { + color: var(--color-primary); + background: var(--color-primary-lighter); + } + } + + &__edit-area { + flex: 1; + } + + &__confirm { + padding: 5px 12px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + flex-shrink: 0; + font-weight: 500; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + &:not(:disabled):hover { + background: var(--color-primary-dark); + } + } + + &__cancel-edit { + background: none; + border: none; + cursor: pointer; + color: var(--color-granite); + padding: 4px; + display: flex; + align-items: center; + flex-shrink: 0; + + &:hover { + color: var(--color-red); + } + } +} diff --git a/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.vue b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.vue new file mode 100644 index 000000000..da1d4289d --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/naming-violation-modal/NamingViolationModal.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-panel/namingConventionService.test.js b/src/components/specific/files/files-manager/files-naming-panel/namingConventionService.test.js new file mode 100644 index 000000000..47a306571 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-panel/namingConventionService.test.js @@ -0,0 +1,250 @@ +/** + * Tests: namingConventionService.js + * Run with: npx jest src/services/namingConventionService.test.js + */ + +import { + buildRuleRegex, + validateFileName, + buildHumanReadablePattern, + suggestRenames, + SEPARATOR_TYPES, + SEGMENT_TYPES, + RULE_MODES, +} from "./namingConventionService.js"; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeRule({ separator = "-", segments = [], mode = RULE_MODES.SOFT } = {}) { + return { id: "r1", name: "Test", separator, segments, mode }; +} + +function seg_nchars(min, max) { + return { type: SEGMENT_TYPES.N_CHARS, config: { min, max } }; +} + +function seg_bounded(min, max) { + return { type: SEGMENT_TYPES.BOUNDED, config: { min, max } }; +} + +function seg_list(values) { + return { type: SEGMENT_TYPES.LIST, config: { values } }; +} + +// ─── buildRuleRegex ─────────────────────────────────────────────────────────── + +describe("buildRuleRegex", () => { + test("simple N chars + separator dash", () => { + const rule = makeRule({ + separator: "-", + segments: [seg_nchars(null, null), seg_nchars(null, null)], + }); + const regex = buildRuleRegex(rule); + expect(regex.test("ABC-DEF")).toBe(true); + expect(regex.test("ABC")).toBe(false); + }); + + test("bounded segment matches range 1-50", () => { + const rule = makeRule({ + separator: ".", + segments: [seg_bounded(1, 50)], + }); + const regex = buildRuleRegex(rule); + expect(regex.test("1")).toBe(true); + expect(regex.test("50")).toBe(true); + expect(regex.test("51")).toBe(false); + expect(regex.test("0")).toBe(false); + }); + + test("list segment matches allowed values only", () => { + const rule = makeRule({ + separator: "_", + segments: [seg_list(["STRUCT", "ELEC", "PLUMB"])], + }); + const regex = buildRuleRegex(rule); + expect(regex.test("STRUCT")).toBe(true); + expect(regex.test("ELEC")).toBe(true); + expect(regex.test("MECA")).toBe(false); + }); + + test("file extension is accepted", () => { + const rule = makeRule({ + separator: "-", + segments: [seg_nchars(null, null), seg_nchars(null, null)], + }); + const regex = buildRuleRegex(rule); + expect(regex.test("ABC-DEF.ifc")).toBe(true); + expect(regex.test("ABC-DEF.pdf")).toBe(true); + }); + + test("complex rule: n_chars + bounded + list with dot separator", () => { + // Pattern like: a.1.a + const rule = makeRule({ + separator: ".", + segments: [ + seg_nchars(null, null), + seg_bounded(1, 99), + seg_nchars(null, null), + ], + }); + const regex = buildRuleRegex(rule); + expect(regex.test("20191002.1.Piping")).toBe(true); + expect(regex.test("A.50.B")).toBe(true); + expect(regex.test("A.100.B")).toBe(false); + }); +}); + +// ─── validateFileName ───────────────────────────────────────────────────────── + +describe("validateFileName", () => { + const rule = makeRule({ + separator: "-", + segments: [seg_nchars(2, 8), seg_list(["WIP", "VAL", "ARCH"])], + }); + + test("valid file passes", () => { + expect(validateFileName("ABC-WIP", rule).valid).toBe(true); + expect(validateFileName("STRUCT-VAL", rule).valid).toBe(true); + expect(validateFileName("STRUCT-VAL.ifc", rule).valid).toBe(true); + }); + + test("invalid file fails with reason", () => { + const result = validateFileName("invalid_name", rule); + expect(result.valid).toBe(false); + expect(result.reason).toBeTruthy(); + }); + + test("wrong list value fails", () => { + expect(validateFileName("ABC-DRAFT", rule).valid).toBe(false); + }); +}); + +// ─── buildHumanReadablePattern ──────────────────────────────────────────────── + +describe("buildHumanReadablePattern", () => { + test("n_chars segments show as 'a'", () => { + const rule = makeRule({ + separator: ".", + segments: [seg_nchars(null, null), seg_nchars(null, null)], + }); + expect(buildHumanReadablePattern(rule)).toBe("a.a"); + }); + + test("bounded shows min-max", () => { + const rule = makeRule({ + separator: ".", + segments: [seg_bounded(1, 50)], + }); + expect(buildHumanReadablePattern(rule)).toBe("(1-50)"); + }); + + test("list shows [liste]", () => { + const rule = makeRule({ + separator: "-", + segments: [seg_list(["A", "B"])], + }); + expect(buildHumanReadablePattern(rule)).toBe("[liste]"); + }); + + test("complex pattern (1-50).a.1.a.[liste]", () => { + const rule = makeRule({ + separator: ".", + segments: [ + seg_bounded(1, 50), + seg_nchars(null, null), + seg_bounded(1, 1), + seg_nchars(null, null), + seg_list(["elem", "type"]), + ], + }); + expect(buildHumanReadablePattern(rule)).toBe("(1-50).a.(1-1).a.[liste]"); + }); +}); + +// ─── suggestRenames ─────────────────────────────────────────────────────────── + +describe("suggestRenames", () => { + test("returns non-empty suggestions for non-conforming name", () => { + const rule = makeRule({ + separator: ".", + segments: [seg_nchars(null, null), seg_bounded(1, 99), seg_nchars(null, null)], + }); + const suggestions = suggestRenames("20191002Mechanical Piping.ifc", rule); + expect(suggestions.length).toBeGreaterThan(0); + // Suggestions should differ from original + suggestions.forEach((s) => { + expect(s).not.toBe("20191002Mechanical Piping.ifc"); + }); + }); + + test("preserves file extension in suggestions", () => { + const rule = makeRule({ + separator: "-", + segments: [seg_nchars(null, null), seg_list(["WIP"])], + }); + const suggestions = suggestRenames("myfile.ifc", rule); + suggestions.forEach((s) => { + if (s.includes(".")) { + expect(s.endsWith(".ifc")).toBe(true); + } + }); + }); +}); + +// ─── Integration: checkFilesBeforeUpload ───────────────────────────────────── + +describe("checkFilesBeforeUpload (store level, simulated)", () => { + // Simulate the store logic directly + function check(files, rule) { + const { validateFiles } = require("./namingConventionService.js"); + const violations = []; + const strictViolations = []; + const results = validateFiles(files, rule); + const invalid = results.filter((r) => !r.valid); + for (const inv of invalid) { + const entry = { ...inv, rule }; + violations.push(entry); + if (rule.mode === RULE_MODES.STRICT) strictViolations.push(entry); + } + return { + pass: violations.length === 0, + violations, + strictViolations, + hasStrict: strictViolations.length > 0, + }; + } + + const softRule = makeRule({ + separator: "-", + segments: [seg_nchars(2, 8), seg_list(["WIP", "VAL"])], + mode: RULE_MODES.SOFT, + }); + + const strictRule = makeRule({ + separator: "-", + segments: [seg_nchars(2, 8), seg_list(["WIP", "VAL"])], + mode: RULE_MODES.STRICT, + }); + + const validFile = { name: "STRUCT-WIP.ifc" }; + const invalidFile = { name: "random_file.ifc" }; + + test("all valid files pass", () => { + const result = check([validFile], softRule); + expect(result.pass).toBe(true); + expect(result.violations).toHaveLength(0); + }); + + test("soft violation does not trigger strict block", () => { + const result = check([invalidFile], softRule); + expect(result.pass).toBe(false); + expect(result.hasStrict).toBe(false); + }); + + test("strict violation triggers block", () => { + const result = check([invalidFile], strictRule); + expect(result.pass).toBe(false); + expect(result.hasStrict).toBe(true); + expect(result.strictViolations).toHaveLength(1); + }); +}); diff --git a/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue b/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue index 132936db2..10ef15a8e 100644 --- a/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue +++ b/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue @@ -14,27 +14,22 @@ - +
@@ -50,7 +45,7 @@ import { isConvertibleToPhotosphere, isModel, isViewable, - openInViewer + openInViewer, } from "../../../../../utils/models.js"; import { dropdownPositioner } from "../../../../../utils/positioner.js"; // Components @@ -60,19 +55,19 @@ import SetAsModelIcon from "../../../../../components/images/SetAsModelIcon.vue" export default { props: { parent: { - type: Object + type: Object, }, project: { type: Object, - required: true + required: true, }, file: { type: Object, - required: true + required: true, }, loading: { type: Boolean, - required: true + required: true, }, }, emits: [ @@ -85,6 +80,7 @@ export default { "open-tag-manager", "open-versioning-manager", "open-visa-manager", + "open-naming-template", "remove-model", "update", ], @@ -129,7 +125,7 @@ export default { iconComponent: SetAsModelIcon, text: "FileActionsCell.createModelButtonText", disabled: !hasAdminPerm(props.project, props.file), - action: () => onClick("create-model") + action: () => onClick("create-model"), }); } else { menuItems.value.push({ @@ -147,7 +143,7 @@ export default { iconComponent: SetAsModelIcon, text: "FileActionsCell.createPhotosphereButtonText", disabled: !hasAdminPerm(props.project, props.file), - action: () => onClick("create-photosphere") + action: () => onClick("create-photosphere"), }); } @@ -169,6 +165,16 @@ export default { if (isFolder(props.file) && isProjectAdmin(props.project)) { menuItems.value.push({ key: 7, + icon: "eye", + text: "FileActionsCell.manageNamingTemplateButtonText", + action: () => onClick("open-naming-template", props.file), + divider: true, + }); + } + + if (isFolder(props.file) && isProjectAdmin(props.project)) { + menuItems.value.push({ + key: 8, icon: "key", text: "FileActionsCell.manageAccessButtonText", action: () => onClick("manage-access"), @@ -178,21 +184,21 @@ export default { if (!isFolder(props.file) && hasAdminPerm(props.project, props.file)) { menuItems.value.push({ - key: 8, + key: 9, icon: "visa", text: "FileActionsCell.visaButtonText", action: () => onClick("open-visa-manager"), dataTestId: "btn-open-visa-manager", }); menuItems.value.push({ - key: 9, + key: 10, icon: "tag", text: "FileActionsCell.addTagsButtonText", action: () => onClick("open-tag-manager"), dataTestId: "btn-open-tag-manager", }); menuItems.value.push({ - key: 10, + key: 11, icon: "versioning", text: "FileActionsCell.versioningButtonText", action: () => onClick("open-versioning-manager"), @@ -202,7 +208,7 @@ export default { } menuItems.value.push({ - key: 11, + key: 12, icon: "delete", text: "t.delete", color: "high", @@ -214,10 +220,7 @@ export default { nextTick(() => { if (props.parent) { - menu.value.$el.style.top = dropdownPositioner( - props.parent.$el, - menu.value.$el - ); + menu.value.$el.style.top = dropdownPositioner(props.parent.$el, menu.value.$el); } }); }; @@ -230,9 +233,9 @@ export default { }); }; - const onClick = event => { + const onClick = (event, payload) => { closeMenu(); - emit(event); + emit(event, payload); }; return { @@ -242,9 +245,9 @@ export default { menuItems, // Methods closeMenu, - openMenu + openMenu, }; - } + }, }; diff --git a/src/components/specific/files/folder-table/FoldersTable.vue b/src/components/specific/files/folder-table/FoldersTable.vue index 76c4e7a55..51d063128 100644 --- a/src/components/specific/files/folder-table/FoldersTable.vue +++ b/src/components/specific/files/folder-table/FoldersTable.vue @@ -99,6 +99,7 @@ @open-tag-manager="$emit('open-tag-manager', file)" @open-versioning-manager="$emit('open-versioning-manager', file)" @open-visa-manager="$emit('open-visa-manager', file)" + @open-naming-template="$emit('open-naming-template', file)" @remove-model="$emit('remove-model', file)" @update="nameEditMode[file.id] = true" /> @@ -175,6 +176,7 @@ export default { "open-tag-manager", "open-versioning-manager", "open-visa-manager", + "open-naming-template", "remove-model", "row-drop", "selection-changed", diff --git a/src/composables/naming-convention.js b/src/composables/naming-convention.js new file mode 100644 index 000000000..7071ff345 --- /dev/null +++ b/src/composables/naming-convention.js @@ -0,0 +1,184 @@ +import { ref, computed } from "vue"; +import { useNamingConventionStore } from "../state/naming-convention.js"; +import { RULE_MODES } from "../services/NamingConvention.js"; + +export function useNamingConvention(currentFolder) { + const store = useNamingConventionStore(); + + // ── State ──────────────────────────────────────────────────────────────── + + /** All violations (soft + strict) from the last check */ + const violations = ref([]); + + /** Only strict-mode violations */ + const strictViolations = ref([]); + + /** Whether we should show the soft banner */ + const showBanner = ref(false); + + /** Whether we should show the strict blocking modal */ + const showModal = ref(false); + + /** Files pending upload (held while modal is open) */ + const pendingFiles = ref([]); + + /** Resolve/reject callbacks for the async check flow */ + let _resolve = null; + let _reject = null; + + // ── Computed ───────────────────────────────────────────────────────────── + + const hasSoftViolations = computed( + () => violations.value.filter((v) => v.rule?.mode === RULE_MODES.SOFT).length > 0, + ); + + const hasStrictViolations = computed( + () => violations.value.filter((v) => v.rule?.mode === RULE_MODES.STRICT).length > 0, + ); + + // ── Core check ─────────────────────────────────────────────────────────── + + /** + * Check files before upload. + * + * Returns a promise that resolves with the final file list to upload + * (potentially with renamed files substituted) or rejects if the user cancels. + * + * If no rules apply: resolves immediately with original files. + * If soft violations: shows banner, resolves immediately (upload proceeds). + * If strict violations: shows blocking modal, waits for user resolution. + */ + async function checkBeforeUpload(files) { + const folder = + typeof currentFolder === "function" + ? currentFolder() + : (currentFolder?.value ?? currentFolder); + + if (!folder) return { files, blocked: false }; + + const result = store.checkFilesBeforeUpload(files, folder); + + // No violations → proceed + if (result.pass) return { files, blocked: false }; + + violations.value = result.violations; + strictViolations.value = result.strictViolations; + + // Soft only → show banner, don't block + if (!result.hasStrict) { + showBanner.value = true; + return { files, blocked: false, hasSoftWarnings: true }; + } + + // Strict violations → block and wait for modal resolution + pendingFiles.value = [...files]; + showModal.value = true; + + return new Promise((resolve, reject) => { + _resolve = resolve; + _reject = reject; + }); + } + + // ── Modal callbacks ─────────────────────────────────────────────────────── + + /** + * Called when the user confirms renames in the strict modal. + * renames: Array of { original: File, newName: string } + */ + function onModalConfirm(renames) { + showModal.value = false; + + // Substitute renamed files in the pending list + const finalFiles = pendingFiles.value.map((file) => { + const rename = renames.find( + (r) => (r.original.name || r.original.file_name) === (file.name || file.file_name), + ); + if (rename) { + // Create a new File with the new name (browser File API) + if (file instanceof File) { + return new File([file], rename.newName, { type: file.type }); + } + // For API file objects (already uploaded), return a patch object + return { ...file, name: rename.newName, file_name: rename.newName }; + } + return file; + }); + + clearViolations(); + if (_resolve) { + _resolve({ files: finalFiles, blocked: false }); + _resolve = null; + } + } + + function onModalCancel() { + showModal.value = false; + pendingFiles.value = []; + clearViolations(); + if (_reject) { + _reject(new Error("Upload cancelled by user (naming rule)")); + _reject = null; + } + } + + // ── Banner callbacks ────────────────────────────────────────────────────── + + function onBannerDismiss() { + showBanner.value = false; + clearViolations(); + } + + function onBannerApplyRenames(renames) { + showBanner.value = false; + // Emit for parent to handle API rename calls + return renames; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + function clearViolations() { + violations.value = []; + strictViolations.value = []; + } + + /** + * Déclenche l'affichage des violations depuis une source externe + * (ex: vérification post-assignment-saved). + */ + function triggerViolations(newViolations) { + if (!newViolations.length) return; + + violations.value = newViolations; + + const hasStrict = newViolations.some((v) => v.rule?.mode === RULE_MODES.STRICT); + + if (hasStrict) { + strictViolations.value = newViolations.filter((v) => v.rule?.mode === RULE_MODES.STRICT); + showModal.value = true; + } else { + showBanner.value = true; + } + } + + return { + // State + violations, + strictViolations, + showBanner, + showModal, + hasSoftViolations, + hasStrictViolations, + // Core + checkBeforeUpload, + // Modal + onModalConfirm, + onModalCancel, + // Banner + onBannerDismiss, + onBannerApplyRenames, + // Utils + clearViolations, + triggerViolations, + }; +} diff --git a/src/i18n/lang/fr.json b/src/i18n/lang/fr.json index b3128d827..81dab79e4 100644 --- a/src/i18n/lang/fr.json +++ b/src/i18n/lang/fr.json @@ -185,6 +185,7 @@ "addVersionButtonText": "Ajouter une version", "createModelButtonText": "Définir comme modèle", "createPhotosphereButtonText": "Définir comme photosphère", + "manageNamingTemplateButtonText": "Convention de nommage", "manageAccessButtonText": "Gérer les accès", "openViewerButtonText": "Ouvrir", "previewModelButtonText": "Prévisualiser", @@ -216,7 +217,8 @@ "folderImport": "Importer un dossier", "foldersTab": "Dossiers", "filesTab": "Tous les fichiers", - "visasTab": "Mes visas" + "visasTab": "Mes visas", + "namingConvention": "Convention de nommage" }, "FilesManagerOnboarding": { "text": "Téléverser votre premier fichier", @@ -989,4 +991,4 @@ "title": "Suppression de {visasCount} visas", "message": "Vous êtes sur le point de supprimer les visas sur les fichiers suivants :" } -} \ No newline at end of file +} diff --git a/src/services/NamingConvention.js b/src/services/NamingConvention.js new file mode 100644 index 000000000..32de918d3 --- /dev/null +++ b/src/services/NamingConvention.js @@ -0,0 +1,248 @@ +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const SEPARATOR_TYPES = { + DASH: { value: "-", label: "- (tiret)" }, + DOT: { value: ".", label: ". (point)" }, + UNDERSCORE: { value: "_", label: "_ (trait de soulignement)" }, +}; + +export const SEGMENT_TYPES = { + N_CHARS: "n_chars", + BOUNDED: "bounded", + LIST: "list", +}; + +export const RULE_MODES = { + SOFT: "soft", // warn but allow + STRICT: "strict", // block until renamed +}; + +// ─── Regex Builder ──────────────────────────────────────────────────────────── + +/** + * Build a regex pattern from a rule definition. + * @param {Object} rule - The naming rule object + * @returns {RegExp} + */ +export function buildRuleRegex(rule) { + const sep = escapeRegex(rule.separator); + const parts = rule.segments.map((seg) => buildSegmentPattern(seg)); + const pattern = parts.join(sep); + // Allow optional file extension at the end + return new RegExp(`^${pattern}(\\.[a-zA-Z0-9]+)?$`); +} + +function buildSegmentPattern(segment) { + switch (segment.type) { + case SEGMENT_TYPES.N_CHARS: { + const { min, max } = segment.config; + if (min && max) return `[\\w\\-]{${min},${max}}`; + if (min) return `[\\w\\-]{${min},}`; + if (max) return `[\\w\\-]{1,${max}}`; + return `[\\w\\-]+`; + } + case SEGMENT_TYPES.BOUNDED: { + const { min, max } = segment.config; + // Numeric range + return `(${generateNumericRange(min, max)})`; + } + case SEGMENT_TYPES.LIST: { + const values = segment.config.values || []; + if (!values.length) return `[\\w\\-]+`; + return `(${values.map(escapeRegex).join("|")})`; + } + default: + return `[\\w\\-]+`; + } +} + +/** + * Generate a regex alternation for a numeric range like 1-50. + * Falls back to simple \d+ if range is too large. + */ +function generateNumericRange(min, max) { + if (!min && !max) return `\\d+`; + const mn = parseInt(min, 10); + const mx = parseInt(max, 10); + if (isNaN(mn) || isNaN(mx) || mx - mn > 200) return `\\d+`; + const nums = []; + for (let i = mn; i <= mx; i++) nums.push(String(i)); + return nums.join("|"); +} + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// ─── Validation ─────────────────────────────────────────────────────────────── + +/** + * Validate a file name against a naming rule. + * Returns { valid: bool, reason: string|null } + */ +export function validateFileName(fileName, rule) { + // Strip extension for validation + const nameWithoutExt = stripExtension(fileName); + const regex = buildRuleRegex(rule); + const valid = regex.test(nameWithoutExt) || regex.test(fileName); + return { + valid, + reason: valid + ? null + : `Le nom "${fileName}" ne correspond pas au format attendu : ${buildHumanReadablePattern(rule)}`, + }; +} + +/** + * Validate a list of files against a rule. + * Returns array of { file, valid, reason } + */ +export function validateFiles(files, rule) { + return files.map((file) => ({ + file, + ...validateFileName(file.name || file.file_name, rule), + })); +} + +/** + * Build a human-readable pattern description for display in UI. + * e.g. "a.1.a" or "(1-50).a.1.a.[liste]" + */ +export function buildHumanReadablePattern(rule) { + const sep = rule.separator; + return rule.segments + .map((seg) => { + switch (seg.type) { + case SEGMENT_TYPES.N_CHARS: + return "a"; + case SEGMENT_TYPES.BOUNDED: + return `(${seg.config.min}-${seg.config.max})`; + case SEGMENT_TYPES.LIST: + return "[liste]"; + default: + return "?"; + } + }) + .join(sep); +} + +// ─── Suggestion Engine ──────────────────────────────────────────────────────── + +/** + * Suggest renamed variants for a non-conforming file. + * Returns up to 3 suggestions. + */ +export function suggestRenames(fileName, rule) { + const nameWithoutExt = stripExtension(fileName); + const ext = getExtension(fileName); + const sep = rule.separator; + const parts = nameWithoutExt.split(/[\.\-_]/); + + const suggestions = []; + + // Strategy 1: pad/trim parts to match segment count + if (rule.segments.length > 0) { + const padded = rule.segments.map((seg, i) => { + const part = parts[i] || getDefaultValue(seg); + return conformPart(part, seg); + }); + suggestions.push(padded.join(sep) + (ext ? `.${ext}` : "")); + } + + // Strategy 2: insert index + if (rule.segments.length > 1) { + const variant = [ + ...(suggestions[0] + ? suggestions[0].replace(ext ? `.${ext}` : "", "").split(sep) + : rule.segments.map((s) => getDefaultValue(s))), + ]; + variant[0] = (variant[0] || "A") + "2"; + suggestions.push(variant.join(sep) + (ext ? `.${ext}` : "")); + } + + // Deduplicate & filter valid + return [...new Set(suggestions)].filter((s) => s !== fileName && s.trim() !== ""); +} + +function conformPart(part, segment) { + switch (segment.type) { + case SEGMENT_TYPES.N_CHARS: { + const { min, max } = segment.config; + if (max && part.length > max) return part.slice(0, max); + if (min && part.length < min) return part.padEnd(min, "0"); + return part; + } + case SEGMENT_TYPES.BOUNDED: { + const num = parseInt(part, 10); + const mn = parseInt(segment.config.min, 10); + const mx = parseInt(segment.config.max, 10); + if (isNaN(num)) return String(mn || 1); + if (!isNaN(mn) && num < mn) return String(mn); + if (!isNaN(mx) && num > mx) return String(mx); + return String(num); + } + case SEGMENT_TYPES.LIST: { + const values = segment.config.values || []; + if (!values.length) return part; + return values.includes(part) ? part : values[0]; + } + default: + return part; + } +} + +function getDefaultValue(segment) { + switch (segment.type) { + case SEGMENT_TYPES.N_CHARS: + return "A"; + case SEGMENT_TYPES.BOUNDED: + return String(segment.config.min || 1); + case SEGMENT_TYPES.LIST: + return (segment.config.values || ["val"])[0]; + default: + return "x"; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function stripExtension(fileName) { + const lastDot = fileName.lastIndexOf("."); + if (lastDot === -1) return fileName; + return fileName.slice(0, lastDot); +} + +export function getExtension(fileName) { + const lastDot = fileName.lastIndexOf("."); + if (lastDot === -1) return ""; + return fileName.slice(lastDot + 1); +} + +/** + * Check if a folder has an active naming rule. + * Returns the matching rule or null. + */ +export function getActiveRuleForFolder(folder, rules) { + // Direct assignment + const direct = rules.find((r) => r.folder_ids?.includes(folder.id) && r.active); + if (direct) return direct; + + // Recursive rules from parent folders + const recursive = rules.find( + (r) => + r.recursive && + r.active && + r.folder_ids?.some((fid) => isAncestor(fid, folder, /* tree */ [])), + ); + return recursive || null; +} + +function isAncestor(ancestorId, folder, _tree) { + // Traverse parent_id chain — tree traversal handled at store level + let current = folder; + while (current?.parent_id) { + if (current.parent_id === ancestorId) return true; + current = { id: current.parent_id, parent_id: null }; // simplified + } + return false; +} diff --git a/src/state/naming-convention.js b/src/state/naming-convention.js new file mode 100644 index 000000000..07f4e1c56 --- /dev/null +++ b/src/state/naming-convention.js @@ -0,0 +1,231 @@ +/** + * Naming Convention Store (Pinia) + * Manages naming rules state, CRUD, and folder associations. + * + * Assumes BIMData API base: https://api.bimdata.io + * Endpoints are mocked here — adapt to real platform-back routes when available. + */ + +// import { defineStore } from "pinia"; +import { ref, computed, toRaw } from "vue"; +import { + validateFiles, + buildHumanReadablePattern, + RULE_MODES, +} from "../services/NamingConvention.js"; +import { useUser } from "./user.js"; + +// ─── Store ──────────────────────────────────────────────────────────────────── + +// State +const rules = ref([]); // NamingRule[] +const customLists = ref([]); // CustomList[] +const loading = ref(false); +const error = ref(null); + +// ── Getters ────────────────────────────────────────────────────────────── + +const getRulesForFolder = computed(() => (folderId) => { + return rules.value.filter((r) => r.active && r.folder_ids?.includes(folderId)); +}); + +const getRecursiveRulesForFolder = computed(() => (folder) => { + return rules.value.filter( + (r) => r.active && r.recursive && r.folder_ids?.some((fid) => isAncestorId(fid, folder)), + ); +}); + +function isAncestorId(ancestorId, folder) { + // Walk up parent_id chain in current rules context + // In real usage, pass folder tree from GED store + let cur = folder; + while (cur?.parent_id) { + if (cur.parent_id === ancestorId) return true; + cur = { id: cur.parent_id, parent_id: null }; + } + return false; +} + +// All rules that apply to a folder (direct + recursive parents) +const getEffectiveRulesForFolder = computed(() => (folder) => { + const direct = getRulesForFolder.value(folder.id); + const recursive = getRecursiveRulesForFolder.value(folder); + // Merge, deduplicate by id + const all = [...direct, ...recursive]; + return all.filter((r, i) => all.findIndex((x) => x.id === r.id) === i); +}); + +// ── Actions ─────────────────────────────────────────────────────────────── + +/** + * Load rules for a project from the API. + * Route: GET /cloud/:cloudPk/project/:projectPk/naming-rules + */ +async function fetchRules(cloudPk, projectPk, apiClient) { + loading.value = true; + error.value = null; + try { + // When platform-back implements this endpoint: + // const data = await apiClient.get(`/cloud/${cloudPk}/project/${projectPk}/naming-rules`); + // rules.value = data; + + // For now: load from localStorage as dev stub + const key = `naming_rules_${projectPk}`; + const stored = localStorage.getItem(key); + rules.value = stored ? JSON.parse(stored) : []; + console.log("fetchRules →", rules.value); + } catch (e) { + error.value = e.message; + } finally { + loading.value = false; + } +} + +/** + * Persist rules to API. + * Route: POST /cloud/:cloudPk/project/:projectPk/naming-rules + */ +async function saveRule(cloudPk, projectPk, ruleData, apiClient) { + const { user } = useUser(); + loading.value = true; + try { + const id = ruleData.id || crypto.randomUUID(); + const rule = { + ...toRaw(ruleData), + id, + folder_ids: toRaw(ruleData.folder_ids) || [], + segments: toRaw(ruleData.segments) || [], + creator_id: ruleData.creator_id || user.value?.id, + creator_name: + ruleData.creator_name || `${user.value?.firstname} ${user.value?.lastname}`.trim(), + creator_email: ruleData.creator_email || user.value?.email, + creator_picture: ruleData.creator_picture || user.value?.profile_picture, + created_at: ruleData.created_at || new Date().toISOString(), + updated_at: new Date().toISOString(), + pattern: buildHumanReadablePattern(ruleData), + active: true, + }; + + const idx = rules.value.findIndex((r) => r.id === rule.id); + if (idx >= 0) { + rules.value[idx] = rule; + } else { + rules.value.push(rule); + } + + // Stub: persist locally + localStorage.setItem(`naming_rules_${projectPk}`, JSON.stringify(rules.value)); + + return rule; + } catch (e) { + error.value = e.message; + throw e; + } finally { + loading.value = false; + } +} + +/** + * Delete a rule. + * Route: DELETE /cloud/:cloudPk/project/:projectPk/naming-rules/:id + */ +async function deleteRule(ruleId, projectPk) { + rules.value = rules.value.filter((r) => r.id !== ruleId); + localStorage.setItem(`naming_rules_${projectPk}`, JSON.stringify(rules.value)); +} + +/** + * Assign a rule to one or more folders. + */ +function assignRuleToFolder(ruleId, folderId) { + const rule = rules.value.find((r) => r.id === ruleId); + if (!rule) return; + if (!rule.folder_ids) rule.folder_ids = []; + if (!rule.folder_ids.includes(folderId)) { + rule.folder_ids.push(folderId); + } +} + +function unassignRuleFromFolder(ruleId, folderId) { + const rule = rules.value.find((r) => r.id === ruleId); + if (!rule) return; + rule.folder_ids = (rule.folder_ids || []).filter((id) => id !== folderId); +} + +// ── Custom Lists ────────────────────────────────────────────────────────── + +async function fetchCustomLists(projectPk) { + const key = `naming_lists_${projectPk}`; + const stored = localStorage.getItem(key); + customLists.value = stored ? JSON.parse(stored) : []; +} + +function saveCustomList(projectPk, listData) { + const list = { + id: listData.id || crypto.randomUUID(), + ...listData, + }; + const idx = customLists.value.findIndex((l) => l.id === list.id); + if (idx >= 0) { + customLists.value[idx] = list; + } else { + customLists.value.push(list); + } + localStorage.setItem(`naming_lists_${projectPk}`, JSON.stringify(customLists.value)); + return list; +} + +// ── File Validation ─────────────────────────────────────────────────────── + +/** + * Check files before upload against rules of the target folder. + * Returns { pass: bool, violations: [], strictViolations: [] } + */ +function checkFilesBeforeUpload(files, targetFolder) { + const effectiveRules = getEffectiveRulesForFolder.value(targetFolder); + if (!effectiveRules.length) return { pass: true, violations: [], strictViolations: [] }; + + const violations = []; + const strictViolations = []; + + for (const rule of effectiveRules) { + const results = validateFiles(files, rule); + const invalid = results.filter((r) => !r.valid); + for (const inv of invalid) { + const entry = { ...inv, rule }; + violations.push(entry); + if (rule.mode === RULE_MODES.STRICT) { + strictViolations.push(entry); + } + } + } + + return { + pass: violations.length === 0, + violations, + strictViolations, + hasStrict: strictViolations.length > 0, + }; +} + +export function useNamingConventionStore() { + return { + // State + rules, + customLists, + loading, + error, + // Getters + getRulesForFolder, + getEffectiveRulesForFolder, + // Actions + fetchRules, + saveRule, + deleteRule, + assignRuleToFolder, + unassignRuleFromFolder, + fetchCustomLists, + saveCustomList, + checkFilesBeforeUpload, + }; +}