Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c66fe6518 | |||
| 5d305ccc4c | |||
| e9065f2410 |
@@ -583,6 +583,8 @@ Cette action est irréversible.</value>
|
|||||||
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
|
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
|
||||||
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
|
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
|
||||||
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
|
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
|
||||||
|
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclure les liens de partage</value></data>
|
||||||
|
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
|
||||||
<!-- Phase 19: App Registration & Removal -->
|
<!-- Phase 19: App Registration & Removal -->
|
||||||
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data>
|
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data>
|
||||||
<data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</value></data>
|
<data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</value></data>
|
||||||
@@ -687,6 +689,7 @@ Cette action est irréversible.</value>
|
|||||||
<data name="report.text.generated" xml:space="preserve"><value>Généré</value></data>
|
<data name="report.text.generated" xml:space="preserve"><value>Généré</value></data>
|
||||||
<data name="report.text.generated_colon" xml:space="preserve"><value>Généré :</value></data>
|
<data name="report.text.generated_colon" xml:space="preserve"><value>Généré :</value></data>
|
||||||
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
|
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
|
||||||
|
<data name="report.text.empty_group" xml:space="preserve"><value>Groupe vide</value></data>
|
||||||
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
|
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
|
||||||
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
|
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
|
||||||
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
|
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
|
||||||
@@ -784,4 +787,84 @@ Cette action est irréversible.</value>
|
|||||||
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
|
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
|
||||||
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
|
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
|
||||||
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
|
<data name="report.text.entries_unit" xml:space="preserve"><value>entrées</value></data>
|
||||||
|
<!-- Textes d'aide / boutons info -->
|
||||||
|
<data name="help.perm.simplified.title" xml:space="preserve"><value>Mode simplifié</value></data>
|
||||||
|
<data name="help.perm.simplified.body" xml:space="preserve"><value>Regroupe les permissions brutes SharePoint en libellés lisibles (Propriétaire, Éditeur, Contributeur, Lecteur, Lecture seule) et colore les lignes par niveau de risque. Utile pour un aperçu rapide de la sécurité sans jargon technique.</value></data>
|
||||||
|
<data name="help.perm.merge.title" xml:space="preserve"><value>Fusionner les permissions</value></data>
|
||||||
|
<data name="help.perm.merge.body" xml:space="preserve"><value>Lorsqu'activé, les entrées de permission multiples pour le même utilisateur ou groupe sont regroupées en une seule ligne dans l'export, réduisant la taille du rapport. Désactivez pour voir chaque permission individuellement.</value></data>
|
||||||
|
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Masquer les groupes système</value></data>
|
||||||
|
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Supprime les groupes système créés automatiquement par SharePoint (ex. « Excel Services Viewers », groupes « SharingLinks.* »). Ces groupes sont gérés en interne par SharePoint et ne sont généralement pas pertinents pour les audits d'accès.</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclure les liens de partage</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Supprime les entrées de lien de partage des résultats et des exports (ex. « Tout le monde avec le lien », liens à l'échelle de l'organisation). Utile pour ne conserver que les permissions directes des utilisateurs et groupes.</value></data>
|
||||||
|
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
|
||||||
|
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Supprime les entrées « Limited Access System Group For Web/List » des résultats et des exports. SharePoint crée ces groupes automatiquement lorsqu'un utilisateur a accès à un élément spécifique ; ils sont rarement pertinents pour les audits d'accès.</value></data>
|
||||||
|
<data name="help.perm.inherited.title" xml:space="preserve"><value>Inclure les permissions héritées</value></data>
|
||||||
|
<data name="help.perm.inherited.body" xml:space="preserve"><value>Par défaut, seuls les objets avec des permissions uniques (rompues) sont affichés. Activez pour inclure les objets qui héritent les permissions d'un parent et obtenir une vue complète des accès.</value></data>
|
||||||
|
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Mode de fractionnement de l'export</value></data>
|
||||||
|
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Fichier unique : tous les résultats dans un seul fichier CSV ou HTML.
|
||||||
|
|
||||||
|
Fractionner par site : crée un fichier séparé pour chaque collection de sites. Utile pour les grandes tenances multi-sites.</value></data>
|
||||||
|
<data name="help.search.title" xml:space="preserve"><value>Recherche de fichiers KQL</value></data>
|
||||||
|
<data name="help.search.body" xml:space="preserve"><value>Recherche des fichiers dans vos sites SharePoint via KQL (Keyword Query Language). Le champ mot-clé est optionnel — laissez-le vide pour retourner tous les fichiers correspondant aux filtres actifs. Combinez les filtres de date, auteur et bibliothèque pour affiner les résultats.</value></data>
|
||||||
|
<data name="help.search.regex.title" xml:space="preserve"><value>Filtre regex sur le nom de fichier</value></data>
|
||||||
|
<data name="help.search.regex.body" xml:space="preserve"><value>Filtre les résultats côté client avec une expression régulière .NET appliquée aux noms de fichiers. Exemple : \.pdf$ correspond uniquement aux PDF. Laissez vide pour ignorer ce filtre. L'expression est insensible à la casse.</value></data>
|
||||||
|
<data name="help.versions.policy.title" xml:space="preserve"><value>Politique de nettoyage des versions</value></data>
|
||||||
|
<data name="help.versions.policy.body" xml:space="preserve"><value>Supprime définitivement les anciennes versions de documents des bibliothèques SharePoint. Seules les N versions les plus récentes sont conservées — les versions plus anciennes sont supprimées de façon permanente et ne peuvent pas être récupérées. Effectuez d'abord une analyse pour prévisualiser les suppressions.</value></data>
|
||||||
|
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Conserver la première version</value></data>
|
||||||
|
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Conserve toujours la version 1.0 (originale) de chaque document, indépendamment du paramètre « Conserver les N dernières ». Utile pour maintenir une trace de l'état initial du document.</value></data>
|
||||||
|
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirmer avant suppression</value></data>
|
||||||
|
<data name="help.versions.confirm.body" xml:space="preserve"><value>Lorsqu'activé, une boîte de dialogue de confirmation apparaît pour chaque fichier avant la suppression des versions. Décochez pour un traitement en lot sans intervention.</value></data>
|
||||||
|
<data name="help.dup.criteria.title" xml:space="preserve"><value>Critères de détection des doublons</value></data>
|
||||||
|
<data name="help.dup.criteria.body" xml:space="preserve"><value>Deux éléments sont identifiés comme doublons quand leurs noms correspondent ET que tous les critères supplémentaires cochés correspondent également. Plus de critères cochés = moins de groupes, mais plus précis. Nom uniquement : trouve les fichiers avec le même nom, quel que soit leur contenu.</value></data>
|
||||||
|
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Inclure le dossier source</value></data>
|
||||||
|
<data name="help.transfer.incsource.body" xml:space="preserve"><value>Lorsqu'activé, le dossier source lui-même est recréé à la destination (ex. transférer « Rapports » crée un dossier « Rapports/ » à la cible). Lorsque désactivé, seul le contenu du dossier est transféré — utile pour fusionner du contenu dans un dossier existant.</value></data>
|
||||||
|
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copier uniquement le contenu</value></data>
|
||||||
|
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>Lorsqu'activé, seuls les fichiers et sous-dossiers à l'intérieur du dossier sélectionné sont transférés — le dossier lui-même n'est pas recréé à la destination.</value></data>
|
||||||
|
<data name="help.transfer.conflict.title" xml:space="preserve"><value>Politique de conflit de fichiers</value></data>
|
||||||
|
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Définit ce qui se passe quand un fichier du même nom existe déjà à la destination :
|
||||||
|
|
||||||
|
• Ignorer — laisser le fichier destination inchangé.
|
||||||
|
• Écraser — remplacer le fichier destination par le fichier source.
|
||||||
|
• Renommer — conserver les deux en ajoutant un suffixe numérique au fichier transféré.</value></data>
|
||||||
|
<data name="help.bulkmembers.title" xml:space="preserve"><value>Ajout de membres en masse — Format CSV</value></data>
|
||||||
|
<data name="help.bulkmembers.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes (en-têtes obligatoires, ordre libre) :
|
||||||
|
• GroupName — le nom exact du groupe SharePoint
|
||||||
|
• Email — l'adresse e-mail de l'utilisateur
|
||||||
|
• Role — Member, Owner ou Visitor
|
||||||
|
|
||||||
|
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
|
||||||
|
<data name="help.bulksites.title" xml:space="preserve"><value>Création de sites en masse — Format CSV</value></data>
|
||||||
|
<data name="help.bulksites.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes :
|
||||||
|
• Name — le nom d'affichage du nouveau site
|
||||||
|
• Alias — alias d'URL (sans espaces ; fait partie de l'URL du site)
|
||||||
|
• Type — TeamSite ou CommunicationSite
|
||||||
|
• Owners — liste d'adresses e-mail des propriétaires séparées par des virgules
|
||||||
|
|
||||||
|
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
|
||||||
|
<data name="help.folderstruct.title" xml:space="preserve"><value>Créer une structure de dossiers — Format CSV</value></data>
|
||||||
|
<data name="help.folderstruct.body" xml:space="preserve"><value>Crée une hiérarchie de dossiers dans une bibliothèque SharePoint à partir d'un fichier CSV. Chaque ligne définit un chemin avec jusqu'à 4 niveaux (Level1–Level4). Laissez les colonnes des niveaux inférieurs vides pour des chemins plus courts.
|
||||||
|
|
||||||
|
Exemple : Contrats | 2024 | T1 | (vide)
|
||||||
|
Crée : Bibliothèque / Contrats / 2024 / T1</value></data>
|
||||||
|
<data name="help.templates.capture.title" xml:space="preserve"><value>Capturer un modèle de site</value></data>
|
||||||
|
<data name="help.templates.capture.body" xml:space="preserve"><value>Enregistre la structure du site sélectionné (bibliothèques, dossiers, permissions, paramètres et logo) comme modèle réutilisable stocké localement. Le site source n'est pas modifié.
|
||||||
|
|
||||||
|
Sélectionnez les éléments à capturer avec les cases à cocher ci-dessus.</value></data>
|
||||||
|
<data name="help.templates.apply.title" xml:space="preserve"><value>Appliquer le modèle à un nouveau site</value></data>
|
||||||
|
<data name="help.templates.apply.body" xml:space="preserve"><value>Crée un nouveau site SharePoint et reproduit la structure du modèle sélectionné — bibliothèques, dossiers, permissions, paramètres et logo. Le modèle source et le site d'origine ne sont pas affectés.
|
||||||
|
|
||||||
|
Fournissez un nom d'affichage et un alias d'URL avant de cliquer sur Appliquer.</value></data>
|
||||||
|
<data name="help.audit.mode.title" xml:space="preserve"><value>Mode Recherche vs Mode Navigation</value></data>
|
||||||
|
<data name="help.audit.mode.body" xml:space="preserve"><value>Mode Recherche : tapez un nom ou e-mail pour trouver un utilisateur via Azure AD. Les résultats apparaissent dans une liste — cliquez pour sélectionner.
|
||||||
|
|
||||||
|
Mode Navigation : charge tous les utilisateurs du répertoire de la tenant. Utilisez le filtre pour trouver un utilisateur, puis double-cliquez pour l'ajouter à l'audit.</value></data>
|
||||||
|
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>Audit d'accès vs Audit des permissions</value></data>
|
||||||
|
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>L'onglet Permissions analyse les objets (bibliothèques, dossiers, éléments) pour montrer qui y a accès.
|
||||||
|
|
||||||
|
Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et il trouve chaque objet auquel ils peuvent accéder — y compris via des groupes SharePoint ou Active Directory.</value></data>
|
||||||
|
<data name="help.storage.hidden.title" xml:space="preserve"><value>Bibliothèques masquées</value></data>
|
||||||
|
<data name="help.storage.hidden.body" xml:space="preserve"><value>Analyse les bibliothèques SharePoint cachées dans la navigation normale du site (ex. Site Assets, Style Library, Form Templates). Elles peuvent consommer beaucoup d'espace et sont souvent oubliées dans les audits de routine.</value></data>
|
||||||
|
<data name="help.storage.preservation.title" xml:space="preserve"><value>Bibliothèque de conservation</value></data>
|
||||||
|
<data name="help.storage.preservation.body" xml:space="preserve"><value>Bibliothèque SharePoint cachée qui stocke les versions de documents modifiés ou supprimés pendant qu'une politique de rétention Microsoft Purview / Microsoft 365 Compliance est active. Elle peut croître considérablement sans être visible pour les utilisateurs du site.</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -583,6 +583,8 @@ This cannot be undone.</value>
|
|||||||
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
|
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
|
||||||
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
|
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
|
||||||
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
|
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
|
||||||
|
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclude sharing links</value></data>
|
||||||
|
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclude system groups (Limited Access)</value></data>
|
||||||
<!-- Phase 19: App Registration & Removal -->
|
<!-- Phase 19: App Registration & Removal -->
|
||||||
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
|
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
|
||||||
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
|
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
|
||||||
@@ -687,6 +689,7 @@ This cannot be undone.</value>
|
|||||||
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
|
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
|
||||||
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
|
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
|
||||||
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
|
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
|
||||||
|
<data name="report.text.empty_group" xml:space="preserve"><value>Empty group</value></data>
|
||||||
<data name="report.text.link" xml:space="preserve"><value>Link</value></data>
|
<data name="report.text.link" xml:space="preserve"><value>Link</value></data>
|
||||||
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
|
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
|
||||||
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
|
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
|
||||||
@@ -784,4 +787,84 @@ This cannot be undone.</value>
|
|||||||
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
|
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
|
||||||
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
|
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
|
||||||
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
|
<data name="report.text.entries_unit" xml:space="preserve"><value>entries</value></data>
|
||||||
|
<!-- Help / Info button strings -->
|
||||||
|
<data name="help.perm.simplified.title" xml:space="preserve"><value>Simplified Permissions Mode</value></data>
|
||||||
|
<data name="help.perm.simplified.body" xml:space="preserve"><value>Groups raw SharePoint permissions into readable labels (Owner, Editor, Contributor, Reader, View-Only) and color-codes rows by risk level. Useful for a quick security overview without permission-level jargon.</value></data>
|
||||||
|
<data name="help.perm.merge.title" xml:space="preserve"><value>Merge Permissions</value></data>
|
||||||
|
<data name="help.perm.merge.body" xml:space="preserve"><value>When enabled, multiple permission entries for the same user or group are consolidated into a single row in the export, reducing report size. Disable to see every individual permission assignment separately.</value></data>
|
||||||
|
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Hide System Groups</value></data>
|
||||||
|
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Removes automatically-created SharePoint system groups from results (e.g. "Excel Services Viewers", "SharingLinks.*" groups). These groups are managed internally by SharePoint and are typically not relevant for user access audits.</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclude Sharing Links</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Removes sharing link entries from results and exports (e.g. "Anyone with the link", organisation-wide links). Useful when you only care about direct user and group permissions.</value></data>
|
||||||
|
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclude System Groups (Limited Access)</value></data>
|
||||||
|
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Removes "Limited Access System Group For Web/List" entries from results and exports. SharePoint creates these automatically when a user has item-level access; they are rarely relevant for user access audits.</value></data>
|
||||||
|
<data name="help.perm.inherited.title" xml:space="preserve"><value>Include Inherited Permissions</value></data>
|
||||||
|
<data name="help.perm.inherited.body" xml:space="preserve"><value>By default only objects with unique (broken) permissions are reported. Enable this to also include objects that inherit permissions from a parent, giving a complete picture of who can access every item.</value></data>
|
||||||
|
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Export Split Mode</value></data>
|
||||||
|
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Single File: all results are saved in one CSV or HTML file.
|
||||||
|
|
||||||
|
Split by Site: creates a separate file for each site collection. Useful when auditing large multi-site tenants to keep individual files manageable.</value></data>
|
||||||
|
<data name="help.search.title" xml:space="preserve"><value>KQL File Search</value></data>
|
||||||
|
<data name="help.search.body" xml:space="preserve"><value>Searches files across your SharePoint sites using KQL (Keyword Query Language). The keyword field is optional — leave it empty to return all files matching only the active filters. Combine date range, author, and library filters to narrow results.</value></data>
|
||||||
|
<data name="help.search.regex.title" xml:space="preserve"><value>Filename Regex Filter</value></data>
|
||||||
|
<data name="help.search.regex.body" xml:space="preserve"><value>Post-filters results client-side using a .NET regular expression matched against file names. Example: \.pdf$ matches only PDF files. Leave blank to skip this filter. The expression is case-insensitive.</value></data>
|
||||||
|
<data name="help.versions.policy.title" xml:space="preserve"><value>Version Cleanup Policy</value></data>
|
||||||
|
<data name="help.versions.policy.body" xml:space="preserve"><value>Permanently deletes old document versions from SharePoint libraries. Only the N most recent versions are kept — older ones are removed permanently and cannot be recovered. Run a preview scan first to see what will be deleted.</value></data>
|
||||||
|
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Keep First Version</value></data>
|
||||||
|
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Always preserves version 1.0 (the original) of each document, regardless of the "Keep Last N" setting. Useful to maintain an audit trail of a document's initial state.</value></data>
|
||||||
|
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirm Before Delete</value></data>
|
||||||
|
<data name="help.versions.confirm.body" xml:space="preserve"><value>When enabled, a confirmation dialog appears for each file before its versions are deleted. Uncheck for unattended batch processing.</value></data>
|
||||||
|
<data name="help.dup.criteria.title" xml:space="preserve"><value>Duplicate Matching Criteria</value></data>
|
||||||
|
<data name="help.dup.criteria.body" xml:space="preserve"><value>Two items are flagged as duplicates when their names match AND all checked additional criteria also match. More criteria checked = fewer groups, but more precise matches. Using name only finds files with the same filename anywhere in the site, regardless of content.</value></data>
|
||||||
|
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Include Source Folder</value></data>
|
||||||
|
<data name="help.transfer.incsource.body" xml:space="preserve"><value>When enabled, the source folder itself is recreated at the destination (e.g. transferring "Reports" creates a "Reports/" folder at the target). When disabled, only the contents inside the folder are transferred — useful when merging into an existing destination folder.</value></data>
|
||||||
|
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copy Folder Contents Only</value></data>
|
||||||
|
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>When enabled, only the files and subfolders inside the selected folder are transferred — the selected folder itself is not recreated at the destination.</value></data>
|
||||||
|
<data name="help.transfer.conflict.title" xml:space="preserve"><value>File Conflict Policy</value></data>
|
||||||
|
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Defines what happens when a file with the same name already exists at the destination:
|
||||||
|
|
||||||
|
• Skip — leave the existing destination file unchanged.
|
||||||
|
• Overwrite — replace the destination file with the source file.
|
||||||
|
• Rename — keep both by appending a number suffix to the transferred file's name.</value></data>
|
||||||
|
<data name="help.bulkmembers.title" xml:space="preserve"><value>Bulk Add Members — CSV Format</value></data>
|
||||||
|
<data name="help.bulkmembers.body" xml:space="preserve"><value>The CSV file must contain these columns (headers required, order is flexible):
|
||||||
|
• GroupName — the exact SharePoint group name
|
||||||
|
• Email — the user's email address
|
||||||
|
• Role — Member, Owner, or Visitor
|
||||||
|
|
||||||
|
Click "Load Example" to open a pre-filled sample file.</value></data>
|
||||||
|
<data name="help.bulksites.title" xml:space="preserve"><value>Bulk Create Sites — CSV Format</value></data>
|
||||||
|
<data name="help.bulksites.body" xml:space="preserve"><value>The CSV file must contain these columns:
|
||||||
|
• Name — the display name for the new site
|
||||||
|
• Alias — URL alias (no spaces; becomes part of the site URL)
|
||||||
|
• Type — TeamSite or CommunicationSite
|
||||||
|
• Owners — comma-separated list of owner email addresses
|
||||||
|
|
||||||
|
Click "Load Example" to open a pre-filled sample file.</value></data>
|
||||||
|
<data name="help.folderstruct.title" xml:space="preserve"><value>Create Folder Structure — CSV Format</value></data>
|
||||||
|
<data name="help.folderstruct.body" xml:space="preserve"><value>Creates a folder hierarchy inside a SharePoint library from a CSV file. Each row defines one folder path using up to 4 levels (Level1–Level4). Leave deeper level columns empty for shallower paths.
|
||||||
|
|
||||||
|
Example row: Contracts | 2024 | Q1 | (empty)
|
||||||
|
Creates: Library / Contracts / 2024 / Q1</value></data>
|
||||||
|
<data name="help.templates.capture.title" xml:space="preserve"><value>Capture Site Template</value></data>
|
||||||
|
<data name="help.templates.capture.body" xml:space="preserve"><value>Saves the currently selected site's structure (libraries, folder hierarchy, permissions, settings, and logo) as a reusable template stored locally on your machine. The source site is not modified in any way.
|
||||||
|
|
||||||
|
Select which elements to include using the checkboxes above.</value></data>
|
||||||
|
<data name="help.templates.apply.title" xml:space="preserve"><value>Apply Template to New Site</value></data>
|
||||||
|
<data name="help.templates.apply.body" xml:space="preserve"><value>Creates a brand-new SharePoint site and reproduces the structure captured in the selected template — including libraries, folders, permissions, settings, and logo. The source template and original site are not affected.
|
||||||
|
|
||||||
|
Provide a display name and URL alias for the new site before clicking Apply.</value></data>
|
||||||
|
<data name="help.audit.mode.title" xml:space="preserve"><value>Search vs Browse Mode</value></data>
|
||||||
|
<data name="help.audit.mode.body" xml:space="preserve"><value>Search Mode: type a name or email to find a specific user via Azure AD. Matching users appear in a list — click to select them for the audit.
|
||||||
|
|
||||||
|
Browse Mode: loads all users in your tenant directory. Use the filter box to narrow the list, then double-click a row to add the user to the audit.</value></data>
|
||||||
|
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>User Access Audit vs Permissions Audit</value></data>
|
||||||
|
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>The Permissions tab scans objects (libraries, folders, items) and shows who has access to each one.
|
||||||
|
|
||||||
|
This tab does the reverse: you select one or more users and it finds every object they can access — including access granted via SharePoint groups or Active Directory groups.</value></data>
|
||||||
|
<data name="help.storage.hidden.title" xml:space="preserve"><value>Hidden Libraries</value></data>
|
||||||
|
<data name="help.storage.hidden.body" xml:space="preserve"><value>Scans SharePoint libraries hidden from the site's normal navigation (e.g. Site Assets, Style Library, Form Templates). These can consume significant storage and are often overlooked in routine audits.</value></data>
|
||||||
|
<data name="help.storage.preservation.title" xml:space="preserve"><value>Preservation Hold Library</value></data>
|
||||||
|
<data name="help.storage.preservation.body" xml:space="preserve"><value>A hidden SharePoint library that stores versions of documents modified or deleted while a Microsoft Purview / Microsoft 365 Compliance retention policy is active. It can grow very large over time without being visible to normal site users.</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class BulkMemberService : IBulkMemberService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
|
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
|
||||||
@@ -83,7 +84,7 @@ public class BulkMemberService : IBulkMemberService
|
|||||||
{
|
{
|
||||||
// Resolve user by email
|
// Resolve user by email
|
||||||
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
|
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
|
||||||
if (user == null)
|
if (user?.Id == null)
|
||||||
throw new InvalidOperationException($"User not found: {email}");
|
throw new InvalidOperationException($"User not found: {email}");
|
||||||
|
|
||||||
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
|
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
|
||||||
@@ -138,13 +139,16 @@ public class BulkMemberService : IBulkMemberService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* not a group-connected site */ }
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex) { Log.Debug("Group lookup not available for {SiteUrl}: {Error}", siteUrl, ex.Message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Log.Debug("Could not resolve M365 group ID for {SiteUrl}: {Error}", siteUrl, ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public class BulkSiteService : IBulkSiteService
|
|||||||
var owners = ParseEmails(row.Owners);
|
var owners = ParseEmails(row.Owners);
|
||||||
var members = ParseEmails(row.Members);
|
var members = ParseEmails(row.Members);
|
||||||
|
|
||||||
|
if (owners.Count == 0)
|
||||||
|
throw new InvalidOperationException($"Team site '{row.Name}' requires at least one owner.");
|
||||||
|
|
||||||
var creationInfo = new TeamSiteCollectionCreationInformation
|
var creationInfo = new TeamSiteCollectionCreationInformation
|
||||||
{
|
{
|
||||||
DisplayName = row.Name,
|
DisplayName = row.Name,
|
||||||
@@ -88,6 +91,7 @@ public class BulkSiteService : IBulkSiteService
|
|||||||
membersGroup.Users.AddUser(user);
|
membersGroup.Users.AddUser(user);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||||
@@ -142,6 +146,7 @@ public class BulkSiteService : IBulkSiteService
|
|||||||
ownersGroup.Users.AddUser(user);
|
ownersGroup.Users.AddUser(user);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
|
Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
|
||||||
@@ -162,6 +167,7 @@ public class BulkSiteService : IBulkSiteService
|
|||||||
membersGroup.Users.AddUser(user);
|
membersGroup.Users.AddUser(user);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||||
|
|||||||
@@ -122,29 +122,38 @@ public class HtmlExportService
|
|||||||
AppendFilterInput(sb);
|
AppendFilterInput(sb);
|
||||||
AppendTableOpen(sb);
|
AppendTableOpen(sb);
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
sb.AppendLine($" <th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
sb.AppendLine("<tbody>");
|
sb.AppendLine("<tbody>");
|
||||||
|
|
||||||
int grpMemIdx = 0;
|
int grpMemIdx = 0;
|
||||||
foreach (var entry in entries)
|
int sectionIdx = 0;
|
||||||
|
var groups = entries.GroupBy(e => (e.ObjectType, e.Title, e.Url)).ToList();
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var sectionId = $"sec{sectionIdx++}";
|
||||||
|
var first = group.First();
|
||||||
|
var typeCss = ObjectTypeCss(group.Key.ObjectType);
|
||||||
|
var uniqueCss = first.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||||
|
var uniqueLbl = first.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||||
|
var count = group.Count();
|
||||||
|
|
||||||
|
sb.AppendLine($"<tr class=\"section-header collapsed\" data-section=\"{sectionId}\">");
|
||||||
|
sb.AppendLine($" <td colspan=\"5\"><span class=\"chevron\">▼</span><span class=\"{typeCss}\">{HtmlEncode(group.Key.ObjectType)}</span> <strong>{HtmlEncode(group.Key.Title)}</strong> <a href=\"{HtmlEncode(group.Key.Url)}\" target=\"_blank\">↗</a> <span class=\"{uniqueCss}\">{uniqueLbl}</span><span class=\"entry-badge\">{count} {T["report.text.entries_unit"]}</span></td>");
|
||||||
|
sb.AppendLine("</tr>");
|
||||||
|
|
||||||
|
foreach (var entry in group)
|
||||||
{
|
{
|
||||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
|
||||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
|
||||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
|
||||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||||
|
|
||||||
var (pills, subRows) = BuildUserPillsCell(
|
var (pills, subRows) = BuildUserPillsCell(
|
||||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||||
colSpan: 9, grpMemIdx: ref grpMemIdx,
|
colSpan: 5, grpMemIdx: ref grpMemIdx,
|
||||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
hideSystemGroupRaw: hideSystemGroupRaw,
|
||||||
|
sectionId: sectionId);
|
||||||
|
|
||||||
sb.AppendLine("<tr>");
|
sb.AppendLine($"<tr data-section-member=\"{sectionId}\" style=\"display:none\">");
|
||||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
|
||||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
|
||||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
|
||||||
sb.AppendLine($" <td>{pills}</td>");
|
sb.AppendLine($" <td>{pills}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||||
@@ -153,6 +162,7 @@ public class HtmlExportService
|
|||||||
sb.AppendLine("</tr>");
|
sb.AppendLine("</tr>");
|
||||||
if (subRows.Length > 0) sb.Append(subRows);
|
if (subRows.Length > 0) sb.Append(subRows);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AppendTableClose(sb);
|
AppendTableClose(sb);
|
||||||
AppendInlineJs(sb);
|
AppendInlineJs(sb);
|
||||||
|
|||||||
@@ -52,17 +52,62 @@ a:hover { text-decoration: underline; }
|
|||||||
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
||||||
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
||||||
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
||||||
|
.section-header td { background: #edf2f7; font-weight: 600; cursor: pointer; padding: 8px 14px; border-bottom: 2px solid #cbd5e0; user-select: none; }
|
||||||
|
.section-header:hover td { background: #e2e8f0; }
|
||||||
|
.section-header .chevron { margin-right: 8px; display: inline-block; transition: transform 0.15s; }
|
||||||
|
.section-header.collapsed .chevron { transform: rotate(-90deg); }
|
||||||
|
.entry-badge { display: inline-block; background: #e2e8f0; color: #4a5568; border-radius: 10px; padding: 1px 8px; font-size: .75rem; font-weight: 600; margin-left: 8px; }
|
||||||
";
|
";
|
||||||
|
|
||||||
internal const string InlineJs = @"function filterTable() {
|
internal const string InlineJs = @"function filterTable() {
|
||||||
var input = document.getElementById('filter').value.toLowerCase();
|
var input = document.getElementById('filter').value.toLowerCase();
|
||||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
var sections = document.querySelectorAll('#permTable tbody tr.section-header');
|
||||||
rows.forEach(function(row) {
|
if (sections.length === 0) {
|
||||||
|
document.querySelectorAll('#permTable tbody tr').forEach(function(row) {
|
||||||
if (row.hasAttribute('data-group')) return;
|
if (row.hasAttribute('data-group')) return;
|
||||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!input) {
|
||||||
|
sections.forEach(function(hdr) {
|
||||||
|
hdr.style.display = '';
|
||||||
|
var sid = hdr.getAttribute('data-section');
|
||||||
|
var collapsed = hdr.classList.contains('collapsed');
|
||||||
|
document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])').forEach(function(r) {
|
||||||
|
r.style.display = collapsed ? 'none' : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sections.forEach(function(hdr) {
|
||||||
|
var sid = hdr.getAttribute('data-section');
|
||||||
|
var members = document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])');
|
||||||
|
var anyMatch = false;
|
||||||
|
members.forEach(function(r) {
|
||||||
|
var match = r.textContent.toLowerCase().indexOf(input) > -1;
|
||||||
|
r.style.display = match ? '' : 'none';
|
||||||
|
if (match) anyMatch = true;
|
||||||
|
});
|
||||||
|
if (!anyMatch && hdr.textContent.toLowerCase().indexOf(input) > -1) {
|
||||||
|
anyMatch = true;
|
||||||
|
members.forEach(function(r) { r.style.display = ''; });
|
||||||
|
}
|
||||||
|
hdr.style.display = anyMatch ? '' : 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
document.addEventListener('click', function(ev) {
|
document.addEventListener('click', function(ev) {
|
||||||
|
var hdr = ev.target.closest('.section-header');
|
||||||
|
if (hdr) {
|
||||||
|
var sid = hdr.getAttribute('data-section');
|
||||||
|
hdr.classList.toggle('collapsed');
|
||||||
|
var collapsed = hdr.classList.contains('collapsed');
|
||||||
|
document.querySelectorAll('[data-section-member=' + sid + ']').forEach(function(r) {
|
||||||
|
if (r.hasAttribute('data-group')) { r.style.display = 'none'; return; }
|
||||||
|
r.style.display = collapsed ? 'none' : '';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
var trigger = ev.target.closest('.group-expandable');
|
var trigger = ev.target.closest('.group-expandable');
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
var id = trigger.getAttribute('data-group-target');
|
var id = trigger.getAttribute('data-group-target');
|
||||||
@@ -141,7 +186,8 @@ document.addEventListener('click', function(ev) {
|
|||||||
ref int grpMemIdx,
|
ref int grpMemIdx,
|
||||||
string? targetLabel = null,
|
string? targetLabel = null,
|
||||||
string? sharingLinkType = null,
|
string? sharingLinkType = null,
|
||||||
bool hideSystemGroupRaw = false)
|
bool hideSystemGroupRaw = false,
|
||||||
|
string? sectionId = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
@@ -172,6 +218,25 @@ document.addEventListener('click', function(ev) {
|
|||||||
&& groupMembers.TryGetValue(name, out _);
|
&& groupMembers.TryGetValue(name, out _);
|
||||||
|
|
||||||
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
|
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
|
||||||
|
{
|
||||||
|
if (resolved.Count == 0)
|
||||||
|
{
|
||||||
|
// Members unavailable — render plain pill, skip expandable sub-row.
|
||||||
|
var cls2 = isResolvedSystemGroup ? "user-pill\" data-system-group=\"1" : "user-pill";
|
||||||
|
pills.Append($"<span class=\"{cls2}\" title=\"{HtmlEncode(T["report.text.empty_group"])}\" data-email=\"{HtmlEncode(login)}\">");
|
||||||
|
if (isResolvedSystemGroup)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||||
|
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||||
|
pills.Append(HtmlEncode(targetLabel!));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pills.Append(HtmlEncode(name));
|
||||||
|
}
|
||||||
|
pills.Append("</span>");
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
var grpId = $"grpmem{grpMemIdx}";
|
var grpId = $"grpmem{grpMemIdx}";
|
||||||
pills.Append("<span class=\"user-pill group-expandable\"");
|
pills.Append("<span class=\"user-pill group-expandable\"");
|
||||||
@@ -190,19 +255,13 @@ document.addEventListener('click', function(ev) {
|
|||||||
}
|
}
|
||||||
pills.Append(" ▼</span>");
|
pills.Append(" ▼</span>");
|
||||||
|
|
||||||
string memberContent;
|
|
||||||
if (resolved.Count > 0)
|
|
||||||
{
|
|
||||||
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||||
memberContent = string.Join(" • ", parts);
|
var memberContent = string.Join(" • ", parts);
|
||||||
}
|
var sectionAttr = sectionId != null ? $" data-section-member=\"{HtmlEncode(sectionId)}\"" : "";
|
||||||
else
|
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\"{sectionAttr} style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||||
{
|
|
||||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
|
||||||
}
|
|
||||||
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
|
||||||
grpMemIdx++;
|
grpMemIdx++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else if (isResolvedSystemGroup)
|
else if (isResolvedSystemGroup)
|
||||||
{
|
{
|
||||||
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
|
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ using SharepointToolbox.Core.Models;
|
|||||||
namespace SharepointToolbox.Services;
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Orchestrates server-side file copy/move between two SharePoint libraries
|
/// Orchestrates file copy/move between two SharePoint libraries (same or
|
||||||
/// (same or different tenants). Uses <see cref="MoveCopyUtil"/> for the
|
/// different tenants). Hybrid strategy: server-side <see cref="MoveCopyUtil"/>
|
||||||
/// transfer itself so bytes never round-trip through the local machine.
|
/// first (zero local bandwidth), then transparent fallback to stream copy
|
||||||
/// Folder creation and enumeration are done via CSOM; all ambient retries
|
/// (<c>OpenBinaryDirect</c>/<c>SaveBinaryDirect</c>) on a list-view-threshold
|
||||||
/// flow through <see cref="ExecuteQueryRetryHelper"/>.
|
/// failure so transfers still succeed against libraries above the 5,000-item
|
||||||
|
/// cap. Folder enumeration uses paged CAML; folder creation is cached per job
|
||||||
|
/// to avoid re-checking the same path for every file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FileTransferService : IFileTransferService
|
public class FileTransferService : IFileTransferService
|
||||||
{
|
{
|
||||||
|
private const int ListViewThresholdItemCount = 5000;
|
||||||
|
private const int LargeLibraryPageSize = 500;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
|
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
|
||||||
/// (unless the job is folder-only), pre-creates destination folders, then
|
/// (unless the job is folder-only), pre-creates destination folders, then
|
||||||
@@ -30,12 +35,30 @@ public class FileTransferService : IFileTransferService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 1. Enumerate files from source (unless contents are suppressed).
|
// 1. Pre-flight: discover library item counts so we can pick a page size
|
||||||
|
// for source enumeration and warn early that the server-side copy path
|
||||||
|
// may trip the list-view threshold. The stream fallback in
|
||||||
|
// TransferSingleFileAsync handles the LVT case transparently, but the
|
||||||
|
// counts help size-tune enumeration up front.
|
||||||
|
var srcItemCount = await TryGetListItemCountAsync(sourceCtx, job.SourceLibrary, progress, ct);
|
||||||
|
var dstItemCount = await TryGetListItemCountAsync(destCtx, job.DestinationLibrary, progress, ct);
|
||||||
|
Log.Information(
|
||||||
|
"Transfer pre-flight: source={SrcLib} ({SrcCount} items), dest={DstLib} ({DstCount} items)",
|
||||||
|
job.SourceLibrary, srcItemCount, job.DestinationLibrary, dstItemCount);
|
||||||
|
|
||||||
|
if (srcItemCount > ListViewThresholdItemCount || dstItemCount > ListViewThresholdItemCount)
|
||||||
|
{
|
||||||
|
progress.Report(OperationProgress.Indeterminate(
|
||||||
|
$"Large library detected (source: {srcItemCount}, dest: {dstItemCount}). " +
|
||||||
|
"Using paged enumeration and stream-copy fallback when needed."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enumerate files from source (unless contents are suppressed).
|
||||||
IReadOnlyList<string> files;
|
IReadOnlyList<string> files;
|
||||||
if (job.CopyFolderContents)
|
if (job.CopyFolderContents)
|
||||||
{
|
{
|
||||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||||
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
files = await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -51,7 +74,7 @@ public class FileTransferService : IFileTransferService
|
|||||||
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build source and destination base paths. Resolve library roots via
|
// 3. Build source and destination base paths. Resolve library roots via
|
||||||
// CSOM — constructing from title breaks for localized libraries whose
|
// CSOM — constructing from title breaks for localized libraries whose
|
||||||
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
|
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
|
||||||
// causing "Access denied" when CSOM tries to touch a non-existent path.
|
// causing "Access denied" when CSOM tries to touch a non-existent path.
|
||||||
@@ -60,6 +83,11 @@ public class FileTransferService : IFileTransferService
|
|||||||
var dstBasePath = await ResolveLibraryPathAsync(
|
var dstBasePath = await ResolveLibraryPathAsync(
|
||||||
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
|
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
|
||||||
|
|
||||||
|
// Per-job cache of destination folders we've already ensured. Without
|
||||||
|
// this, EnsureFolderAsync re-checks .Exists for every file in the same
|
||||||
|
// folder — thousands of round-trips on a flat directory transfer.
|
||||||
|
var ensuredFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// When IncludeSourceFolder is set, recreate the source folder name under
|
// When IncludeSourceFolder is set, recreate the source folder name under
|
||||||
// destination so dest/srcFolderName/... mirrors the source tree. When
|
// destination so dest/srcFolderName/... mirrors the source tree. When
|
||||||
// no SourceFolderPath is set, fall back to the source library name.
|
// no SourceFolderPath is set, fall back to the source library name.
|
||||||
@@ -74,11 +102,11 @@ public class FileTransferService : IFileTransferService
|
|||||||
if (!string.IsNullOrEmpty(srcFolderName))
|
if (!string.IsNullOrEmpty(srcFolderName))
|
||||||
{
|
{
|
||||||
dstBasePath = $"{dstBasePath}/{srcFolderName}";
|
dstBasePath = $"{dstBasePath}/{srcFolderName}";
|
||||||
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
|
await EnsureFolderCachedAsync(destCtx, dstBasePath, ensuredFolders, progress, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Transfer each file using BulkOperationRunner
|
// 4. Transfer each file using BulkOperationRunner
|
||||||
return await BulkOperationRunner.RunAsync(
|
return await BulkOperationRunner.RunAsync(
|
||||||
files,
|
files,
|
||||||
async (fileRelUrl, idx, token) =>
|
async (fileRelUrl, idx, token) =>
|
||||||
@@ -88,13 +116,13 @@ public class FileTransferService : IFileTransferService
|
|||||||
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
|
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
|
||||||
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
|
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
|
||||||
|
|
||||||
// Ensure destination folder exists
|
// Ensure destination folder exists (cached)
|
||||||
var destFolderRelative = dstBasePath;
|
var destFolderRelative = dstBasePath;
|
||||||
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
|
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
|
||||||
if (!string.IsNullOrEmpty(fileFolder))
|
if (!string.IsNullOrEmpty(fileFolder))
|
||||||
{
|
{
|
||||||
destFolderRelative = $"{dstBasePath}/{fileFolder}";
|
destFolderRelative = $"{dstBasePath}/{fileFolder}";
|
||||||
await EnsureFolderAsync(destCtx, destFolderRelative, progress, token);
|
await EnsureFolderCachedAsync(destCtx, destFolderRelative, ensuredFolders, progress, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileName = Path.GetFileName(relativePart);
|
var fileName = Path.GetFileName(relativePart);
|
||||||
@@ -116,6 +144,32 @@ public class FileTransferService : IFileTransferService
|
|||||||
TransferJob job,
|
TransferJob job,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Hybrid path: try the server-side MoveCopyUtil first (bytes never
|
||||||
|
// leave SharePoint). If the destination (or source) library trips the
|
||||||
|
// list-view threshold, fall back to a stream copy via HTTP-direct APIs
|
||||||
|
// that bypass list internals.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ServerSideTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
|
||||||
|
}
|
||||||
|
catch (ServerException ex) when (IsListViewThresholdException(ex))
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"Server-side transfer hit list-view threshold for {File} — falling back to stream copy.",
|
||||||
|
srcFileUrl);
|
||||||
|
await StreamTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServerSideTransferAsync(
|
||||||
|
ClientContext sourceCtx,
|
||||||
|
ClientContext destCtx,
|
||||||
|
string srcFileUrl,
|
||||||
|
string dstFileUrl,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
||||||
// not server-relative paths. Passing "/sites/..." silently fails or
|
// not server-relative paths. Passing "/sites/..." silently fails or
|
||||||
@@ -153,9 +207,154 @@ public class FileTransferService : IFileTransferService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path-based stream copy fallback. Reads the source via
|
||||||
|
/// <see cref="Microsoft.SharePoint.Client.File.OpenBinaryStream"/> and writes
|
||||||
|
/// to the destination via <c>Folder.Files.Add(FileCreationInformation)</c>.
|
||||||
|
/// Both target a specific folder by path rather than querying list items,
|
||||||
|
/// so they succeed against libraries that exceed the list-view threshold.
|
||||||
|
/// Bytes do round-trip through the local machine — this is strictly the
|
||||||
|
/// fallback when server-side copy is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task StreamTransferAsync(
|
||||||
|
ClientContext sourceCtx,
|
||||||
|
ClientContext destCtx,
|
||||||
|
string srcFileUrl,
|
||||||
|
string dstFileUrl,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Resolve the destination file name for conflict handling. Returns null
|
||||||
|
// when policy=Skip and the file already exists.
|
||||||
|
var effectiveDestUrl = await ResolveDestinationOnConflictAsync(destCtx, dstFileUrl, job, progress, ct);
|
||||||
|
if (effectiveDestUrl == null)
|
||||||
|
{
|
||||||
|
Log.Warning("Skipped (already exists, stream fallback): {File}", srcFileUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename policy guarantees a free path via ResolveDestinationOnConflictAsync,
|
||||||
|
// so overwrite is only needed for the explicit Overwrite policy.
|
||||||
|
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// 1. Download the source bytes into memory. OpenBinaryStream is a
|
||||||
|
// ClientResult<Stream> — usable only after ExecuteQuery.
|
||||||
|
var srcFile = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
|
||||||
|
var streamResult = srcFile.OpenBinaryStream();
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await streamResult.Value.CopyToAsync(buffer, 81920, ct);
|
||||||
|
buffer.Position = 0;
|
||||||
|
|
||||||
|
// 2. Upload to the destination folder. Files.Add with ContentStream
|
||||||
|
// streams the payload in one request and does not touch list-view
|
||||||
|
// metadata, so it bypasses LVT.
|
||||||
|
var slash = effectiveDestUrl.LastIndexOf('/');
|
||||||
|
var destFolderUrl = effectiveDestUrl.Substring(0, slash);
|
||||||
|
var destFileName = effectiveDestUrl.Substring(slash + 1);
|
||||||
|
|
||||||
|
var destFolder = destCtx.Web.GetFolderByServerRelativeUrl(destFolderUrl);
|
||||||
|
var creation = new FileCreationInformation
|
||||||
|
{
|
||||||
|
Url = destFileName,
|
||||||
|
Overwrite = overwrite,
|
||||||
|
ContentStream = buffer,
|
||||||
|
};
|
||||||
|
destFolder.Files.Add(creation);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(destCtx, progress, ct);
|
||||||
|
|
||||||
|
if (job.Mode == TransferMode.Move)
|
||||||
|
{
|
||||||
|
// Stream copy cannot atomically move; delete the source after a
|
||||||
|
// successful upload to honour Move semantics.
|
||||||
|
var srcDelete = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
|
||||||
|
srcDelete.DeleteObject();
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Honours <see cref="TransferJob.ConflictPolicy"/> when the destination
|
||||||
|
/// path already exists. Returns the URL to write to, or <c>null</c> when
|
||||||
|
/// the file should be skipped. For <see cref="ConflictPolicy.Rename"/>,
|
||||||
|
/// probes <c>name (1).ext</c>, <c>name (2).ext</c>, ... until a free slot
|
||||||
|
/// is found.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> ResolveDestinationOnConflictAsync(
|
||||||
|
ClientContext destCtx,
|
||||||
|
string dstFileUrl,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (job.ConflictPolicy == ConflictPolicy.Overwrite)
|
||||||
|
return dstFileUrl;
|
||||||
|
|
||||||
|
bool exists = await FileExistsAsync(destCtx, dstFileUrl, progress, ct);
|
||||||
|
if (!exists) return dstFileUrl;
|
||||||
|
|
||||||
|
if (job.ConflictPolicy == ConflictPolicy.Skip)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Rename: keep both. Append " (n)" before the extension.
|
||||||
|
var dir = dstFileUrl.Substring(0, dstFileUrl.LastIndexOf('/'));
|
||||||
|
var leaf = dstFileUrl.Substring(dstFileUrl.LastIndexOf('/') + 1);
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(leaf);
|
||||||
|
var ext = Path.GetExtension(leaf);
|
||||||
|
|
||||||
|
for (int n = 1; n <= 999; n++)
|
||||||
|
{
|
||||||
|
var candidate = $"{dir}/{stem} ({n}){ext}";
|
||||||
|
if (!await FileExistsAsync(destCtx, candidate, progress, ct))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
// Extremely unlikely; surface as failure rather than silent overwrite.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not find an unused destination filename for {dstFileUrl} after 999 attempts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> FileExistsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string fileServerRelativeUrl,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
||||||
|
ctx.Load(file, f => f.Exists);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
return file.Exists;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects SharePoint's list-view-threshold ServerException across locales.
|
||||||
|
/// English: "exceeds the list view threshold". French: "depasse le seuil
|
||||||
|
/// d'affichage de liste". German: "Listenansichtsschwellenwert".
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsListViewThresholdException(Exception ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message ?? string.Empty;
|
||||||
|
return msg.Contains("list view threshold", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("seuil d'affichage", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("seuil d", StringComparison.OrdinalIgnoreCase) && msg.Contains("liste", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("Listenansichtsschwellenwert", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("umbral de vista de lista", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<string>> EnumerateFilesAsync(
|
private async Task<IReadOnlyList<string>> EnumerateFilesAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
TransferJob job,
|
TransferJob job,
|
||||||
|
int sourceItemCount,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -226,6 +425,44 @@ public class FileTransferService : IFileTransferService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<int> TryGetListItemCountAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string libraryTitle,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
|
||||||
|
ctx.Load(list, l => l.ItemCount);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
return list.ItemCount;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Non-fatal: pre-flight count is purely informational. Treat as
|
||||||
|
// unknown (-1) so the rest of the pipeline still runs.
|
||||||
|
Log.Warning("Failed to read ItemCount for {Library}: {Error}", libraryTitle, ex.Message);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EnsureFolderAsync wrapper that records successful checks in a per-job
|
||||||
|
/// set so the same destination folder isn't re-validated for every file.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureFolderCachedAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string folderServerRelativeUrl,
|
||||||
|
HashSet<string> cache,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var normalized = folderServerRelativeUrl.TrimEnd('/');
|
||||||
|
if (!cache.Add(normalized)) return;
|
||||||
|
await EnsureFolderAsync(ctx, normalized, progress, ct);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task EnsureFolderAsync(
|
private async Task EnsureFolderAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
string folderServerRelativeUrl,
|
string folderServerRelativeUrl,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ namespace SharepointToolbox.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SharePointGroupResolver : ISharePointGroupResolver
|
public class SharePointGroupResolver : ISharePointGroupResolver
|
||||||
{
|
{
|
||||||
private readonly AppGraphClientFactory? _graphClientFactory;
|
private readonly AppGraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
|
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
|
||||||
{
|
{
|
||||||
@@ -57,6 +57,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
foreach (var g in ctx.Web.SiteGroups)
|
foreach (var g in ctx.Web.SiteGroups)
|
||||||
groupTitles.Add(g.Title);
|
groupTitles.Add(g.Title);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
|
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
|
||||||
@@ -92,7 +93,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
if (IsAadGroup(user.LoginName))
|
if (IsAadGroup(user.LoginName))
|
||||||
{
|
{
|
||||||
// Lazy-create graph client on first AAD group encountered
|
// Lazy-create graph client on first AAD group encountered
|
||||||
graphClient ??= await _graphClientFactory!.CreateClientAsync(clientId, ct);
|
graphClient ??= await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
|
||||||
var aadId = ExtractAadGroupId(user.LoginName);
|
var aadId = ExtractAadGroupId(user.LoginName);
|
||||||
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
|
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
|
||||||
@@ -110,6 +111,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)
|
.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
|
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
|
||||||
@@ -182,6 +184,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
await pageIterator.IterateAsync(ct);
|
await pageIterator.IterateAsync(ct);
|
||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
|
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
|
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
|
||||||
@@ -97,7 +98,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
|||||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
|
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ServerException) { /* fall through */ }
|
catch (ServerException ex) { Log.Debug("File by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
|
||||||
|
|
||||||
// 2. Try as folder on current web.
|
// 2. Try as folder on current web.
|
||||||
try
|
try
|
||||||
@@ -109,7 +110,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
|||||||
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
|
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
|
||||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
|
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
|
||||||
}
|
}
|
||||||
catch (ServerException) { /* fall through */ }
|
catch (ServerException ex) { Log.Debug("Folder by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
|
||||||
|
|
||||||
// 3. Search-index fallback — covers items moved to a different subsite or
|
// 3. Search-index fallback — covers items moved to a different subsite or
|
||||||
// deleted recently (the index may lag the deletion by minutes/hours).
|
// deleted recently (the index may lag the deletion by minutes/hours).
|
||||||
@@ -168,6 +169,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
|||||||
path,
|
path,
|
||||||
linkType);
|
linkType);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
|
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ public class VersionCleanupService : IVersionCleanupService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
int before = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
||||||
@@ -131,7 +132,7 @@ public class VersionCleanupService : IVersionCleanupService
|
|||||||
// file.Versions contains only HISTORICAL versions; the current published
|
// file.Versions contains only HISTORICAL versions; the current published
|
||||||
// version lives on `file` itself and is never deletable here.
|
// version lives on `file` itself and is never deletable here.
|
||||||
var versions = file.Versions.ToList();
|
var versions = file.Versions.ToList();
|
||||||
int before = versions.Count;
|
before = versions.Count;
|
||||||
if (before == 0) return null;
|
if (before == 0) return null;
|
||||||
|
|
||||||
// Sort by Created ascending so [0] is the oldest historical version.
|
// Sort by Created ascending so [0] is the oldest historical version.
|
||||||
@@ -173,6 +174,7 @@ public class VersionCleanupService : IVersionCleanupService
|
|||||||
BytesFreed = bytesFreed,
|
BytesFreed = bytesFreed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
|
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
|
||||||
@@ -182,6 +184,7 @@ public class VersionCleanupService : IVersionCleanupService
|
|||||||
Library = libraryTitle,
|
Library = libraryTitle,
|
||||||
FileServerRelativeUrl = fileServerRelativeUrl,
|
FileServerRelativeUrl = fileServerRelativeUrl,
|
||||||
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
|
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
|
||||||
|
VersionsBefore = before,
|
||||||
Error = ex.Message,
|
Error = ex.Message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
|
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
|
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399" />
|
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399" />
|
||||||
|
<SolidColorBrush x:Key="ErrorRowBgBrush" Color="#2D0C0C" />
|
||||||
|
<SolidColorBrush x:Key="ErrorRowFgBrush" Color="#F87171" />
|
||||||
<!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). -->
|
<!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). -->
|
||||||
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
|
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
|
||||||
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
|
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
|
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#047857" />
|
<SolidColorBrush x:Key="SuccessBrush" Color="#047857" />
|
||||||
|
<SolidColorBrush x:Key="ErrorRowBgBrush" Color="#FDE0E0" />
|
||||||
|
<SolidColorBrush x:Key="ErrorRowFgBrush" Color="#B00020" />
|
||||||
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
|
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
|
||||||
<SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" />
|
<SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" />
|
||||||
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
|
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
|
||||||
|
|||||||
@@ -1357,6 +1357,55 @@
|
|||||||
<Setter Property="Height" Value="6" />
|
<Setter Property="Height" Value="6" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ====================================================== -->
|
||||||
|
<!-- InfoButton — small circular "?" help button -->
|
||||||
|
<!-- ====================================================== -->
|
||||||
|
<Style x:Key="InfoButtonStyle" TargetType="{x:Type Button}">
|
||||||
|
<Setter Property="Width" Value="18" />
|
||||||
|
<Setter Property="Height" Value="18" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource TextMutedBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="{x:Type Button}">
|
||||||
|
<Grid>
|
||||||
|
<Ellipse x:Name="Circle"
|
||||||
|
Fill="{TemplateBinding Background}"
|
||||||
|
Stroke="{TemplateBinding BorderBrush}"
|
||||||
|
StrokeThickness="{TemplateBinding BorderThickness}" />
|
||||||
|
<TextBlock x:Name="Label"
|
||||||
|
Text="?"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
FontWeight="{TemplateBinding FontWeight}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
SnapsToDevicePixels="True" />
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Circle" Property="Fill" Value="{DynamicResource AccentSoftBrush}" />
|
||||||
|
<Setter TargetName="Circle" Property="Stroke" Value="{DynamicResource AccentBrush}" />
|
||||||
|
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Circle" Property="Fill" Value="{DynamicResource AccentBrush}" />
|
||||||
|
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource AccentForegroundBrush}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
<!-- ToolTip -->
|
<!-- ToolTip -->
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _hideSystemGroupRaw = true;
|
private bool _hideSystemGroupRaw = true;
|
||||||
|
|
||||||
|
/// <summary>When true, sharing link entries (SharingLinkType != null) are removed from results and exports.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _excludeSharingLinks;
|
||||||
|
|
||||||
|
/// <summary>When true, "Limited Access System Group For Web/List" entries are removed from results and exports.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _excludeSystemGroups;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _includeSubsites;
|
private bool _includeSubsites;
|
||||||
|
|
||||||
@@ -102,6 +110,17 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Results after applying ExcludeSharingLinks / ExcludeSystemGroups filters.
|
||||||
|
/// Rebuilt when Results changes or filter flags change.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<PermissionEntry> _filteredResults = Array.Empty<PermissionEntry>();
|
||||||
|
public IReadOnlyList<PermissionEntry> FilteredResults
|
||||||
|
{
|
||||||
|
get => _filteredResults;
|
||||||
|
private set => SetProperty(ref _filteredResults, value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -124,16 +143,37 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The collection the DataGrid actually binds to. Returns:
|
/// The collection the DataGrid actually binds to. Returns:
|
||||||
/// - Results (raw) when simplified mode is OFF
|
/// - FilteredResults (raw) when simplified mode is OFF
|
||||||
/// - SimplifiedResults when simplified mode is ON and detail view is ON
|
/// - SimplifiedResults when simplified mode is ON and detail view is ON
|
||||||
/// - (View handles summary display separately via Summaries property)
|
/// - (View handles summary display separately via Summaries property)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public object ActiveItemsSource => IsSimplifiedMode
|
public object ActiveItemsSource => IsSimplifiedMode
|
||||||
? (object)SimplifiedResults
|
? (object)SimplifiedResults
|
||||||
: Results;
|
: FilteredResults;
|
||||||
|
|
||||||
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
|
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
|
||||||
|
|
||||||
|
partial void OnExcludeSharingLinksChanged(bool value) => RefreshAfterFilterChange();
|
||||||
|
partial void OnExcludeSystemGroupsChanged(bool value) => RefreshAfterFilterChange();
|
||||||
|
|
||||||
|
private void RefreshAfterFilterChange()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
RebuildFilteredResults();
|
||||||
|
if (IsSimplifiedMode) RebuildSimplifiedData();
|
||||||
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildFilteredResults()
|
||||||
|
{
|
||||||
|
IEnumerable<PermissionEntry> filtered = Results;
|
||||||
|
if (ExcludeSharingLinks)
|
||||||
|
filtered = filtered.Where(e => string.IsNullOrEmpty(e.SharingLinkType));
|
||||||
|
if (ExcludeSystemGroups)
|
||||||
|
filtered = filtered.Where(e => !e.GrantedThrough.Contains("Limited Access System Group", StringComparison.OrdinalIgnoreCase));
|
||||||
|
FilteredResults = filtered.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Commands ────────────────────────────────────────────────────────────
|
// ── Commands ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
@@ -172,8 +212,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_ownershipService = ownershipService;
|
_ownershipService = ownershipService;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -199,8 +239,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_ownershipService = ownershipService;
|
_ownershipService = ownershipService;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||||
@@ -221,9 +261,18 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
/// Recomputes SimplifiedResults and Summaries from the current Results collection.
|
/// Recomputes SimplifiedResults and Summaries from the current Results collection.
|
||||||
/// Called when Results changes or when simplified mode is toggled on.
|
/// Called when Results changes or when simplified mode is toggled on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private static bool IsSimplifiedModeNoise(PermissionEntry e)
|
||||||
|
{
|
||||||
|
if (e.Users.Contains("SharePointHome", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (e.GrantedThrough.Contains("SharePointHome", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (e.UserLogins.Split(';').Any(l => l.Trim().StartsWith("c:0u.c|tenant|", StringComparison.OrdinalIgnoreCase))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void RebuildSimplifiedData()
|
private void RebuildSimplifiedData()
|
||||||
{
|
{
|
||||||
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results);
|
var forSimplified = FilteredResults.Where(e => !IsSimplifiedModeNoise(e));
|
||||||
|
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(forSimplified);
|
||||||
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
|
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +352,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
await dispatcher.InvokeAsync(() =>
|
await dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
||||||
|
RebuildFilteredResults();
|
||||||
if (IsSimplifiedMode)
|
if (IsSimplifiedMode)
|
||||||
RebuildSimplifiedData();
|
RebuildSimplifiedData();
|
||||||
OnPropertyChanged(nameof(ActiveItemsSource));
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||||
@@ -311,6 +361,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
||||||
|
RebuildFilteredResults();
|
||||||
if (IsSimplifiedMode)
|
if (IsSimplifiedMode)
|
||||||
RebuildSimplifiedData();
|
RebuildSimplifiedData();
|
||||||
OnPropertyChanged(nameof(ActiveItemsSource));
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||||
@@ -384,6 +435,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
_currentProfile = profile;
|
_currentProfile = profile;
|
||||||
Results = new ObservableCollection<PermissionEntry>();
|
Results = new ObservableCollection<PermissionEntry>();
|
||||||
|
FilteredResults = Array.Empty<PermissionEntry>();
|
||||||
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
||||||
Summaries = Array.Empty<PermissionSummary>();
|
Summaries = Array.Empty<PermissionSummary>();
|
||||||
OnPropertyChanged(nameof(ActiveItemsSource));
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||||
@@ -404,7 +456,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
private bool CanExport() => Results.Count > 0;
|
private bool CanExport() => Results.Count > 0;
|
||||||
|
|
||||||
private async Task ExportCsvAsync()
|
private async Task ExportCsvAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_csvExportService == null || Results.Count == 0) return;
|
if (_csvExportService == null || Results.Count == 0) return;
|
||||||
var dialog = new SaveFileDialog
|
var dialog = new SaveFileDialog
|
||||||
@@ -418,9 +470,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
|
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, ct);
|
||||||
else
|
else
|
||||||
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
|
await _csvExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, ct);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -430,7 +482,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExportHtmlAsync()
|
private async Task ExportHtmlAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_htmlExportService == null || Results.Count == 0) return;
|
if (_htmlExportService == null || Results.Count == 0) return;
|
||||||
var dialog = new SaveFileDialog
|
var dialog = new SaveFileDialog
|
||||||
@@ -458,7 +510,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
// by the site it was observed on, then resolve against that
|
// by the site it was observed on, then resolve against that
|
||||||
// site's context. Using the root tenant ctx for a group that
|
// site's context. Using the root tenant ctx for a group that
|
||||||
// lives on a sub-site makes CSOM fail with "Group not found".
|
// lives on a sub-site makes CSOM fail with "Group not found".
|
||||||
var groupsBySite = Results
|
var groupsBySite = FilteredResults
|
||||||
.Where(r => r.PrincipalType == "SharePointGroup")
|
.Where(r => r.PrincipalType == "SharePointGroup")
|
||||||
.SelectMany(r => r.Users
|
.SelectMany(r => r.Users
|
||||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
@@ -488,9 +540,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
Name = _currentProfile.Name
|
Name = _currentProfile.Name
|
||||||
};
|
};
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||||
siteProfile, CancellationToken.None);
|
siteProfile, ct);
|
||||||
var resolved = await _groupResolver.ResolveGroupsAsync(
|
var resolved = await _groupResolver.ResolveGroupsAsync(
|
||||||
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
|
ctx, _currentProfile.ClientId, distinctNames, ct);
|
||||||
foreach (var kv in resolved)
|
foreach (var kv in resolved)
|
||||||
merged[kv.Key] = kv.Value;
|
merged[kv.Key] = kv.Value;
|
||||||
}
|
}
|
||||||
@@ -507,9 +559,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
|
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, ct, branding, groupMembers, HideSystemGroupRaw);
|
||||||
else
|
else
|
||||||
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
|
await _htmlExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, CurrentLayout, ct, branding, groupMembers, HideSystemGroupRaw);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<UserControl x:Class="SharepointToolbox.Views.Common.InfoButton"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Name="Root"
|
||||||
|
Width="18" Height="18"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Grid>
|
||||||
|
<Button x:Name="Btn"
|
||||||
|
Style="{StaticResource InfoButtonStyle}"
|
||||||
|
Click="Btn_Click" />
|
||||||
|
<Popup x:Name="InfoPopup"
|
||||||
|
StaysOpen="False"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Placement="Bottom"
|
||||||
|
PlacementTarget="{Binding ElementName=Btn}"
|
||||||
|
MaxWidth="340">
|
||||||
|
<Border Background="{DynamicResource SurfaceBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderStrongBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="14,10">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect ShadowDepth="2" BlurRadius="10" Opacity="0.18" Color="#000000" />
|
||||||
|
</Border.Effect>
|
||||||
|
<StackPanel MaxWidth="310">
|
||||||
|
<TextBlock Text="{Binding Title, ElementName=Root}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="13"
|
||||||
|
Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding Body, ElementName=Root}"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LineHeight="18" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Common
|
||||||
|
{
|
||||||
|
public partial class InfoButton : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty TitleProperty =
|
||||||
|
DependencyProperty.Register(nameof(Title), typeof(string), typeof(InfoButton), new PropertyMetadata(string.Empty));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty BodyProperty =
|
||||||
|
DependencyProperty.Register(nameof(Body), typeof(string), typeof(InfoButton), new PropertyMetadata(string.Empty));
|
||||||
|
|
||||||
|
public string Title
|
||||||
|
{
|
||||||
|
get => (string)GetValue(TitleProperty);
|
||||||
|
set => SetValue(TitleProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Body
|
||||||
|
{
|
||||||
|
get => (string)GetValue(BodyProperty);
|
||||||
|
set => SetValue(BodyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InfoButton()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Btn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
InfoPopup.IsOpen = !InfoPopup.IsOpen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@
|
|||||||
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
|
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
|
||||||
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
|
||||||
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
Command="{Binding ImportCsvCommand}" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulkmembers.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulkmembers.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
|
||||||
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
||||||
|
|
||||||
@@ -41,8 +46,8 @@
|
|||||||
<Style TargetType="DataGridRow">
|
<Style TargetType="DataGridRow">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding IsValid}" Value="False">
|
<DataTrigger Binding="{Binding IsValid}" Value="False">
|
||||||
<Setter Property="Background" Value="#FFFDE0E0" />
|
<Setter Property="Background" Value="{DynamicResource ErrorRowBgBrush}" />
|
||||||
<Setter Property="Foreground" Value="#FFB00020" />
|
<Setter Property="Foreground" Value="{DynamicResource ErrorRowFgBrush}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -7,8 +7,13 @@
|
|||||||
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
|
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
|
||||||
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
|
||||||
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
Command="{Binding ImportCsvCommand}" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulksites.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulksites.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
|
||||||
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
||||||
|
|
||||||
@@ -39,8 +44,8 @@
|
|||||||
<Style TargetType="DataGridRow">
|
<Style TargetType="DataGridRow">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding IsValid}" Value="False">
|
<DataTrigger Binding="{Binding IsValid}" Value="False">
|
||||||
<Setter Property="Background" Value="#FFFDE0E0" />
|
<Setter Property="Background" Value="{DynamicResource ErrorRowBgBrush}" />
|
||||||
<Setter Property="Foreground" Value="#FFB00020" />
|
<Setter Property="Foreground" Value="{DynamicResource ErrorRowFgBrush}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<!-- Options panel -->
|
<!-- Options panel -->
|
||||||
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||||
@@ -15,7 +16,15 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
|
<GroupBox Margin="0,0,0,8">
|
||||||
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.dup.criteria.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.dup.criteria.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel Margin="4">
|
<StackPanel Margin="4">
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
|
||||||
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" />
|
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||||
|
|||||||
@@ -12,8 +12,13 @@
|
|||||||
Margin="0,0,0,3" />
|
Margin="0,0,0,3" />
|
||||||
<TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
|
<TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
|
||||||
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
Command="{Binding ImportCsvCommand}" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.folderstruct.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.folderstruct.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
|
||||||
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,13 @@
|
|||||||
<!-- Checkboxes -->
|
<!-- Checkboxes -->
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
|
||||||
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
|
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
|
||||||
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
|
IsChecked="{Binding IncludeInherited}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.inherited.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.inherited.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}"
|
||||||
IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" />
|
IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
@@ -57,8 +62,13 @@
|
|||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<!-- Simplified Mode toggle -->
|
<!-- Simplified Mode toggle -->
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.simplified.mode]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.simplified.mode]}"
|
||||||
IsChecked="{Binding IsSimplifiedMode}" Margin="0,0,0,8" />
|
IsChecked="{Binding IsSimplifiedMode}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.simplified.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.simplified.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Detail Level radio buttons (only enabled when simplified mode is on) -->
|
<!-- Detail Level radio buttons (only enabled when simplified mode is on) -->
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.detail.level]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.detail.level]}"
|
||||||
@@ -90,10 +100,46 @@
|
|||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.export]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.export]}"
|
||||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}"
|
||||||
IsChecked="{Binding MergePermissions}" Margin="0,0,0,4" />
|
IsChecked="{Binding MergePermissions}" VerticalAlignment="Center" />
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.hide.system.group.raw]}"
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
IsChecked="{Binding HideSystemGroupRaw}" />
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.merge.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.merge.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding HideSystemGroupRaw}" VerticalAlignment="Top">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.hide.system.group.raw]}"
|
||||||
|
TextWrapping="Wrap" MaxWidth="210" />
|
||||||
|
</CheckBox>
|
||||||
|
<common:InfoButton Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Top"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.hidesys.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.hidesys.body]}" />
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||||
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.exclude.sharing.links]}"
|
||||||
|
IsChecked="{Binding ExcludeSharingLinks}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.sharing.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.sharing.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Margin="0,4,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding ExcludeSystemGroups}" VerticalAlignment="Top">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.exclude.system.groups]}"
|
||||||
|
TextWrapping="Wrap" MaxWidth="210" />
|
||||||
|
</CheckBox>
|
||||||
|
<common:InfoButton Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Top"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.system.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.system.body]}" />
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
@@ -113,7 +159,12 @@
|
|||||||
Command="{Binding CancelCommand}"
|
Command="{Binding CancelCommand}"
|
||||||
Margin="0,0,0,4" Padding="6,3" />
|
Margin="0,0,0,4" Padding="6,3" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" />
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="4,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.splitmode.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.splitmode.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
|
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
|
||||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
||||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<!-- Filters panel -->
|
<!-- Filters panel -->
|
||||||
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}"
|
<GroupBox Margin="0,0,0,8">
|
||||||
Margin="0,0,0,8">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel Margin="4">
|
<StackPanel Margin="4">
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
|
||||||
<TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
<TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />
|
||||||
|
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0,0,0,2" />
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="4,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.regex.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.regex.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
<TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
|
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
|
||||||
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF">
|
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
|
||||||
|
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<!-- Options panel -->
|
<!-- Options panel -->
|
||||||
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
|
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
|
||||||
Margin="8,8,4,8">
|
HorizontalScrollBarVisibility="Disabled" Margin="8,8,4,8">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<!-- Scan options group -->
|
<!-- Scan options group -->
|
||||||
@@ -37,10 +38,20 @@
|
|||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8">
|
||||||
<StackPanel Margin="4">
|
<StackPanel Margin="4">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,2">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
|
||||||
IsChecked="{Binding ScanHiddenLibraries}" Margin="0,2" />
|
IsChecked="{Binding ScanHiddenLibraries}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.hidden.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.hidden.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,2">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
|
||||||
IsChecked="{Binding ScanPreservationHold}" Margin="0,2" />
|
IsChecked="{Binding ScanPreservationHold}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.preservation.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.preservation.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.attachments]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.attachments]}"
|
||||||
IsChecked="{Binding ScanListAttachments}" Margin="0,2" />
|
IsChecked="{Binding ScanListAttachments}" Margin="0,2" />
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.recyclebin]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.recyclebin]}"
|
||||||
@@ -65,8 +76,10 @@
|
|||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.subsites]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.subsites]}"
|
||||||
IsChecked="{Binding ShowSubsites}" Margin="0,2" />
|
IsChecked="{Binding ShowSubsites}" Margin="0,2" />
|
||||||
<Separator Margin="0,4" />
|
<Separator Margin="0,4" />
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
|
<CheckBox IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2">
|
||||||
IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2" />
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
|
||||||
|
TextWrapping="Wrap" MaxWidth="195" />
|
||||||
|
</CheckBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.TemplatesView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.TemplatesView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
|
||||||
<DockPanel Margin="10">
|
<DockPanel Margin="10">
|
||||||
<!-- Left panel: Capture and Apply -->
|
<!-- Left panel: Capture and Apply -->
|
||||||
<ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"
|
<ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"
|
||||||
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<!-- Capture Section -->
|
<!-- Capture Section -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
|
<GroupBox Margin="0,0,0,10">
|
||||||
Margin="0,0,0,10">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.capture.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.capture.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel Margin="5">
|
<StackPanel Margin="5">
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
|
||||||
Margin="0,0,0,3" />
|
Margin="0,0,0,3" />
|
||||||
@@ -35,8 +43,15 @@
|
|||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<!-- Apply Section -->
|
<!-- Apply Section -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}"
|
<GroupBox Margin="0,0,0,10">
|
||||||
Margin="0,0,0,10">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.apply.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.apply.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel Margin="5">
|
<StackPanel Margin="5">
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newtitle]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newtitle]}"
|
||||||
Margin="0,0,0,3" />
|
Margin="0,0,0,3" />
|
||||||
|
|||||||
@@ -24,14 +24,24 @@
|
|||||||
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
|
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
|
||||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" />
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}"
|
||||||
IsChecked="{Binding IncludeSourceFolder}"
|
IsChecked="{Binding IncludeSourceFolder}"
|
||||||
Margin="0,6,0,0"
|
VerticalAlignment="Center"
|
||||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.incsource.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.incsource.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
|
||||||
IsChecked="{Binding CopyFolderContents}"
|
IsChecked="{Binding CopyFolderContents}"
|
||||||
Margin="0,4,0,0"
|
VerticalAlignment="Center"
|
||||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.copycontent.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.copycontent.body]}" />
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
@@ -61,8 +71,15 @@
|
|||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<!-- Conflict Policy -->
|
<!-- Conflict Policy -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}"
|
<GroupBox Margin="0,0,0,10">
|
||||||
Margin="0,0,0,10">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.conflict.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.conflict.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
|
<ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
|
||||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
|
||||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}"
|
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}"
|
||||||
IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}"
|
IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}"
|
||||||
Margin="0,0,12,0" />
|
Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||||
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}"
|
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}"
|
||||||
IsChecked="{Binding IsBrowseMode}" />
|
IsChecked="{Binding IsBrowseMode}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="8,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.mode.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.mode.body]}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) -->
|
<!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) -->
|
||||||
@@ -201,8 +204,15 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Scan Options (always visible) -->
|
<!-- Scan Options (always visible) -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
|
<GroupBox DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.vs.perms.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.vs.perms.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
|
||||||
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
|
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -17,8 +18,15 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}"
|
<GroupBox Margin="0,0,0,8">
|
||||||
Margin="0,0,0,8">
|
<GroupBox.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.policy.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.policy.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox.Header>
|
||||||
<StackPanel Margin="4">
|
<StackPanel Margin="4">
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,2">
|
<StackPanel Orientation="Horizontal" Margin="0,2">
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
|
||||||
@@ -26,10 +34,20 @@
|
|||||||
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
|
||||||
Width="50" Height="22" VerticalAlignment="Center" />
|
Width="50" Height="22" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,4,0,2">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
|
||||||
IsChecked="{Binding KeepFirstVersion}" Margin="0,4,0,2" />
|
IsChecked="{Binding KeepFirstVersion}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.keepfirst.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.keepfirst.body]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,4,0,2">
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
|
||||||
IsChecked="{Binding ConfirmDelete}" Margin="0,4,0,2" />
|
IsChecked="{Binding ConfirmDelete}" VerticalAlignment="Center" />
|
||||||
|
<common:InfoButton Margin="6,0,0,0"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.confirm.title]}"
|
||||||
|
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.confirm.body]}" />
|
||||||
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
|
||||||
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
|
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
|
||||||
Margin="0,6,0,0" />
|
Margin="0,6,0,0" />
|
||||||
|
|||||||
Reference in New Issue
Block a user