Sauber strukturiert: inklusive Tabellen, Attribute, Forms, Tabs, Sections, Views sowie Local und Global Option Sets — mit Publish Job, Status-Handling, Chunking und Sandbox-safe Architektur.
6-Phasen Pipeline — sandbox-sicher, paged, selektiver PublishXml. Fehler pro Record isoliert, kein Blanket-Update.
Erstellt einen xrm_publishjob Record, setzt Status = Running, schreibt Startzeit und Table-Filter. Dient als Audit-Trail für die gesamte Pipeline.
Lädt alle deployable Records über 8 Entity-Typen via paged QueryExpression (PageSize=50). Filter: Status IN (New=570690000, Changed=570690001) + TranslatedLabel NOT NULL + LCID-Match.
Deployed in Dependency-Order: Tables → Attributes → Forms → Tabs+Sections (FormXML grouped per Form) → Views → LocalOptionSets → GlobalOptionSets. Fehler pro Record werden geloggt, stoppen nicht die Pipeline.
Publiziert nur die betroffenen Entity LogicalNames via PublishXmlRequest. Chunked in Batches von max. 10 Entities. Publish-Fehler sind non-fatal.
Markiert NUR Records die tatsächlich erfolgreich deployed wurden als Published (570690004). Nutzt ExecuteMultiple mit ContinueOnError=true in Batches von 20. Kein Blanket-Update.
Setzt Endzeit, finalen Status (Completed=570690002 / Failed=570690003) und aggregierten Log auf dem PublishJob. Log wird auf 50.000 Zeichen gecapped.
OptionSet-Werte für Translation Records und xrm_publishjob
Translation Record — xrm_deploymentstatus
Publish Job — xrm_status
Custom API Entry Point — vollständig mit allen using-Statements
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.Xrm.Sdk; using XrmTranslationStudio.Deployment.Models; using XrmTranslationStudio.Deployment.Services; namespace XrmTranslationStudio.Deployment { /// <summary> /// Custom API: xrm_DeployTranslations /// Input: xrm_languagecode (Int, required) – LCID to deploy /// xrm_tablefilter (String, optional) – comma-separated entity logical names /// Output: xrm_successcount (Int), xrm_failcount (Int) /// </summary> public class DeployTranslationsPlugin : IPlugin { public void Execute(IServiceProvider serviceProvider) { var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext)); var serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory)); var tracer = (ITracingService)serviceProvider.GetService(typeof(ITracingService)); var service = serviceFactory.CreateOrganizationService(context.UserId); // ── Validate input ────────────────────────────────────────────────── if (!context.InputParameters.Contains("xrm_languagecode") || !(context.InputParameters["xrm_languagecode"] is int languageCode)) throw new InvalidPluginExecutionException("Input parameter 'xrm_languagecode' (Int) is required."); string tableFilter = context.InputParameters.Contains("xrm_tablefilter") ? context.InputParameters["xrm_tablefilter"] as string : null; tracer.Trace($"[Deploy] START — LCID={languageCode}, Filter='{tableFilter ?? "all"}'"); var jobService = new PublishJobService(service, tracer); Guid jobId = Guid.Empty; try { // Phase 1 ── Create PublishJob ───────────────────────────────────── jobId = jobService.CreateJob(languageCode, tableFilter); tracer.Trace($"[Deploy] Phase 1 OK – Job {jobId}"); // Phase 2 ── Load all deployable translation records ────────────── var queryService = new TranslationQueryService(service, tracer); var allRecords = queryService.LoadAllDeployableRecords(languageCode, tableFilter); tracer.Trace($"[Deploy] Phase 2 OK – {allRecords.Count} records loaded"); if (allRecords.Count == 0) { jobService.CompleteJob(jobId, true, "No deployable records found. Nothing to do."); context.OutputParameters["xrm_successcount"] = 0; context.OutputParameters["xrm_failcount"] = 0; return; } // Phase 3 ── Deploy metadata ────────────────────────────────────── var deployService = new MetadataDeploymentService(service, tracer); var results = deployService.DeployAll(allRecords); var succeeded = results.Where(r => r.Success).ToList(); var failed = results.Where(r => !r.Success).ToList(); tracer.Trace($"[Deploy] Phase 3 OK – Success={succeeded.Count}, Failed={failed.Count}"); // Phase 4 ── Selective PublishXml ────────────────────────────────── var affectedEntities = succeeded .Select(r => allRecords.First(x => x.RecordId == r.RecordId).TargetEntityLogicalName) .Where(e => !string.IsNullOrWhiteSpace(e)) .Distinct().ToList(); if (affectedEntities.Any()) { new PublishService(service, tracer).PublishEntities(affectedEntities); tracer.Trace($"[Deploy] Phase 4 OK – Published {affectedEntities.Count} entities"); } // Phase 5 ── Status update – only successfully deployed records ──── new StatusUpdateService(service, tracer).MarkAsPublished(succeeded, allRecords); tracer.Trace($"[Deploy] Phase 5 OK – {succeeded.Count} records marked Published"); // Phase 6 ── Finalize PublishJob ─────────────────────────────────── var log = BuildLog(succeeded, failed); jobService.CompleteJob(jobId, failed.Count == 0, log); context.OutputParameters["xrm_successcount"] = succeeded.Count; context.OutputParameters["xrm_failcount"] = failed.Count; tracer.Trace($"[Deploy] DONE"); } catch (Exception ex) { tracer.Trace($"[Deploy] FATAL: {ex.Message}\n{ex.StackTrace}"); if (jobId != Guid.Empty) jobService.FailJob(jobId, ex.Message); throw new InvalidPluginExecutionException($"Deployment failed: {ex.Message}", ex); } } private static string BuildLog(List<DeploymentResult> succeeded, List<DeploymentResult> failed) { var sb = new StringBuilder(); sb.AppendLine($"Deployment Summary: {succeeded.Count} succeeded, {failed.Count} failed."); if (failed.Any()) { sb.AppendLine("--- Failed Records ---"); foreach (var f in failed.Take(100)) sb.AppendLine($" [{f.EntityType}] {f.RecordId}: {f.ErrorMessage}"); if (failed.Count > 100) sb.AppendLine($" ... and {failed.Count - 100} more."); } return sb.ToString(); } } }
TranslationRecord, DeploymentResult, FormPatchContext, TranslationEntityType
using System; namespace XrmTranslationStudio.Deployment.Models { public enum TranslationEntityType { Table, Attribute, Form, Tab, Section, View, LocalOptionSetValue, GlobalOptionSetValue } public class TranslationRecord { public Guid RecordId { get; set; } public TranslationEntityType EntityType { get; set; } // Target metadata identifiers public string TargetEntityLogicalName { get; set; } public string TargetAttributeLogicalName { get; set; } public string TargetFormId { get; set; } public string TargetTabId { get; set; } public string TargetSectionId { get; set; } public string TargetViewId { get; set; } public string TargetOptionSetName { get; set; } public int? TargetOptionValue { get; set; } // Translation payload public string TranslatedLabel { get; set; } public int LanguageCode { get; set; } // Form XML parent reference (for Tab / Section) public Guid? ParentFormId { get; set; } public string ParentFormEntityLogicalName{ get; set; } } public class DeploymentResult { public Guid RecordId { get; set; } public bool Success { get; set; } public string ErrorMessage { get; set; } public TranslationEntityType EntityType { get; set; } } }
using System; using System.Collections.Generic; namespace XrmTranslationStudio.Deployment.Models { /// <summary> /// Aggregates all pending label patches for a single systemform record. /// Prevents multiple FormXML load/save round-trips for the same form. /// </summary> public class FormPatchContext { public Guid FormId { get; set; } public string EntityLogicalName { get; set; } public List<TranslationRecord> TabPatches { get; } = new List<TranslationRecord>(); public List<TranslationRecord> SectionPatches { get; } = new List<TranslationRecord>(); public List<TranslationRecord> FormLabelPatches { get; } = new List<TranslationRecord>(); } }
Erstellt und aktualisiert xrm_publishjob — Audit-Trail und Fehlerlog der Pipeline
using System; using Microsoft.Xrm.Sdk; namespace XrmTranslationStudio.Deployment.Services { public class PublishJobService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; private const int Status_Running = 570690001; private const int Status_Completed = 570690002; private const int Status_Failed = 570690003; private const int MaxLogLength = 50000; public PublishJobService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } public Guid CreateJob(int languageCode, string tableFilter) { var job = new Entity("xrm_publishjob"); job["xrm_name"] = $"Translation Deploy LCID={languageCode} @ {DateTime.UtcNow:u}"; job["xrm_status"] = new OptionSetValue(Status_Running); job["xrm_startedon"] = DateTime.UtcNow; job["xrm_tables"] = tableFilter ?? "all"; var id = _service.Create(job); _tracer.Trace($"[PublishJobService] Created job {id}"); return id; } public void CompleteJob(Guid jobId, bool success, string log) { var job = new Entity("xrm_publishjob", jobId); job["xrm_status"] = new OptionSetValue(success ? Status_Completed : Status_Failed); job["xrm_finishedon"] = DateTime.UtcNow; job["xrm_log"] = TruncateLog(log); _service.Update(job); _tracer.Trace($"[PublishJobService] Job {jobId} → {(success ? "Completed" : "Failed")}"); } public void FailJob(Guid jobId, string errorMessage) { try { var job = new Entity("xrm_publishjob", jobId); job["xrm_status"] = new OptionSetValue(Status_Failed); job["xrm_finishedon"] = DateTime.UtcNow; job["xrm_log"] = TruncateLog($"FATAL ERROR: {errorMessage}"); _service.Update(job); } catch (Exception ex) { _tracer.Trace($"[PublishJobService] Could not update FailJob: {ex.Message}"); } } private static string TruncateLog(string log) { if (log == null) return string.Empty; return log.Length > MaxLogLength ? log.Substring(0, MaxLogLength) + "\n[LOG TRUNCATED]" : log; } } }
Lädt alle deployable Records über alle 8 Entity-Typen — paged, gefiltert, NULL-gesichert
using System; using System.Collections.Generic; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; using XrmTranslationStudio.Deployment.Models; namespace XrmTranslationStudio.Deployment.Services { public class TranslationQueryService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; // Status values eligible for deployment: New (570690000) + Changed (570690001) private static readonly int[] DeployableStatuses = { 570690000, 570690001 }; private const int PageSize = 50; public TranslationQueryService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } public List<TranslationRecord> LoadAllDeployableRecords(int lcid, string tableFilter) { var all = new List<TranslationRecord>(); all.AddRange(LoadTableTranslations(lcid, tableFilter)); all.AddRange(LoadAttributeTranslations(lcid, tableFilter)); all.AddRange(LoadFormTranslations(lcid, tableFilter)); all.AddRange(LoadTabTranslations(lcid, tableFilter)); all.AddRange(LoadSectionTranslations(lcid, tableFilter)); all.AddRange(LoadViewTranslations(lcid, tableFilter)); all.AddRange(LoadLocalOptionSetTranslations(lcid, tableFilter)); all.AddRange(LoadGlobalOptionSetTranslations(lcid)); _tracer.Trace($"[QueryService] Total deployable records: {all.Count}"); return all; } // ── Per-entity query methods ──────────────────────────────────────── private List<TranslationRecord> LoadTableTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_tablemanagement", "xrm_tablemanagementid", "xrm_entitylogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.Table, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }); } private List<TranslationRecord> LoadAttributeTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_attribute", "xrm_attributeid", "xrm_entitylogicalname", "xrm_attributelogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.Attribute, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetAttributeLogicalName = e.GetAttributeValue<string>("xrm_attributelogicalname"), TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }); } private List<TranslationRecord> LoadFormTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_form", "xrm_formid", "xrm_formid_target", "xrm_entitylogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => { var raw = e.GetAttributeValue<string>("xrm_formid_target"); return new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.Form, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetFormId = raw, ParentFormId = Guid.TryParse(raw, out var fg) ? fg : (Guid?)null, TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }; }); } private List<TranslationRecord> LoadTabTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_tab", "xrm_tabid", "xrm_tabid_target", "xrm_formid_target", "xrm_entitylogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => { var raw = e.GetAttributeValue<string>("xrm_formid_target"); return new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.Tab, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetTabId = e.GetAttributeValue<string>("xrm_tabid_target"), TargetFormId = raw, ParentFormId = Guid.TryParse(raw, out var fg) ? fg : (Guid?)null, TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }; }); } private List<TranslationRecord> LoadSectionTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_sections", "xrm_sectionsid", "xrm_sectionid_target", "xrm_tabid_target", "xrm_formid_target", "xrm_entitylogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => { var raw = e.GetAttributeValue<string>("xrm_formid_target"); return new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.Section, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetSectionId = e.GetAttributeValue<string>("xrm_sectionid_target"), TargetTabId = e.GetAttributeValue<string>("xrm_tabid_target"), TargetFormId = raw, ParentFormId = Guid.TryParse(raw, out var fg) ? fg : (Guid?)null, TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }; }); } private List<TranslationRecord> LoadViewTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_view", "xrm_viewid", "xrm_viewid_target", "xrm_entitylogicalname", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.View, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetViewId = e.GetAttributeValue<string>("xrm_viewid_target"), TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }); } private List<TranslationRecord> LoadLocalOptionSetTranslations(int lcid, string f) { var q = BuildBaseQuery("xrm_localoptionsetvalue", "xrm_localoptionsetvalueid", "xrm_entitylogicalname", "xrm_attributelogicalname", "xrm_optionvalue", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); ApplyTableFilter(q, "xrm_entitylogicalname", f); return PagedQuery(q, e => new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.LocalOptionSetValue, TargetEntityLogicalName = e.GetAttributeValue<string>("xrm_entitylogicalname"), TargetAttributeLogicalName = e.GetAttributeValue<string>("xrm_attributelogicalname"), TargetOptionValue = e.GetAttributeValue<int?>("xrm_optionvalue"), TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }); } private List<TranslationRecord> LoadGlobalOptionSetTranslations(int lcid) { var q = BuildBaseQuery("xrm_globaloptionsetvalue", "xrm_globaloptionsetvalueid", "xrm_optionsetname", "xrm_optionvalue", "xrm_translatedlabel", "xrm_languagecode", "xrm_deploymentstatus"); ApplyCriteria(q, lcid); return PagedQuery(q, e => new TranslationRecord { RecordId = e.Id, EntityType = TranslationEntityType.GlobalOptionSetValue, TargetOptionSetName = e.GetAttributeValue<string>("xrm_optionsetname"), TargetOptionValue = e.GetAttributeValue<int?>("xrm_optionvalue"), TranslatedLabel = e.GetAttributeValue<string>("xrm_translatedlabel"), LanguageCode = lcid }); } // ── Helpers ───────────────────────────────────────────────────────── private static QueryExpression BuildBaseQuery(string entity, params string[] cols) => new QueryExpression(entity) { ColumnSet = new ColumnSet(cols) }; private void ApplyCriteria(QueryExpression q, int lcid) { var f = new FilterExpression(LogicalOperator.And); f.AddCondition("xrm_languagecode", ConditionOperator.Equal, lcid); var sf = new FilterExpression(LogicalOperator.Or); foreach (var s in DeployableStatuses) sf.AddCondition("xrm_deploymentstatus", ConditionOperator.Equal, s); f.AddFilter(sf); f.AddCondition("xrm_translatedlabel", ConditionOperator.NotNull); f.AddCondition("xrm_translatedlabel", ConditionOperator.NotEqual, string.Empty); q.Criteria = f; } private static void ApplyTableFilter(QueryExpression q, string field, string filter) { if (string.IsNullOrWhiteSpace(filter)) return; var tables = filter.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); if (tables.Length == 0) return; var tf = new FilterExpression(LogicalOperator.Or); foreach (var t in tables) tf.AddCondition(field, ConditionOperator.Equal, t.Trim().ToLowerInvariant()); q.Criteria.AddFilter(tf); } private List<TranslationRecord> PagedQuery( QueryExpression query, Func<Entity, TranslationRecord> mapper) { var results = new List<TranslationRecord>(); query.PageInfo = new PagingInfo { Count = PageSize, PageNumber = 1, ReturnTotalRecordCount = false }; while (true) { var response = _service.RetrieveMultiple(query); foreach (var entity in response.Entities) { try { var record = mapper(entity); if (!string.IsNullOrWhiteSpace(record?.TranslatedLabel)) results.Add(record); } catch (Exception ex) { _tracer.Trace($"[QueryService] Mapping error for {entity.Id}: {ex.Message}"); } } if (!response.MoreRecords) break; query.PageInfo.PageNumber++; query.PageInfo.PagingCookie = response.PagingCookie; } return results; } } }
Deploy in Dependency-Order — je Typ eigener Handler, per-Record Error Isolation
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Query; using XrmTranslationStudio.Deployment.Helpers; using XrmTranslationStudio.Deployment.Models; namespace XrmTranslationStudio.Deployment.Services { public class MetadataDeploymentService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; public MetadataDeploymentService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } /// Deploys all translation records in dependency order. public List<DeploymentResult> DeployAll(List<TranslationRecord> records) { var results = new List<DeploymentResult>(); results.AddRange(DeployTableLabels( records.Where(r => r.EntityType == TranslationEntityType.Table).ToList())); results.AddRange(DeployAttributeLabels( records.Where(r => r.EntityType == TranslationEntityType.Attribute).ToList())); results.AddRange(DeployFormLabels( records.Where(r => r.EntityType == TranslationEntityType.Form).ToList())); results.AddRange(DeployFormXmlPatches( records.Where(r => r.EntityType == TranslationEntityType.Tab).ToList(), records.Where(r => r.EntityType == TranslationEntityType.Section).ToList())); results.AddRange(DeployViewLabels( records.Where(r => r.EntityType == TranslationEntityType.View).ToList())); results.AddRange(DeployLocalOptionSetLabels( records.Where(r => r.EntityType == TranslationEntityType.LocalOptionSetValue).ToList())); results.AddRange(DeployGlobalOptionSetLabels( records.Where(r => r.EntityType == TranslationEntityType.GlobalOptionSetValue).ToList())); return results; } // ── Table labels ───────────────────────────────────────────────────── private List<DeploymentResult> DeployTableLabels(List<TranslationRecord> records) { _tracer.Trace($"[Deploy] Table labels: {records.Count}"); var results = new List<DeploymentResult>(); foreach (var r in records) { var res = new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType }; try { var resp = (RetrieveEntityResponse)_service.Execute( new RetrieveEntityRequest { LogicalName = r.TargetEntityLogicalName, EntityFilters = EntityFilters.Entity }); MetadataHelper.SetOrUpdateLabel(resp.EntityMetadata.DisplayName, r.LanguageCode, r.TranslatedLabel); _service.Execute(new UpdateEntityRequest { Entity = resp.EntityMetadata }); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } return results; } // ── Attribute labels ───────────────────────────────────────────────── private List<DeploymentResult> DeployAttributeLabels(List<TranslationRecord> records) { _tracer.Trace($"[Deploy] Attribute labels: {records.Count}"); var results = new List<DeploymentResult>(); foreach (var r in records) { var res = new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType }; try { var resp = (RetrieveAttributeResponse)_service.Execute( new RetrieveAttributeRequest { EntityLogicalName = r.TargetEntityLogicalName, LogicalName = r.TargetAttributeLogicalName, RetrieveAsIfPublished = true }); MetadataHelper.SetOrUpdateLabel(resp.AttributeMetadata.DisplayName, r.LanguageCode, r.TranslatedLabel); _service.Execute(new UpdateAttributeRequest { EntityName = r.TargetEntityLogicalName, Attribute = resp.AttributeMetadata }); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } return results; } // ── Form labels (deferred to FormXML phase) ────────────────────────── private List<DeploymentResult> DeployFormLabels(List<TranslationRecord> records) { // systemform.name is NOT localizable via Metadata API. // Form label translation is applied in DeployFormXmlPatches via LocalizedNames XML node. // We mark all form label records as success so they proceed to status update. return records.Select(r => new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType, Success = true }).ToList(); } // ── FormXML patches — grouped by form to minimize round-trips ──────── private List<DeploymentResult> DeployFormXmlPatches( List<TranslationRecord> tabs, List<TranslationRecord> sections) { _tracer.Trace($"[Deploy] FormXML: {tabs.Count} tabs + {sections.Count} sections"); var results = new List<DeploymentResult>(); var formGroups = new Dictionary<Guid, FormPatchContext>(); void AddToGroup(TranslationRecord rec) { if (rec.ParentFormId == null || rec.ParentFormId == Guid.Empty) return; var fid = rec.ParentFormId.Value; if (!formGroups.ContainsKey(fid)) formGroups[fid] = new FormPatchContext { FormId = fid, EntityLogicalName = rec.TargetEntityLogicalName }; if (rec.EntityType == TranslationEntityType.Tab) formGroups[fid].TabPatches.Add(rec); else if (rec.EntityType == TranslationEntityType.Section) formGroups[fid].SectionPatches.Add(rec); } foreach (var r in tabs) AddToGroup(r); foreach (var r in sections) AddToGroup(r); var patchService = new FormXmlPatchService(_service, _tracer); foreach (var kvp in formGroups) results.AddRange(patchService.PatchForm(kvp.Value)); // Records with null ParentFormId: mark as failed foreach (var r in tabs.Concat(sections) .Where(r => r.ParentFormId == null || r.ParentFormId == Guid.Empty)) { results.Add(new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType, Success = false, ErrorMessage = "ParentFormId is null – cannot identify form." }); } return results; } // ── View labels ────────────────────────────────────────────────────── private List<DeploymentResult> DeployViewLabels(List<TranslationRecord> records) { _tracer.Trace($"[Deploy] View labels: {records.Count}"); var results = new List<DeploymentResult>(); foreach (var r in records) { var res = new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType }; try { if (!Guid.TryParse(r.TargetViewId, out var viewGuid)) throw new InvalidOperationException($"Invalid TargetViewId: '{r.TargetViewId}'"); var view = new Entity("savedquery", viewGuid); view["name"] = r.TranslatedLabel; _service.Update(view); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } return results; } // ── Local OptionSet labels ─────────────────────────────────────────── private List<DeploymentResult> DeployLocalOptionSetLabels(List<TranslationRecord> records) { _tracer.Trace($"[Deploy] Local OptionSet labels: {records.Count}"); var results = new List<DeploymentResult>(); foreach (var r in records) { var res = new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType }; try { if (r.TargetOptionValue == null) throw new InvalidOperationException("TargetOptionValue is null."); var resp = (RetrieveAttributeResponse)_service.Execute( new RetrieveAttributeRequest { EntityLogicalName = r.TargetEntityLogicalName, LogicalName = r.TargetAttributeLogicalName, RetrieveAsIfPublished = true }); if (!(resp.AttributeMetadata is EnumAttributeMetadata enumMeta)) throw new InvalidOperationException( $"'{r.TargetAttributeLogicalName}' is not an OptionSet attribute."); var opt = enumMeta.OptionSet.Options.FirstOrDefault(o => o.Value == r.TargetOptionValue); if (opt == null) throw new InvalidOperationException( $"Option value {r.TargetOptionValue} not found in '{r.TargetAttributeLogicalName}'."); MetadataHelper.SetOrUpdateLabel(opt.Label, r.LanguageCode, r.TranslatedLabel); _service.Execute(new UpdateAttributeRequest { EntityName = r.TargetEntityLogicalName, Attribute = resp.AttributeMetadata }); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } return results; } // ── Global OptionSet labels — grouped to minimize round-trips ──────── private List<DeploymentResult> DeployGlobalOptionSetLabels(List<TranslationRecord> records) { _tracer.Trace($"[Deploy] Global OptionSet labels: {records.Count}"); var results = new List<DeploymentResult>(); foreach (var group in records.GroupBy(r => r.TargetOptionSetName)) { if (string.IsNullOrWhiteSpace(group.Key)) continue; try { var resp = (RetrieveOptionSetResponse)_service.Execute( new RetrieveOptionSetRequest { Name = group.Key }); var osMeta = (OptionSetMetadata)resp.OptionSetMetadata; foreach (var r in group) { var res = new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType }; var opt = osMeta.Options.FirstOrDefault(o => o.Value == r.TargetOptionValue); if (opt == null) res.ErrorMessage = $"Option {r.TargetOptionValue} not found in '{group.Key}'."; else { MetadataHelper.SetOrUpdateLabel(opt.Label, r.LanguageCode, r.TranslatedLabel); res.Success = true; } results.Add(res); } _service.Execute(new UpdateOptionSetRequest { OptionSet = osMeta }); } catch (Exception ex) { foreach (var r in group) results.Add(new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType, Success = false, ErrorMessage = $"OptionSet '{group.Key}' load failed: {ex.Message}" }); } } return results; } } }
Patcht FormXML für Tabs und Sections — jedes Form wird genau einmal geladen und gespeichert
using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; using XrmTranslationStudio.Deployment.Models; namespace XrmTranslationStudio.Deployment.Services { /// <summary> /// Loads each systemform record once, applies all pending tab/section label patches, /// then saves FormXML once. Prevents N round-trips for the same form. /// /// FormXML tab structure: /// <form><tabs><tab id="tab_general" ...> /// <labels><label description="General" languagecode="1033"/></labels> /// </tab></tabs></form> /// </summary> public class FormXmlPatchService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; public FormXmlPatchService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } public List<DeploymentResult> PatchForm(FormPatchContext context) { var results = new List<DeploymentResult>(); try { // Load FormXML once var formEntity = _service.Retrieve( "systemform", context.FormId, new ColumnSet("formxml")); var formXml = formEntity.GetAttributeValue<string>("formxml"); if (string.IsNullOrWhiteSpace(formXml)) throw new InvalidOperationException($"FormXML is empty for form {context.FormId}"); var doc = XDocument.Parse(formXml); // Apply tab patches foreach (var tab in context.TabPatches) { var res = new DeploymentResult { RecordId = tab.RecordId, EntityType = tab.EntityType }; try { PatchTabLabel(doc, tab.TargetTabId, tab.LanguageCode, tab.TranslatedLabel); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } // Apply section patches foreach (var sec in context.SectionPatches) { var res = new DeploymentResult { RecordId = sec.RecordId, EntityType = sec.EntityType }; try { PatchSectionLabel(doc, sec.TargetTabId, sec.TargetSectionId, sec.LanguageCode, sec.TranslatedLabel); res.Success = true; } catch (Exception ex) { res.ErrorMessage = ex.Message; } results.Add(res); } // Save FormXML once with all patches applied var updated = new Entity("systemform", context.FormId); updated["formxml"] = doc.ToString(SaveOptions.DisableFormatting); _service.Update(updated); _tracer.Trace($"[FormXML] Form {context.FormId} saved. Tabs={context.TabPatches.Count}, Sections={context.SectionPatches.Count}"); } catch (Exception ex) { _tracer.Trace($"[FormXML] FATAL for form {context.FormId}: {ex.Message}"); foreach (var r in context.TabPatches.Concat(context.SectionPatches)) results.Add(new DeploymentResult { RecordId = r.RecordId, EntityType = r.EntityType, Success = false, ErrorMessage = ex.Message }); } return results; } private void PatchTabLabel(XDocument doc, string tabId, int lcid, string label) { var tab = FindById(doc.Root, "tab", tabId); if (tab == null) throw new InvalidOperationException($"Tab '{tabId}' not found in FormXML."); PatchLabelsNode(tab, lcid, label); } private void PatchSectionLabel(XDocument doc, string tabId, string sectionId, int lcid, string label) { var searchRoot = !string.IsNullOrWhiteSpace(tabId) ? FindById(doc.Root, "tab", tabId) : null; var section = FindById(searchRoot ?? doc.Root, "section", sectionId); if (section == null) throw new InvalidOperationException($"Section '{sectionId}' not found in FormXML."); PatchLabelsNode(section, lcid, label); } private static XElement FindById(XElement root, string tag, string id) { if (root == null || string.IsNullOrWhiteSpace(id)) return null; return root.Descendants(tag).FirstOrDefault(e => string.Equals( e.Attribute("id")?.Value ?? e.Attribute("name")?.Value, id, StringComparison.OrdinalIgnoreCase)); } private static void PatchLabelsNode(XElement parent, int lcid, string label) { var labelsNode = parent.Element("labels"); if (labelsNode == null) { labelsNode = new XElement("labels"); parent.AddFirst(labelsNode); } var existing = labelsNode.Elements("label") .FirstOrDefault(l => l.Attribute("languagecode")?.Value == lcid.ToString()); if (existing != null) existing.SetAttributeValue("description", label); else labelsNode.Add(new XElement("label", new XAttribute("description", label), new XAttribute("languagecode", lcid))); } } }
Selektiver PublishXml in 10er-Chunks — Publish-Fehler sind non-fatal
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; namespace XrmTranslationStudio.Deployment.Services { public class PublishService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; private const int ChunkSize = 10; public PublishService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } public void PublishEntities(List<string> entityLogicalNames) { if (entityLogicalNames == null || entityLogicalNames.Count == 0) return; var distinct = entityLogicalNames .Where(e => !string.IsNullOrWhiteSpace(e)) .Distinct().ToList(); _tracer.Trace($"[PublishService] Publishing {distinct.Count} entities in chunks of {ChunkSize}"); for (int i = 0; i < distinct.Count; i += ChunkSize) { var chunk = distinct.Skip(i).Take(ChunkSize).ToList(); PublishChunk(chunk); } } private void PublishChunk(List<string> entities) { try { var sb = new StringBuilder(); sb.Append("<importexportxml><entities>"); foreach (var e in entities) sb.Append($"<entity>{e}</entity>"); sb.Append("</entities></importexportxml>"); _service.Execute(new PublishXmlRequest { ParameterXml = sb.ToString() }); _tracer.Trace($"[PublishService] OK: {string.Join(", ", entities)}"); } catch (Exception ex) { // Non-fatal: deployment has already occurred. // Status update will still run for successfully deployed records. _tracer.Trace($"[PublishService] Publish error (non-fatal): {ex.Message}"); } } } }
Markiert NUR erfolgreich deployte Records als Published — ExecuteMultiple in 20er-Batches
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using XrmTranslationStudio.Deployment.Models; namespace XrmTranslationStudio.Deployment.Services { public class StatusUpdateService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; private const int Status_Published = 570690004; private const int BatchSize = 20; private static readonly Dictionary<TranslationEntityType, string> EntityMap = new Dictionary<TranslationEntityType, string> { { TranslationEntityType.Table, "xrm_tablemanagement" }, { TranslationEntityType.Attribute, "xrm_attribute" }, { TranslationEntityType.Form, "xrm_form" }, { TranslationEntityType.Tab, "xrm_tab" }, { TranslationEntityType.Section, "xrm_sections" }, { TranslationEntityType.View, "xrm_view" }, { TranslationEntityType.LocalOptionSetValue, "xrm_localoptionsetvalue" }, { TranslationEntityType.GlobalOptionSetValue, "xrm_globaloptionsetvalue" }, }; public StatusUpdateService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } /// Updates xrm_deploymentstatus to Published (570690004) ONLY for records /// that were actually successfully deployed. Never touches other records. public void MarkAsPublished( List<DeploymentResult> successfulResults, List<TranslationRecord> originalRecords) { if (successfulResults == null || successfulResults.Count == 0) return; var lookup = originalRecords.ToDictionary(r => r.RecordId, r => r.EntityType); var updates = successfulResults .Where(r => r.Success) .Select(r => { if (!lookup.TryGetValue(r.RecordId, out var entityType)) return null; if (!EntityMap.TryGetValue(entityType, out var logicalName)) return null; var e = new Entity(logicalName, r.RecordId); e["xrm_deploymentstatus"] = new OptionSetValue(Status_Published); return e; }) .Where(e => e != null) .ToList(); _tracer.Trace($"[StatusUpdateService] Marking {updates.Count} records as Published"); for (int i = 0; i < updates.Count; i += BatchSize) { var batch = updates.Skip(i).Take(BatchSize).ToList(); ExecuteBatch(batch); } } private void ExecuteBatch(List<Entity> entities) { try { var req = new ExecuteMultipleRequest { Settings = new ExecuteMultipleSettings { ContinueOnError = true, ReturnResponses = false }, Requests = new OrganizationRequestCollection() }; foreach (var e in entities) req.Requests.Add(new UpdateRequest { Target = e }); var response = (ExecuteMultipleResponse)_service.Execute(req); if (response.IsFaulted) { foreach (var r in response.Responses) if (r.Fault != null) _tracer.Trace($"[StatusUpdateService] Batch item fault: {r.Fault.Message}"); } } catch (Exception ex) { _tracer.Trace($"[StatusUpdateService] Batch error (non-fatal): {ex.Message}"); } } } }
MetadataHelper — setzt oder ersetzt einen lokalisierten Label-Wert
using System.Linq; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Metadata; namespace XrmTranslationStudio.Deployment.Helpers { public static class MetadataHelper { /// <summary> /// Sets or replaces the localized label for a given LCID in a Label collection. /// If the language already exists the description is updated in-place. /// If not, a new LocalizedLabel is appended. /// </summary> public static void SetOrUpdateLabel(Label labelCollection, int lcid, string value) { if (labelCollection == null || string.IsNullOrWhiteSpace(value)) return; var existing = labelCollection.LocalizedLabels .FirstOrDefault(l => l.LanguageCode == lcid); if (existing != null) existing.Label = value; else labelCollection.LocalizedLabels.Add(new LocalizedLabel(value, lcid)); } } }
Translation Type → Dataverse API → Methode
| Translation Type | Dataverse API | Ziel / Methode | Hinweis |
|---|---|---|---|
| Table Label | RetrieveEntityRequest UpdateEntityRequest | EntityMetadata.DisplayName Label Collection | EntityFilters.Entity |
| Attribute Label | RetrieveAttributeRequest UpdateAttributeRequest | AttributeMetadata.DisplayName Label Collection | RetrieveAsIfPublished = true |
| Form Label | systemform.formxml XDocument Patch | <LocalizedNames> Node im FormXML | systemform.name ist nicht direkt lokalisierbar via Metadata API |
| Tab Label | systemform.formxml XDocument Patch | <tab id="..."><labels><label/> | Alle Patches pro Form in einer Load/Save-Op |
| Section Label | systemform.formxml XDocument Patch | <section name="..."><labels><label/> | TabId als Hint, Fallback auf Root-Suche |
| View Label | savedquery (Update) | savedquery.name Feld | ⚠ Single-Language — kein echter multilingualer Label-Support via API |
| Local OptionSet | RetrieveAttributeRequest UpdateAttributeRequest | EnumAttributeMetadata.OptionSet.Options[n].Label | Cast zu EnumAttributeMetadata notwendig |
| Global OptionSet | RetrieveOptionSetRequest UpdateOptionSetRequest | OptionSetMetadata.Options[n].Label | Gruppiert nach OptionSetName — 1 Update pro OptionSet |
Hier steckt der Tabellen-Switcher direkt in der Seite — inklusive aller Tabellen, Feldtypen, Notes und Filter-Pills.
Plugin Registration Tool — Custom API Konfiguration
Bekannte technische Grenzen, offene Punkte und Follow-up Empfehlungen
Alle xrm_*-Feldnamen (z.B. xrm_tabid_target, xrm_sectionid_target) sind strukturelle Annahmen und müssen gegen das echte Schema geprüft werden.
savedquery.name ist ein einfaches String-Feld. Echter Multilingual-Support erfordert Translation Import API oder savedquery-XML-Patch. Aktuelle Implementierung überschreibt nur den Base-Name.
Bei >200 Attributen oder >500 Records kann das Sandbox-Zeitlimit erreicht werden. Empfehlung: Async Plugin oder Job-Split nach Entity-Typ.
Für Global OptionSets muss PublishXml zusätzlich das <optionsets>-Element enthalten. Der PublishService enthält aktuell nur Entity-Publishing.
FormXML verwendet sowohl id- als auch name-Attribute je nach Form-Typ. Der PatchService sucht nach beiden — sicherstellen dass xrm_tabid_target den korrekten Identifier aus dem FormXML enthält.
ExecuteMultiple ist in Sandbox-Plugins verfügbar. ContinueOnError=true gesetzt — einzelne Fehler in einem Batch stoppen nicht die gesamte Status-Update-Operation.
systemform.name ist nicht direkt über die Metadata API lokalisierbar. Form-Label-Übersetzung muss über den <LocalizedNames>-Node im FormXML oder über den Import Translation Workflow erfolgen.