From 7d609c808562a7859d03cb7d811fe1090bfceacd Mon Sep 17 00:00:00 2001 From: Gaelle Date: Thu, 21 May 2026 17:51:40 +0200 Subject: [PATCH] test feature naming template --- .../files/files-manager/FilesManager.vue | 83 +++- .../FilesManagerActions.vue | 28 +- .../GedFileUpload.integration.vue | 172 +++++++ .../NamingConventionPanel.vue | 449 +++++++++++++++++ .../files-naming-template/NamingRulesList.vue | 405 ++++++++++++++++ .../NamingSegmentRow.vue | 273 +++++++++++ .../files-naming-template/NamingTemplate.vue | 158 ++++++ .../NamingViolationBanner.vue | 315 ++++++++++++ .../NamingViolationModal.vue | 458 ++++++++++++++++++ .../files-naming-template/SPEC.md | 214 ++++++++ .../namingConventionService.js | 256 ++++++++++ .../namingConventionService.test.js | 250 ++++++++++ .../namingConventionStore.js | 216 +++++++++ .../useNamingConvention.js | 183 +++++++ .../file-actions-cell/FileActionsCell.vue | 81 ++-- .../files/folder-table/FoldersTable.vue | 2 + src/i18n/lang/fr.json | 6 +- 17 files changed, 3495 insertions(+), 54 deletions(-) create mode 100644 src/components/specific/files/files-manager/files-naming-template/GedFileUpload.integration.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingConventionPanel.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingRulesList.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingSegmentRow.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingTemplate.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingViolationBanner.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/NamingViolationModal.vue create mode 100644 src/components/specific/files/files-manager/files-naming-template/SPEC.md create mode 100644 src/components/specific/files/files-manager/files-naming-template/namingConventionService.js create mode 100644 src/components/specific/files/files-manager/files-naming-template/namingConventionService.test.js create mode 100644 src/components/specific/files/files-manager/files-naming-template/namingConventionStore.js create mode 100644 src/components/specific/files/files-manager/files-naming-template/useNamingConvention.js diff --git a/src/components/specific/files/files-manager/FilesManager.vue b/src/components/specific/files/files-manager/FilesManager.vue index d92904645..186e2dca1 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,24 @@ :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 +542,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 +613,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 +648,7 @@ export default { showAccessManager.value = false; showVisaManager.value = false; showVersioningManager.value = false; + showNamingTemplateManager.value = false; } }; const closeTagManager = () => { @@ -806,7 +880,10 @@ export default { searchText, selectedFileTab, selection, + rules, + projectPk, showAccessManager, + showNamingTemplateManager, showDeleteModal, showTagManager, showVersioningManager, @@ -816,6 +893,7 @@ export default { closeAccessManager, closeDeleteModal, closeSidePanel, + closeNamingTemplateManager, closeTagManager, closeVersioningManager, closeVisaManager, @@ -829,6 +907,8 @@ export default { goVisasView, isFullTotal, moveFiles, + onAssignmentSaved, + startEditRule, onFileSelected, openAccessManager, openFileDeleteModal, @@ -838,6 +918,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-template/GedFileUpload.integration.vue b/src/components/specific/files/files-manager/files-naming-template/GedFileUpload.integration.vue new file mode 100644 index 000000000..074e50747 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/GedFileUpload.integration.vue @@ -0,0 +1,172 @@ + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingConventionPanel.vue b/src/components/specific/files/files-manager/files-naming-template/NamingConventionPanel.vue new file mode 100644 index 000000000..da2c3d5cc --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingConventionPanel.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingRulesList.vue b/src/components/specific/files/files-manager/files-naming-template/NamingRulesList.vue new file mode 100644 index 000000000..1cef88cd3 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingRulesList.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingSegmentRow.vue b/src/components/specific/files/files-manager/files-naming-template/NamingSegmentRow.vue new file mode 100644 index 000000000..e20c770f3 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingSegmentRow.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingTemplate.vue b/src/components/specific/files/files-manager/files-naming-template/NamingTemplate.vue new file mode 100644 index 000000000..1c3d7c0ea --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingTemplate.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingViolationBanner.vue b/src/components/specific/files/files-manager/files-naming-template/NamingViolationBanner.vue new file mode 100644 index 000000000..0aeed4771 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingViolationBanner.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/NamingViolationModal.vue b/src/components/specific/files/files-manager/files-naming-template/NamingViolationModal.vue new file mode 100644 index 000000000..28b74a113 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/NamingViolationModal.vue @@ -0,0 +1,458 @@ + + + + + diff --git a/src/components/specific/files/files-manager/files-naming-template/SPEC.md b/src/components/specific/files/files-manager/files-naming-template/SPEC.md new file mode 100644 index 000000000..57c7a1417 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/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-template/namingConventionService.js b/src/components/specific/files/files-manager/files-naming-template/namingConventionService.js new file mode 100644 index 000000000..3690bb1d2 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/namingConventionService.js @@ -0,0 +1,256 @@ +/** + * Naming Convention Service + * Handles rule creation, validation, and enforcement logic + * for BIMData GED file naming conventions. + */ + +// ─── 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/components/specific/files/files-manager/files-naming-template/namingConventionService.test.js b/src/components/specific/files/files-manager/files-naming-template/namingConventionService.test.js new file mode 100644 index 000000000..47a306571 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/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-manager/files-naming-template/namingConventionStore.js b/src/components/specific/files/files-manager/files-naming-template/namingConventionStore.js new file mode 100644 index 000000000..0a78e2f90 --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/namingConventionStore.js @@ -0,0 +1,216 @@ +/** + * 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 } from "vue"; +import { validateFiles, buildHumanReadablePattern, RULE_MODES } from "./namingConventionService.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) : []; + } 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) { + loading.value = true; + try { + const rule = { + id: ruleData.id || crypto.randomUUID(), + ...ruleData, + 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, + }; +} diff --git a/src/components/specific/files/files-manager/files-naming-template/useNamingConvention.js b/src/components/specific/files/files-manager/files-naming-template/useNamingConvention.js new file mode 100644 index 000000000..67ffe20ec --- /dev/null +++ b/src/components/specific/files/files-manager/files-naming-template/useNamingConvention.js @@ -0,0 +1,183 @@ +/** + * useNamingConvention composable + * + * Plug this into any GED upload flow to intercept files, check naming rules, + * and surface the right UI (banner or blocking modal) depending on rule mode. + * + * Usage example (in a GED upload component): + * + * const { checkBeforeUpload, violations, strictViolations, clearViolations } = + * useNamingConvention(currentFolder); + * + * // In your upload handler: + * async function handleFiles(files) { + * const result = await checkBeforeUpload(files); + * if (result.blocked) return; // user must resolve strict violations first + * // proceed with upload + * } + */ + +import { ref, computed } from "vue"; +import { useNamingConventionStore } from "./namingConventionStore.js"; +import { RULE_MODES } from "./namingConventionService.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 = []; + } + + return { + // State + violations, + strictViolations, + showBanner, + showModal, + hasSoftViolations, + hasStrictViolations, + // Core + checkBeforeUpload, + // Modal + onModalConfirm, + onModalCancel, + // Banner + onBannerDismiss, + onBannerApplyRenames, + // Utils + clearViolations, + }; +} 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/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 +}