diff --git a/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationContent.cs b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationContent.cs new file mode 100644 index 0000000..ec1671c --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationContent.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Linq; +using PowerDocu.Common; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + public class ClassicWorkflowDocumentationContent + { + public string folderPath, filename; + public ClassicWorkflowEntity workflow; + public DocumentationContext context; + + public string headerOverview = "Overview"; + public string headerSteps = "Steps"; + public string headerStepDetails = "Step Details"; + public string headerTableRelationships = "Table Relationships"; + public string headerProperties = "Properties"; + public string headerDocumentationGenerated = "Documentation generated at"; + + public ClassicWorkflowDocumentationContent(ClassicWorkflowEntity workflow, string path, DocumentationContext context) + { + NotificationHelper.SendNotification("Preparing documentation content for Classic Workflow: " + workflow.GetDisplayName()); + this.workflow = workflow; + this.context = context; + folderPath = path + CharsetHelper.GetSafeName(@"\WorkflowDoc " + workflow.GetDisplayName() + @"\"); + Directory.CreateDirectory(folderPath); + filename = CharsetHelper.GetSafeName(workflow.GetDisplayName()); + } + + public string GetTableDisplayName(string schemaName) + { + if (string.IsNullOrEmpty(schemaName)) return schemaName; + string displayName = context?.GetTableDisplayName(schemaName) ?? schemaName; + // If display name differs from logical name, show both: "Display Name (logical_name)" + if (!string.IsNullOrEmpty(displayName) && !displayName.Equals(schemaName, System.StringComparison.OrdinalIgnoreCase)) + return displayName + " (" + schemaName + ")"; + return schemaName; + } + + /// + /// Resolves a field logical name to "Display Name (logical_name)" by looking up + /// the column in the primary entity's table definition. + /// + public string GetFieldDisplayName(string fieldLogicalName, string entityLogicalName = null) + { + if (string.IsNullOrEmpty(fieldLogicalName)) return fieldLogicalName; + + string tableName = entityLogicalName ?? workflow.PrimaryEntity; + if (string.IsNullOrEmpty(tableName) || context?.Tables == null) return fieldLogicalName; + + var table = context.Tables.FirstOrDefault(t => + t.getName().Equals(tableName, System.StringComparison.OrdinalIgnoreCase)); + if (table == null) return fieldLogicalName; + + var column = table.GetColumns().FirstOrDefault(c => + c.getLogicalName().Equals(fieldLogicalName, System.StringComparison.OrdinalIgnoreCase)); + if (column == null) return fieldLogicalName; + + string displayName = column.getDisplayName(); + if (!string.IsNullOrEmpty(displayName) && !displayName.Equals(fieldLogicalName, System.StringComparison.OrdinalIgnoreCase)) + return displayName + " (" + fieldLogicalName + ")"; + return fieldLogicalName; + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationGenerator.cs b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationGenerator.cs new file mode 100644 index 0000000..23b4a77 --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowDocumentationGenerator.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using PowerDocu.Common; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + public static class ClassicWorkflowDocumentationGenerator + { + public static void GenerateOutput(DocumentationContext context, string path) + { + if (context.ClassicWorkflows == null || context.ClassicWorkflows.Count == 0 || !context.Config.documentClassicWorkflows) return; + + DateTime startDocGeneration = DateTime.Now; + NotificationHelper.SendNotification($"Found {context.ClassicWorkflows.Count} Classic Workflow(s) in the solution."); + + if (context.FullDocumentation) + { + foreach (ClassicWorkflowEntity workflow in context.ClassicWorkflows) + { + ClassicWorkflowDocumentationContent content = new ClassicWorkflowDocumentationContent(workflow, path, context); + + // Generate workflow flow diagram + if (workflow.Steps.Count > 0) + { + try + { + GraphBuilder graphBuilder = new GraphBuilder(workflow, content.folderPath); + graphBuilder.BuildGraph(); + } + catch (Exception ex) + { + NotificationHelper.SendNotification(" - Warning: Could not generate workflow diagram: " + ex.Message); + } + } + + string wordTemplate = (!String.IsNullOrEmpty(context.Config.wordTemplate) && File.Exists(context.Config.wordTemplate)) + ? context.Config.wordTemplate : null; + if (context.Config.outputFormat.Equals(OutputFormatHelper.Word) || context.Config.outputFormat.Equals(OutputFormatHelper.All)) + { + NotificationHelper.SendNotification("Creating Word documentation for Classic Workflow: " + workflow.GetDisplayName()); + ClassicWorkflowWordDocBuilder wordDoc = new ClassicWorkflowWordDocBuilder(content, wordTemplate); + } + if (context.Config.outputFormat.Equals(OutputFormatHelper.Markdown) || context.Config.outputFormat.Equals(OutputFormatHelper.All)) + { + NotificationHelper.SendNotification("Creating Markdown documentation for Classic Workflow: " + workflow.GetDisplayName()); + ClassicWorkflowMarkdownBuilder markdownDoc = new ClassicWorkflowMarkdownBuilder(content); + } + if (context.Config.outputFormat.Equals(OutputFormatHelper.Html) || context.Config.outputFormat.Equals(OutputFormatHelper.All)) + { + NotificationHelper.SendNotification("Creating HTML documentation for Classic Workflow: " + workflow.GetDisplayName()); + ClassicWorkflowHtmlBuilder htmlDoc = new ClassicWorkflowHtmlBuilder(content); + } + context.Progress?.Increment("Classic Workflows"); + } + } + else + { + context.Progress?.Complete("ClassicWorkflows"); + } + + DateTime endDocGeneration = DateTime.Now; + NotificationHelper.SendNotification( + $"ClassicWorkflowDocumenter: Processed {context.ClassicWorkflows.Count} Classic Workflow(s) in {(endDocGeneration - startDocGeneration).TotalSeconds} seconds." + ); + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowHtmlBuilder.cs b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowHtmlBuilder.cs new file mode 100644 index 0000000..5a8725d --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowHtmlBuilder.cs @@ -0,0 +1,587 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using PowerDocu.Common; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + class ClassicWorkflowHtmlBuilder : HtmlBuilder + { + private readonly ClassicWorkflowDocumentationContent content; + private readonly string mainFileName; + private readonly string triggerActionsFileName; + private string _navigationHtmlTop; + private string _navigationHtmlSub; + private string _metadataTableHtml; + + public ClassicWorkflowHtmlBuilder(ClassicWorkflowDocumentationContent contentDocumentation) + { + content = contentDocumentation; + Directory.CreateDirectory(content.folderPath); + WriteDefaultStylesheet(content.folderPath); + Directory.CreateDirectory(Path.Combine(content.folderPath, "actions")); + WriteDefaultStylesheet(Path.Combine(content.folderPath, "actions")); + + mainFileName = CollapseDashes(("workflow-" + content.filename + ".html").Replace(" ", "-")); + triggerActionsFileName = CollapseDashes(("triggersactions-" + content.filename + ".html").Replace(" ", "-")); + + addMainPage(); + addTriggersActionsPage(); + addTriggerPage(); + addActionPages(); + NotificationHelper.SendNotification("Created HTML documentation for Classic Workflow: " + content.workflow.GetDisplayName()); + } + + // ── Navigation ── + + private string getNavigationHtml(bool fromSubfolder = false) + => fromSubfolder + ? (_navigationHtmlSub ??= BuildNavigationHtmlCore(true)) + : (_navigationHtmlTop ??= BuildNavigationHtmlCore(false)); + + private string BuildNavigationHtmlCore(bool fromSubfolder) + { + string prefix = fromSubfolder ? "../" : ""; + var navItems = new List<(string label, string href)>(); + if (content.context?.Solution != null) + { + string solutionPrefix = fromSubfolder ? "../../" : "../"; + if (content.context?.Config?.documentSolution == true) + navItems.Add(("Solution", solutionPrefix + CrossDocLinkHelper.GetSolutionDocHtmlPath(content.context.Solution.UniqueName))); + else + navItems.Add((content.context.Solution.UniqueName, "")); + } + navItems.AddRange(new (string label, string href)[] + { + ("Overview", prefix + mainFileName), + ("Triggers & Actions", prefix + triggerActionsFileName), + ("Table Relationships", prefix + mainFileName + "#table-relationships") + }); + StringBuilder sb = new StringBuilder(); + sb.AppendLine($"
{Encode(content.workflow.GetDisplayName())}
"); + sb.Append(NavigationList(navItems)); + return sb.ToString(); + } + + private string buildMetadataTable() + { + if (_metadataTableHtml != null) return _metadataTableHtml; + StringBuilder sb = new StringBuilder(); + sb.Append(TableStart("Property", "Value")); + sb.Append(TableRow("Name", content.workflow.GetDisplayName())); + sb.Append(TableRow("Primary Table", content.GetTableDisplayName(content.workflow.PrimaryEntity))); + sb.Append(TableRow("Category", content.workflow.GetCategoryLabel())); + sb.Append(TableRow("Mode", content.workflow.GetModeLabel())); + sb.Append(TableRow("Scope", content.workflow.GetScopeLabel())); + sb.Append(TableRow("Run As", content.workflow.GetRunAsLabel())); + sb.Append(TableRow("State", content.workflow.GetStateLabel())); + sb.Append(TableRow("Is Customizable", content.workflow.IsCustomizable ? "Yes" : "No")); + if (!string.IsNullOrEmpty(content.workflow.ID)) + sb.Append(TableRow("ID", content.workflow.ID)); + if (!string.IsNullOrEmpty(content.workflow.OwnerId)) + sb.Append(TableRow("Owner", content.workflow.OwnerId)); + if (!string.IsNullOrEmpty(content.workflow.Description)) + sb.Append(TableRow("Description", content.workflow.Description)); + if (!string.IsNullOrEmpty(content.workflow.IntroducedVersion)) + sb.Append(TableRow("Version", content.workflow.IntroducedVersion)); + sb.Append(TableRow("Number of Actions", CountAllSteps(content.workflow.Steps).ToString())); + sb.Append(TableRow("Number of Conditions", CountConditions(content.workflow.Steps).ToString())); + sb.Append(TableRow(content.headerDocumentationGenerated, PowerDocuReleaseHelper.GetTimestampWithVersion())); + sb.Append(TableEnd()); + _metadataTableHtml = sb.ToString(); + return _metadataTableHtml; + } + + // ── Main Index Page ── + + private void addMainPage() + { + StringBuilder body = new StringBuilder(); + + // Overview + body.AppendLine(HeadingWithId(1, content.workflow.GetDisplayName(), "overview")); + body.AppendLine(buildMetadataTable()); + + // Workflow diagram + addWorkflowDiagram(body); + + // Table relationships + if (content.workflow.TableReferences.Count > 0) + { + body.AppendLine(HeadingWithId(2, content.headerTableRelationships, "table-relationships")); + body.AppendLine($"

This workflow references {content.workflow.TableReferences.Count} table relationship(s).

"); + body.Append(TableStart("Table Display Name", "Table Logical Name", "Reference Type")); + foreach (var tableRef in content.workflow.TableReferences) + { + string displayName = content.GetTableDisplayName(tableRef.TableLogicalName); + string displayCell; + if (content.context?.Solution != null && content.context?.Config?.documentSolution == true) + { + string anchor = CrossDocLinkHelper.GetSolutionTableHtmlAnchor(tableRef.TableLogicalName); + string solutionHtmlPath = CrossDocLinkHelper.GetSolutionDocHtmlPath(content.context.Solution.UniqueName); + displayCell = $"{Encode(displayName)}"; + } + else + { + displayCell = Encode(displayName); + } + body.Append(TableRowRaw(displayCell, Encode(tableRef.TableLogicalName), Encode(tableRef.ReferenceType.ToString()))); + } + body.AppendLine(TableEnd()); + } + + SaveHtmlFile(Path.Combine(content.folderPath, mainFileName), + WrapInHtmlPage($"Classic Workflow - {content.workflow.GetDisplayName()}", body.ToString(), getNavigationHtml(false))); + } + + private void AddActionLinksRecursive(StringBuilder body, List steps, int depth) + { + foreach (var step in steps) + { + // ConditionBranch steps: show branch label in the list with children nested + if (step.StepType == ClassicWorkflowStepType.ConditionBranch) + { + string branchLabel = step.Name ?? "Condition Branch"; + body.AppendLine($"
  • {Encode(branchLabel)}
  • "); + if (step.ChildSteps.Count > 0) + { + body.AppendLine(""); + } + continue; + } + + string safeName = CharsetHelper.GetSafeName(step.Name ?? step.GetStepTypeLabel()); + string stepFileName = "actions/" + safeName + ".html"; + body.AppendLine(BulletItemRaw(Link(step.Name ?? step.GetStepTypeLabel(), stepFileName) + + $" ({step.GetStepTypeLabel()})")); + + if (step.ChildSteps.Count > 0) + { + body.AppendLine(""); + } + } + } + + // ── Triggers & Actions Overview Page ── + + private void addTriggersActionsPage() + { + StringBuilder body = new StringBuilder(); + body.AppendLine(Heading(1, content.workflow.GetDisplayName())); + body.AppendLine(buildMetadataTable()); + + // Trigger section with link + body.AppendLine(Heading(2, "Trigger")); + body.AppendLine(BulletListStart()); + body.AppendLine(BulletItemRaw(Link("Trigger", "actions/Trigger.html"))); + body.AppendLine(BulletListEnd()); + + // Actions section with links + body.AppendLine(Heading(2, "Actions")); + if (content.workflow.Steps.Count > 0) + { + int totalSteps = CountAllSteps(content.workflow.Steps); + body.AppendLine($"

    There are a total of {totalSteps} action(s) in this workflow:

    "); + body.AppendLine(BulletListStart()); + AddActionLinksRecursive(body, content.workflow.Steps, 0); + body.AppendLine(BulletListEnd()); + } + + SaveHtmlFile(Path.Combine(content.folderPath, triggerActionsFileName), + WrapInHtmlPage("Triggers & Actions - " + content.workflow.GetDisplayName(), body.ToString(), getNavigationHtml(false))); + } + + // ── Trigger Page ── + + private void addTriggerPage() + { + StringBuilder body = new StringBuilder(); + body.AppendLine(Heading(1, content.workflow.GetDisplayName())); + body.AppendLine(buildMetadataTable()); + body.AppendLine(Heading(2, "Trigger")); + + // Trigger type as bulleted list + body.AppendLine("

    Trigger Type

    "); + body.AppendLine(""); + + SaveHtmlFile(Path.Combine(content.folderPath, "actions", "Trigger.html"), + WrapInHtmlPage("Trigger - " + content.workflow.GetDisplayName(), body.ToString(), getNavigationHtml(true))); + } + + // ── Per-Action Pages ── + + private void addActionPages() + { + if (content.workflow.Steps.Count == 0) return; + AddActionPagesRecursive(content.workflow.Steps); + } + + private void AddActionPagesRecursive(List steps) + { + // Build a filtered list of non-branch steps for prev/next navigation + var navigableSteps = new List(); + for (int i = 0; i < steps.Count; i++) + { + if (steps[i].StepType != ClassicWorkflowStepType.ConditionBranch) + navigableSteps.Add(steps[i]); + } + + for (int i = 0; i < steps.Count; i++) + { + var step = steps[i]; + + // ConditionBranch steps are rendered inline on the parent page; + // skip creating separate pages but still recurse into their children. + if (step.StepType == ClassicWorkflowStepType.ConditionBranch) + { + if (step.ChildSteps.Count > 0) + AddActionPagesRecursive(step.ChildSteps); + continue; + } + + int navIndex = navigableSteps.IndexOf(step); + ClassicWorkflowStep prevStep = (navIndex > 0) ? navigableSteps[navIndex - 1] : null; + ClassicWorkflowStep nextStep = (navIndex >= 0 && navIndex + 1 < navigableSteps.Count) ? navigableSteps[navIndex + 1] : null; + + { + string safeName = CharsetHelper.GetSafeName(step.Name ?? step.GetStepTypeLabel()); + string fileName = safeName + ".html"; + + StringBuilder body = new StringBuilder(); + body.AppendLine(Heading(1, content.workflow.GetDisplayName())); + body.AppendLine(buildMetadataTable()); + body.AppendLine(Heading(2, step.Name ?? step.GetStepTypeLabel())); + + // Action detail table + body.Append(TableStart("Property", "Value")); + body.Append(TableRow("Name", step.Name ?? "")); + body.Append(TableRow("Type", step.GetStepTypeLabel())); + if (!string.IsNullOrEmpty(step.StepDescription)) + body.Append(TableRow("Description", step.StepDescription)); + if (!string.IsNullOrEmpty(step.TargetEntity)) + body.Append(TableRow("Target Table", content.GetTableDisplayName(step.TargetEntity))); + if (!string.IsNullOrEmpty(step.CustomActivityName)) + body.Append(TableRow("Custom Activity", step.CustomActivityName)); + if (!string.IsNullOrEmpty(step.CustomActivityClass)) + body.Append(TableRow("Class", step.CustomActivityClass)); + if (!string.IsNullOrEmpty(step.CustomActivityAssembly)) + body.Append(TableRow("Assembly", step.CustomActivityAssembly)); + if (!string.IsNullOrEmpty(step.CustomActivityFriendlyName)) + body.Append(TableRow("Friendly Name", step.CustomActivityFriendlyName)); + if (!string.IsNullOrEmpty(step.CustomActivityDescription)) + body.Append(TableRow("Description", step.CustomActivityDescription)); + if (!string.IsNullOrEmpty(step.CustomActivityGroupName)) + body.Append(TableRow("Group", step.CustomActivityGroupName)); + body.AppendLine(TableEnd()); + + // Condition tree + if (step.ConditionTree != null) + { + body.AppendLine(Heading(3, "Condition")); + RenderConditionSectionHtml(body, step.ConditionTree); + } + else if (!string.IsNullOrEmpty(step.ConditionDescription)) + { + body.AppendLine(Heading(3, "Condition")); + body.AppendLine($"

    {Encode(step.ConditionDescription)}

    "); + } + + // Field assignments / Inputs + if (step.Fields.Count > 0) + { + string inputsLabel = step.StepType switch + { + ClassicWorkflowStepType.Custom => "Inputs", + ClassicWorkflowStepType.SendEmail => "Email Properties", + _ => "Field Assignments" + }; + body.AppendLine(Heading(3, inputsLabel)); + body.Append(TableStart("Field", "Value")); + foreach (var field in step.Fields) + { + body.Append(TableRow(field.FieldName ?? "", field.Value ?? "")); + } + body.AppendLine(TableEnd()); + } + + // Child steps / Subactions + if (step.ChildSteps.Count > 0) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + { + // Render condition branches inline on the CheckCondition/Wait page + RenderConditionBranchesInline(body, step.ChildSteps); + } + else + { + body.AppendLine(Heading(3, "Subactions")); + body.AppendLine(BulletListStart()); + foreach (var child in step.ChildSteps) + { + string childSafeName = CharsetHelper.GetSafeName(child.Name ?? child.GetStepTypeLabel()); + body.AppendLine(BulletItemRaw( + Link(child.Name ?? child.GetStepTypeLabel(), childSafeName + ".html") + + $" ({child.GetStepTypeLabel()})")); + } + body.AppendLine(BulletListEnd()); + } + } + + // Previous Action(s) + if (prevStep != null) + { + body.AppendLine(Heading(3, "Previous Action(s)")); + string prevSafeName = CharsetHelper.GetSafeName(prevStep.Name ?? prevStep.GetStepTypeLabel()); + body.Append(TableStart("Previous Action")); + body.Append(TableRowRaw( + Link(prevStep.Name ?? prevStep.GetStepTypeLabel(), prevSafeName + ".html") + + $" ({prevStep.GetStepTypeLabel()})")); + body.AppendLine(TableEnd()); + } + + // Next Action(s) + if (nextStep != null) + { + body.AppendLine(Heading(3, "Next Action(s)")); + string nextSafeName = CharsetHelper.GetSafeName(nextStep.Name ?? nextStep.GetStepTypeLabel()); + body.Append(TableStart("Next Action")); + body.Append(TableRowRaw( + Link(nextStep.Name ?? nextStep.GetStepTypeLabel(), nextSafeName + ".html") + + $" ({nextStep.GetStepTypeLabel()})")); + body.AppendLine(TableEnd()); + } + + SaveHtmlFile(Path.Combine(content.folderPath, "actions", fileName), + WrapInHtmlPage(step.GetStepTypeLabel() + " - " + (step.Name ?? ""), body.ToString(), getNavigationHtml(true))); + + // Recurse into child steps + if (step.ChildSteps.Count > 0) + AddActionPagesRecursive(step.ChildSteps); + } + } + } + + // ── Inline Condition Branch Rendering ── + + private void RenderConditionBranchesInline(StringBuilder body, List branches) + { + foreach (var branch in branches) + { + if (branch.StepType != ClassicWorkflowStepType.ConditionBranch) continue; + + string branchLabel = branch.Name ?? "Condition Branch"; + string borderColor = branchLabel == "Otherwise (Default)" ? "#888" : "#0078d4"; + + body.AppendLine($"
    "); + body.AppendLine($"

    {Encode(branchLabel)}

    "); + + // Show condition tree if present + if (branch.ConditionTree != null) + { + RenderConditionSectionHtml(body, branch.ConditionTree); + } + else if (!string.IsNullOrEmpty(branch.ConditionDescription) && branchLabel != "Otherwise (Default)") + { + body.AppendLine($"

    {Encode(branch.ConditionDescription)}

    "); + } + + // Show the branch's child actions as links + if (branch.ChildSteps.Count > 0) + { + body.AppendLine("

    Actions:

    "); + body.AppendLine(BulletListStart()); + foreach (var child in branch.ChildSteps) + { + string childSafeName = CharsetHelper.GetSafeName(child.Name ?? child.GetStepTypeLabel()); + body.AppendLine(BulletItemRaw( + Link(child.Name ?? child.GetStepTypeLabel(), childSafeName + ".html") + + $" ({child.GetStepTypeLabel()})")); + } + body.AppendLine(BulletListEnd()); + } + + body.AppendLine("
    "); + } + } + + // ── Helpers ── + + private void addWorkflowDiagram(StringBuilder body) + { + string svgFile = "workflow.svg"; + string pngFile = "workflow.png"; + + if (!File.Exists(Path.Combine(content.folderPath, pngFile))) return; + + body.AppendLine(HeadingWithId(2, "Workflow Diagram", "workflow-diagram")); + body.AppendLine("

    The following diagram shows the flow of the workflow including condition branches.

    "); + + if (File.Exists(Path.Combine(content.folderPath, svgFile))) + { + try + { + string svgContent = File.ReadAllText(Path.Combine(content.folderPath, svgFile)); + int svgStart = svgContent.IndexOf("= 0) + svgContent = svgContent.Substring(svgStart); + body.AppendLine("
    "); + body.AppendLine(svgContent); + body.AppendLine("
    "); + } + catch + { + body.AppendLine($"

    \"{Encode(content.workflow.GetDisplayName())}\"

    "); + } + } + else + { + body.AppendLine($"

    \"{Encode(content.workflow.GetDisplayName())}\"

    "); + } + } + + private void RenderConditionTreeToHtml(StringBuilder body, ConditionExpression expr, int depth) + { + if (expr.IsLeaf) + { + // Parse field into entity and field parts: "Field Display on Entity Display" + string entityPart = ""; + string fieldPart = expr.Field ?? ""; + if (fieldPart.Contains(" on ")) + { + int onIdx = fieldPart.IndexOf(" on "); + entityPart = fieldPart.Substring(onIdx + 4); + fieldPart = fieldPart.Substring(0, onIdx); + } + + body.AppendLine(""); + body.AppendLine($"{Encode(entityPart)}"); + body.AppendLine($"{Encode(fieldPart)}"); + body.AppendLine($"{Encode(expr.Operator ?? "")}"); + body.AppendLine($"{Encode(expr.Value ?? "")}"); + body.AppendLine(""); + } + else + { + // Group: render with the operator label on top, children below + // The parent operator is shown as a header, children are rows/sub-groups under it + string opColor = expr.LogicalOperator == "OR" ? "#d83b01" : "#0078d4"; + + foreach (var child in expr.Children) + { + if (child.IsGroup) + { + // Nested group: render as bordered sub-section with its operator label + string childColor = child.LogicalOperator == "OR" ? "#d83b01" : "#0078d4"; + body.AppendLine(""); + body.AppendLine($""); + body.AppendLine($"
    "); + body.AppendLine($"
    ▼ {Encode(child.LogicalOperator)}
    "); + body.AppendLine(""); + RenderConditionTreeToHtml(body, child, depth + 1); + body.AppendLine("
    "); + body.AppendLine("
    "); + body.AppendLine(""); + } + else + { + // Leaf child: just render as a row + RenderConditionTreeToHtml(body, child, depth); + } + } + } + } + + private void RenderConditionSectionHtml(StringBuilder body, ConditionExpression expr) + { + string rootOp = expr.IsGroup ? expr.LogicalOperator : ""; + string rootColor = rootOp == "OR" ? "#d83b01" : "#0078d4"; + + body.AppendLine("
    "); + if (expr.IsGroup) + { + body.AppendLine($"
    ▼ {Encode(rootOp)}
    "); + } + body.AppendLine(""); + body.AppendLine(""); + body.AppendLine(""); + body.AppendLine(""); + body.AppendLine(""); + body.AppendLine(""); + body.AppendLine(""); + RenderConditionTreeToHtml(body, expr, 0); + body.AppendLine("
    EntityFieldOperatorValue
    "); + body.AppendLine("
    "); + } + + private string ResolveFieldList(string commaDelimitedFields) + { + var parts = commaDelimitedFields.Split(','); + var resolved = new List(); + foreach (string field in parts) + { + string trimmed = field.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + resolved.Add(content.GetFieldDisplayName(trimmed)); + } + return string.Join(", ", resolved); + } + + private static string CollapseDashes(string s) + { + while (s.Contains("--")) + s = s.Replace("--", "-"); + return s; + } + + private static int CountAllSteps(List steps) + { + int count = 0; + foreach (var step in steps) + { + count++; + count += CountAllSteps(step.ChildSteps); + } + return count; + } + + private static int CountConditions(List steps) + { + int count = 0; + foreach (var step in steps) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + count++; + count += CountConditions(step.ChildSteps); + } + return count; + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowMarkdownBuilder.cs b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowMarkdownBuilder.cs new file mode 100644 index 0000000..d876061 --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowMarkdownBuilder.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using PowerDocu.Common; +using Grynwald.MarkdownGenerator; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + class ClassicWorkflowMarkdownBuilder : MarkdownBuilder + { + private readonly ClassicWorkflowDocumentationContent content; + private readonly string mainDocumentFileName; + private readonly string triggerActionsFileName; + private readonly MdDocument mainDocument; + private readonly MdDocument triggerActionsDocument; + private readonly DocumentSet set; + + public ClassicWorkflowMarkdownBuilder(ClassicWorkflowDocumentationContent contentDocumentation) + { + content = contentDocumentation; + Directory.CreateDirectory(content.folderPath); + mainDocumentFileName = ("index-" + content.filename + ".md").Replace(" ", "-"); + triggerActionsFileName = ("triggersactions-" + content.filename + ".md").Replace(" ", "-"); + set = new DocumentSet(); + mainDocument = set.CreateMdDocument(mainDocumentFileName); + triggerActionsDocument = set.CreateMdDocument(triggerActionsFileName); + + addOverview(); + addWorkflowDiagram(); + addTriggerAndActionsLinks(); + addTableRelationships(); + + addTriggerPage(); + addActionPages(); + + set.Save(content.folderPath); + NotificationHelper.SendNotification("Created Markdown documentation for Classic Workflow: " + content.workflow.GetDisplayName()); + } + + // ── Main Document ── + + private void addOverview() + { + mainDocument.Root.Add(new MdHeading(content.workflow.GetDisplayName(), 1)); + + if (content.context?.Solution != null) + { + if (content.context?.Config?.documentSolution == true) + mainDocument.Root.Add(new MdParagraph(new MdCompositeSpan(new MdTextSpan("Solution: "), new MdLinkSpan(content.context.Solution.UniqueName, "../" + CrossDocLinkHelper.GetSolutionDocMdPath(content.context.Solution.UniqueName))))); + else + mainDocument.Root.Add(new MdParagraph(new MdTextSpan("Solution: " + content.context.Solution.UniqueName))); + } + + List tableRows = new List + { + new MdTableRow("Name", content.workflow.GetDisplayName()), + new MdTableRow("Primary Table", content.GetTableDisplayName(content.workflow.PrimaryEntity)), + new MdTableRow("Category", content.workflow.GetCategoryLabel()), + new MdTableRow("Mode", content.workflow.GetModeLabel()), + new MdTableRow("Scope", content.workflow.GetScopeLabel()), + new MdTableRow("Run As", content.workflow.GetRunAsLabel()), + new MdTableRow("State", content.workflow.GetStateLabel()), + new MdTableRow("Is Customizable", content.workflow.IsCustomizable ? "Yes" : "No"), + new MdTableRow("Number of Actions", CountAllSteps(content.workflow.Steps).ToString()), + new MdTableRow("Number of Conditions", CountConditions(content.workflow.Steps).ToString()), + new MdTableRow(content.headerDocumentationGenerated, PowerDocuReleaseHelper.GetTimestampWithVersion()) + }; + if (!string.IsNullOrEmpty(content.workflow.ID)) + tableRows.Insert(tableRows.Count - 1, new MdTableRow("ID", content.workflow.ID)); + if (!string.IsNullOrEmpty(content.workflow.OwnerId)) + tableRows.Insert(tableRows.Count - 1, new MdTableRow("Owner", content.workflow.OwnerId)); + if (!string.IsNullOrEmpty(content.workflow.Description)) + tableRows.Insert(tableRows.Count - 1, new MdTableRow("Description", content.workflow.Description)); + if (!string.IsNullOrEmpty(content.workflow.IntroducedVersion)) + tableRows.Insert(tableRows.Count - 1, new MdTableRow("Version", content.workflow.IntroducedVersion)); + + mainDocument.Root.Add(new MdTable(new MdTableRow("Property", "Value"), tableRows)); + } + + private void addWorkflowDiagram() + { + string pngFile = "workflow.png"; + if (!System.IO.File.Exists(content.folderPath + pngFile)) return; + + mainDocument.Root.Add(new MdHeading("Workflow Diagram", 2)); + mainDocument.Root.Add(new MdParagraph(new MdTextSpan("The following diagram shows the flow of the workflow including condition branches."))); + mainDocument.Root.Add(new MdParagraph(new MdImageSpan(content.workflow.GetDisplayName(), pngFile))); + } + + private void addTriggerAndActionsLinks() + { + // Single "Triggers & Actions" link on the main page (matching Flow documenter pattern) + mainDocument.Root.Add(new MdHeading("Triggers & Actions", 2)); + mainDocument.Root.Add(new MdParagraph(new MdLinkSpan("View Triggers & Actions →", triggerActionsFileName))); + } + + private void AddActionLinkItems(List steps, List items) + { + foreach (var step in steps) + { + string anchor = CharsetHelper.GetSafeName(step.Name ?? step.GetStepTypeLabel()).ToLowerInvariant().Replace(" ", "-"); + string label = (step.Name ?? step.GetStepTypeLabel()) + " (" + step.GetStepTypeLabel() + ")"; + items.Add(new MdListItem(new MdParagraph(new MdLinkSpan(label, triggerActionsFileName + "#" + anchor)))); + + if (step.ChildSteps.Count > 0) + AddActionLinkItems(step.ChildSteps, items); + } + } + + private void addTableRelationships() + { + if (content.workflow.TableReferences.Count == 0) return; + + mainDocument.Root.Add(new MdHeading(content.headerTableRelationships, 2)); + mainDocument.Root.Add(new MdParagraph(new MdTextSpan($"This workflow references {content.workflow.TableReferences.Count} table relationship(s)."))); + + List tableRows = new List(); + foreach (var tableRef in content.workflow.TableReferences) + { + string displayName = content.GetTableDisplayName(tableRef.TableLogicalName); + MdSpan displaySpan; + if (content.context?.Solution != null && content.context?.Config?.documentSolution == true) + { + string anchor = CrossDocLinkHelper.GetSolutionTableMdAnchor(displayName, tableRef.TableLogicalName); + string solutionMdPath = CrossDocLinkHelper.GetSolutionDocMdPath(content.context.Solution.UniqueName); + displaySpan = new MdLinkSpan(displayName, "../" + solutionMdPath + anchor); + } + else + { + displaySpan = new MdTextSpan(displayName); + } + tableRows.Add(new MdTableRow( + new MdCompositeSpan(displaySpan), + new MdTextSpan(tableRef.TableLogicalName), + new MdTextSpan(tableRef.ReferenceType.ToString()) + )); + } + mainDocument.Root.Add(new MdTable( + new MdTableRow("Table Display Name", "Table Logical Name", "Reference Type"), + tableRows)); + } + + // ── Triggers & Actions Document ── + + private void addTriggerPage() + { + triggerActionsDocument.Root.Add(new MdHeading(content.workflow.GetDisplayName(), 1)); + triggerActionsDocument.Root.Add(new MdParagraph(new MdLinkSpan("← Back to Overview", mainDocumentFileName))); + triggerActionsDocument.Root.Add(new MdHeading("Trigger", 2)); + + List triggerRows = new List + { + new MdTableRow("Primary Table", content.GetTableDisplayName(content.workflow.PrimaryEntity)), + new MdTableRow("Mode", content.workflow.GetModeLabel()), + new MdTableRow("Scope", content.workflow.GetScopeLabel()) + }; + triggerActionsDocument.Root.Add(new MdTable(new MdTableRow("Property", "Value"), triggerRows)); + + // Trigger types as bulleted list + triggerActionsDocument.Root.Add(new MdHeading("Trigger Type", 3)); + var triggerItems = new List(); + if (content.workflow.OnDemand) + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan("On-Demand")))); + if (content.workflow.TriggerOnCreate) + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan("Record Created")))); + if (content.workflow.TriggerOnDelete) + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan("Record Deleted")))); + if (!string.IsNullOrEmpty(content.workflow.TriggerOnUpdateAttributeList)) + { + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan("Record Updated")))); + // Add indented field list + foreach (string field in content.workflow.TriggerOnUpdateAttributeList.Split(',')) + { + string trimmed = field.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan(" - " + content.GetFieldDisplayName(trimmed))))); + } + } + if (triggerItems.Count == 0) + triggerItems.Add(new MdListItem(new MdParagraph(new MdTextSpan("None")))); + triggerActionsDocument.Root.Add(new MdBulletList(triggerItems.ToArray())); + } + + private void addActionPages() + { + if (content.workflow.Steps.Count == 0) return; + + triggerActionsDocument.Root.Add(new MdHeading("Actions", 2)); + int totalSteps = CountAllSteps(content.workflow.Steps); + triggerActionsDocument.Root.Add(new MdParagraph(new MdTextSpan($"There are a total of {totalSteps} action(s) in this workflow:"))); + + AddActionDetailsRecursive(content.workflow.Steps, 3); + } + + private void AddActionDetailsRecursive(List steps, int headingLevel) + { + int level = Math.Min(headingLevel, 6); + + // Build filtered list for prev/next navigation (skip branches) + var navigableSteps = steps.Where(s => s.StepType != ClassicWorkflowStepType.ConditionBranch).ToList(); + + for (int i = 0; i < steps.Count; i++) + { + var step = steps[i]; + + // ConditionBranch steps are rendered inline on the parent section + if (step.StepType == ClassicWorkflowStepType.ConditionBranch) + { + if (step.ChildSteps.Count > 0) + AddActionDetailsRecursive(step.ChildSteps, headingLevel); + continue; + } + + int navIndex = navigableSteps.IndexOf(step); + ClassicWorkflowStep prevStep = (navIndex > 0) ? navigableSteps[navIndex - 1] : null; + ClassicWorkflowStep nextStep = (navIndex >= 0 && navIndex + 1 < navigableSteps.Count) ? navigableSteps[navIndex + 1] : null; + + triggerActionsDocument.Root.Add(new MdHeading(step.Name ?? step.GetStepTypeLabel(), level)); + + // Action detail table + List detailRows = new List + { + new MdTableRow("Name", step.Name ?? ""), + new MdTableRow("Type", step.GetStepTypeLabel()) + }; + + if (!string.IsNullOrEmpty(step.StepDescription)) + detailRows.Add(new MdTableRow("Description", step.StepDescription)); + + if (!string.IsNullOrEmpty(step.TargetEntity)) + detailRows.Add(new MdTableRow("Target Table", content.GetTableDisplayName(step.TargetEntity))); + if (!string.IsNullOrEmpty(step.CustomActivityName)) + detailRows.Add(new MdTableRow("Custom Activity", step.CustomActivityName)); + if (!string.IsNullOrEmpty(step.CustomActivityClass)) + detailRows.Add(new MdTableRow("Class", step.CustomActivityClass)); + if (!string.IsNullOrEmpty(step.CustomActivityAssembly)) + detailRows.Add(new MdTableRow("Assembly", step.CustomActivityAssembly)); + if (!string.IsNullOrEmpty(step.CustomActivityFriendlyName)) + detailRows.Add(new MdTableRow("Friendly Name", step.CustomActivityFriendlyName)); + if (!string.IsNullOrEmpty(step.CustomActivityDescription)) + detailRows.Add(new MdTableRow("Description", step.CustomActivityDescription)); + if (!string.IsNullOrEmpty(step.CustomActivityGroupName)) + detailRows.Add(new MdTableRow("Group", step.CustomActivityGroupName)); + + triggerActionsDocument.Root.Add(new MdTable(new MdTableRow("Property", "Value"), detailRows)); + + // Condition tree + if (step.ConditionTree != null) + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan("Condition:"))); + RenderConditionTreeToMarkdown(step.ConditionTree, 0); + } + else if (!string.IsNullOrEmpty(step.ConditionDescription)) + { + triggerActionsDocument.Root.Add(new MdParagraph( + new MdStrongEmphasisSpan("Condition: "), + new MdTextSpan(step.ConditionDescription))); + } + + // Field assignments / Inputs + if (step.Fields.Count > 0) + { + string inputsLabel = step.StepType switch + { + ClassicWorkflowStepType.Custom => "**Inputs:**", + ClassicWorkflowStepType.SendEmail => "**Email Properties:**", + _ => "**Field Assignments:**" + }; + triggerActionsDocument.Root.Add(new MdParagraph(new MdRawMarkdownSpan(inputsLabel))); + List fieldRows = new List(); + foreach (var field in step.Fields) + { + fieldRows.Add(new MdTableRow(field.FieldName ?? "", field.Value ?? "")); + } + triggerActionsDocument.Root.Add(new MdTable(new MdTableRow("Field", "Value"), fieldRows)); + } + + // Child steps + if (step.ChildSteps.Count > 0) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + { + // Render branches inline + foreach (var branch in step.ChildSteps) + { + if (branch.StepType != ClassicWorkflowStepType.ConditionBranch) continue; + string branchLabel = branch.Name ?? "Condition Branch"; + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan(branchLabel + ":"))); + + if (branch.ConditionTree != null) + { + RenderConditionTreeToMarkdown(branch.ConditionTree, 0); + } + else if (!string.IsNullOrEmpty(branch.ConditionDescription) && branchLabel != "Otherwise (Default)") + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdTextSpan(branch.ConditionDescription))); + } + + if (branch.ChildSteps.Count > 0) + { + List branchRows = new List(); + foreach (var child in branch.ChildSteps) + { + branchRows.Add(new MdTableRow(child.GetStepTypeLabel(), child.Name ?? "")); + } + triggerActionsDocument.Root.Add(new MdTable(new MdTableRow("Type", "Name"), branchRows)); + } + } + } + else + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan("Subactions:"))); + List childRows = new List(); + foreach (var child in step.ChildSteps) + { + childRows.Add(new MdTableRow(child.GetStepTypeLabel(), child.Name ?? "")); + } + triggerActionsDocument.Root.Add(new MdTable(new MdTableRow("Type", "Name"), childRows)); + } + + AddActionDetailsRecursive(step.ChildSteps, headingLevel + 1); + } + + // Previous Action(s) + if (prevStep != null) + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan("Previous Action(s):"))); + string prevLabel = (prevStep.Name ?? prevStep.GetStepTypeLabel()) + " (" + prevStep.GetStepTypeLabel() + ")"; + string prevAnchor = CharsetHelper.GetSafeName(prevStep.Name ?? prevStep.GetStepTypeLabel()).ToLowerInvariant().Replace(" ", "-"); + triggerActionsDocument.Root.Add(new MdBulletList( + new MdListItem(new MdParagraph(new MdLinkSpan(prevLabel, "#" + prevAnchor))))); + } + + // Next Action(s) + if (nextStep != null) + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan("Next Action(s):"))); + string nextLabel = (nextStep.Name ?? nextStep.GetStepTypeLabel()) + " (" + nextStep.GetStepTypeLabel() + ")"; + string anchor = CharsetHelper.GetSafeName(nextStep.Name ?? nextStep.GetStepTypeLabel()).ToLowerInvariant().Replace(" ", "-"); + triggerActionsDocument.Root.Add(new MdBulletList( + new MdListItem(new MdParagraph(new MdLinkSpan(nextLabel, "#" + anchor))))); + } + } + } + + // ── Helpers ── + + private void RenderConditionTreeToMarkdown(ConditionExpression expr, int depth) + { + if (expr.IsLeaf) + { + string text = string.IsNullOrEmpty(expr.Value) + ? $"{expr.Field} {expr.Operator}" + : $"{expr.Field} {expr.Operator} {expr.Value}"; + triggerActionsDocument.Root.Add(new MdParagraph(new MdTextSpan(new string(' ', depth * 2) + "- " + text))); + } + else + { + if (depth > 0) + triggerActionsDocument.Root.Add(new MdParagraph(new MdStrongEmphasisSpan(new string(' ', depth * 2) + "Group (" + expr.LogicalOperator + "):"))); + + for (int i = 0; i < expr.Children.Count; i++) + { + RenderConditionTreeToMarkdown(expr.Children[i], depth + 1); + if (i < expr.Children.Count - 1) + { + triggerActionsDocument.Root.Add(new MdParagraph(new MdEmphasisSpan(new string(' ', (depth + 1) * 2) + expr.LogicalOperator))); + } + } + } + } + + private string ResolveFieldList(string commaDelimitedFields) + { + var parts = commaDelimitedFields.Split(','); + var resolved = new List(); + foreach (string field in parts) + { + string trimmed = field.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + resolved.Add(content.GetFieldDisplayName(trimmed)); + } + return string.Join(", ", resolved); + } + + private static int CountAllSteps(List steps) + { + int count = 0; + foreach (var step in steps) + { + count++; + count += CountAllSteps(step.ChildSteps); + } + return count; + } + + private static int CountConditions(List steps) + { + int count = 0; + foreach (var step in steps) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + count++; + count += CountConditions(step.ChildSteps); + } + return count; + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowWordDocBuilder.cs b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowWordDocBuilder.cs new file mode 100644 index 0000000..2256110 --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/ClassicWorkflowWordDocBuilder.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using PowerDocu.Common; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + class ClassicWorkflowWordDocBuilder : WordDocBuilder + { + private readonly ClassicWorkflowDocumentationContent content; + + public ClassicWorkflowWordDocBuilder(ClassicWorkflowDocumentationContent contentDocumentation, string template) + { + content = contentDocumentation; + Directory.CreateDirectory(content.folderPath); + string filename = InitializeWordDocument(content.folderPath + content.filename, template); + using (WordprocessingDocument wordDocument = WordprocessingDocument.Open(filename, true)) + { + mainPart = wordDocument.MainDocumentPart; + body = mainPart.Document.Body; + PrepareDocument(!String.IsNullOrEmpty(template)); + + addOverview(); + addTriggerInfo(); + addWorkflowDiagram(wordDocument); + addStepsOverview(); + addActionDetails(); + addTableRelationships(); + } + NotificationHelper.SendNotification("Created Word documentation for Classic Workflow: " + content.workflow.GetDisplayName()); + } + + private void addOverview() + { + AddHeading(content.workflow.GetDisplayName(), "Heading1"); + body.AppendChild(new Paragraph(new Run())); + + Table table = CreateTable(); + table.Append(CreateRow(new Text("Name"), new Text(content.workflow.GetDisplayName()))); + table.Append(CreateRow(new Text("Primary Table"), new Text(content.GetTableDisplayName(content.workflow.PrimaryEntity)))); + table.Append(CreateRow(new Text("Category"), new Text(content.workflow.GetCategoryLabel()))); + table.Append(CreateRow(new Text("Mode"), new Text(content.workflow.GetModeLabel()))); + table.Append(CreateRow(new Text("Scope"), new Text(content.workflow.GetScopeLabel()))); + table.Append(CreateRow(new Text("Run As"), new Text(content.workflow.GetRunAsLabel()))); + table.Append(CreateRow(new Text("Trigger"), new Text(content.workflow.GetTriggerDescription()))); + table.Append(CreateRow(new Text("On-Demand"), new Text(content.workflow.OnDemand ? "Yes" : "No"))); + table.Append(CreateRow(new Text("State"), new Text(content.workflow.GetStateLabel()))); + table.Append(CreateRow(new Text("Is Customizable"), new Text(content.workflow.IsCustomizable ? "Yes" : "No"))); + if (!string.IsNullOrEmpty(content.workflow.ID)) + table.Append(CreateRow(new Text("ID"), new Text(content.workflow.ID))); + if (!string.IsNullOrEmpty(content.workflow.OwnerId)) + table.Append(CreateRow(new Text("Owner"), new Text(content.workflow.OwnerId))); + if (!string.IsNullOrEmpty(content.workflow.Description)) + table.Append(CreateRow(new Text("Description"), new Text(content.workflow.Description))); + if (!string.IsNullOrEmpty(content.workflow.IntroducedVersion)) + table.Append(CreateRow(new Text("Version"), new Text(content.workflow.IntroducedVersion))); + table.Append(CreateRow(new Text("Number of Actions"), new Text(CountAllSteps(content.workflow.Steps).ToString()))); + table.Append(CreateRow(new Text("Number of Conditions"), new Text(CountConditions(content.workflow.Steps).ToString()))); + table.Append(CreateRow(new Text(content.headerDocumentationGenerated), + new Text(PowerDocuReleaseHelper.GetTimestampWithVersion()))); + body.Append(table); + body.AppendChild(new Paragraph(new Run(new Break()))); + } + + private void addTriggerInfo() + { + AddHeading("Trigger", "Heading2"); + body.AppendChild(new Paragraph(new Run())); + + Table table = CreateTable(); + table.Append(CreateRow(new Text("Primary Table"), new Text(content.GetTableDisplayName(content.workflow.PrimaryEntity)))); + table.Append(CreateRow(new Text("Mode"), new Text(content.workflow.GetModeLabel()))); + table.Append(CreateRow(new Text("Scope"), new Text(content.workflow.GetScopeLabel()))); + body.Append(table); + body.AppendChild(new Paragraph(new Run(new Break()))); + + // Trigger types as list + AddHeading("Trigger Type", "Heading3"); + if (content.workflow.OnDemand) + body.AppendChild(new Paragraph(new Run(new Text("• On-Demand")))); + if (content.workflow.TriggerOnCreate) + body.AppendChild(new Paragraph(new Run(new Text("• Record Created")))); + if (content.workflow.TriggerOnDelete) + body.AppendChild(new Paragraph(new Run(new Text("• Record Deleted")))); + if (!string.IsNullOrEmpty(content.workflow.TriggerOnUpdateAttributeList)) + { + body.AppendChild(new Paragraph(new Run(new Text("• Record Updated")))); + foreach (string field in content.workflow.TriggerOnUpdateAttributeList.Split(',')) + { + string trimmed = field.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + body.AppendChild(new Paragraph(new Run(new Text(" ◦ " + content.GetFieldDisplayName(trimmed))))); + } + } + if (!content.workflow.OnDemand && !content.workflow.TriggerOnCreate && !content.workflow.TriggerOnDelete && string.IsNullOrEmpty(content.workflow.TriggerOnUpdateAttributeList)) + body.AppendChild(new Paragraph(new Run(new Text("• None")))); + body.AppendChild(new Paragraph(new Run(new Break()))); + } + + private void addWorkflowDiagram(WordprocessingDocument wordDoc) + { + string pngPath = content.folderPath + "workflow.png"; + string svgPath = content.folderPath + "workflow.svg"; + + if (!System.IO.File.Exists(pngPath)) return; + + AddHeading("Workflow Diagram", "Heading2"); + body.AppendChild(new Paragraph(new Run(new Text("The following diagram shows the flow of the workflow including condition branches.")))); + + ImagePart imagePart = wordDoc.MainDocumentPart.AddImagePart(ImagePartType.Png); + int imageWidth, imageHeight; + using (FileStream stream = new FileStream(pngPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + using (var image = Image.FromStream(stream, false, false)) + { + imageWidth = image.Width; + imageHeight = image.Height; + } + stream.Position = 0; + imagePart.FeedData(stream); + } + + if (System.IO.File.Exists(svgPath)) + { + ImagePart svgPart = wordDoc.MainDocumentPart.AddNewPart("image/svg+xml", "rId" + (new Random()).Next(100000, 999999)); + using (FileStream stream = new FileStream(svgPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + svgPart.FeedData(stream); + } + body.AppendChild(new Paragraph(new Run( + InsertSvgImage(wordDoc.MainDocumentPart.GetIdOfPart(svgPart), wordDoc.MainDocumentPart.GetIdOfPart(imagePart), imageWidth, imageHeight) + ))); + } + else + { + body.AppendChild(new Paragraph(new Run( + InsertImage(wordDoc.MainDocumentPart.GetIdOfPart(imagePart), imageWidth, imageHeight) + ))); + } + body.AppendChild(new Paragraph(new Run(new Break()))); + } + + private void addStepsOverview() + { + if (content.workflow.Steps.Count == 0) return; + + AddHeading(content.headerSteps, "Heading2"); + body.AppendChild(new Paragraph(new Run( + new Text($"This workflow has {CountAllSteps(content.workflow.Steps)} step(s).")))); + + Table table = CreateTable(); + table.Append(CreateHeaderRow(new Text("#"), new Text("Step Name"), new Text("Step Type"), new Text("Target Table"), new Text("Condition"))); + + int stepNum = 1; + AddStepsToTable(table, content.workflow.Steps, ref stepNum, ""); + + body.Append(table); + body.AppendChild(new Paragraph(new Run(new Break()))); + } + + private void AddStepsToTable(Table table, List steps, ref int stepNum, string prefix) + { + foreach (var step in steps) + { + string targetDisplay = !string.IsNullOrEmpty(step.TargetEntity) + ? content.GetTableDisplayName(step.TargetEntity) + : ""; + + table.Append(CreateRow( + new Text(prefix + stepNum), + new Text(step.Name ?? ""), + new Text(step.GetStepTypeLabel()), + new Text(targetDisplay), + new Text(step.ConditionDescription ?? "") + )); + stepNum++; + + if (step.ChildSteps.Count > 0) + { + int childNum = 1; + string childPrefix = prefix + (stepNum - 1) + "."; + AddStepsToTable(table, step.ChildSteps, ref childNum, childPrefix); + } + } + } + + private void addTableRelationships() + { + if (content.workflow.TableReferences.Count == 0) return; + + AddHeading(content.headerTableRelationships, "Heading2"); + body.AppendChild(new Paragraph(new Run( + new Text($"This workflow references {content.workflow.TableReferences.Count} table relationship(s).")))); + + Table table = CreateTable(); + table.Append(CreateHeaderRow(new Text("Table Display Name"), new Text("Table Logical Name"), new Text("Reference Type"))); + + foreach (var tableRef in content.workflow.TableReferences) + { + table.Append(CreateRow( + new Text(content.GetTableDisplayName(tableRef.TableLogicalName)), + new Text(tableRef.TableLogicalName), + new Text(tableRef.ReferenceType.ToString()) + )); + } + + body.Append(table); + body.AppendChild(new Paragraph(new Run(new Break()))); + } + + private void addActionDetails() + { + if (content.workflow.Steps.Count == 0) return; + + AddHeading("Actions", "Heading2"); + int totalSteps = CountAllSteps(content.workflow.Steps); + body.AppendChild(new Paragraph(new Run(new Text($"There are a total of {totalSteps} action(s) in this workflow:")))); + AddActionDetailsRecursive(content.workflow.Steps, 3); + } + + private void AddActionDetailsRecursive(List steps, int headingLevel) + { + string headingStyle = headingLevel <= 3 ? "Heading" + headingLevel : "Heading3"; + + // Build filtered list for prev/next navigation (skip branches) + var navigableSteps = steps.Where(s => s.StepType != ClassicWorkflowStepType.ConditionBranch).ToList(); + + for (int i = 0; i < steps.Count; i++) + { + var step = steps[i]; + + // ConditionBranch steps are rendered inline on the parent section + if (step.StepType == ClassicWorkflowStepType.ConditionBranch) + { + if (step.ChildSteps.Count > 0) + AddActionDetailsRecursive(step.ChildSteps, headingLevel); + continue; + } + + int navIndex = navigableSteps.IndexOf(step); + ClassicWorkflowStep prevStep = (navIndex > 0) ? navigableSteps[navIndex - 1] : null; + ClassicWorkflowStep nextStep = (navIndex >= 0 && navIndex + 1 < navigableSteps.Count) ? navigableSteps[navIndex + 1] : null; + + AddHeading(step.Name ?? step.GetStepTypeLabel(), headingStyle); + + // Action detail table (matching Flow documenter pattern) + Table actionTable = CreateTable(); + actionTable.Append(CreateRow(new Text("Name"), new Text(step.Name ?? ""))); + actionTable.Append(CreateRow(new Text("Type"), new Text(step.GetStepTypeLabel()))); + + if (!string.IsNullOrEmpty(step.StepDescription)) + actionTable.Append(CreateRow(new Text("Description"), new Text(step.StepDescription))); + + if (!string.IsNullOrEmpty(step.TargetEntity)) + actionTable.Append(CreateRow(new Text("Target Table"), new Text(content.GetTableDisplayName(step.TargetEntity)))); + + if (!string.IsNullOrEmpty(step.CustomActivityName)) + actionTable.Append(CreateRow(new Text("Custom Activity"), new Text(step.CustomActivityName))); + + if (!string.IsNullOrEmpty(step.CustomActivityClass)) + actionTable.Append(CreateRow(new Text("Class"), new Text(step.CustomActivityClass))); + + if (!string.IsNullOrEmpty(step.CustomActivityAssembly)) + actionTable.Append(CreateRow(new Text("Assembly"), new Text(step.CustomActivityAssembly))); + + if (!string.IsNullOrEmpty(step.CustomActivityFriendlyName)) + actionTable.Append(CreateRow(new Text("Friendly Name"), new Text(step.CustomActivityFriendlyName))); + + if (!string.IsNullOrEmpty(step.CustomActivityDescription)) + actionTable.Append(CreateRow(new Text("Description"), new Text(step.CustomActivityDescription))); + + if (!string.IsNullOrEmpty(step.CustomActivityGroupName)) + actionTable.Append(CreateRow(new Text("Group"), new Text(step.CustomActivityGroupName))); + + // Condition tree display + if (step.ConditionTree != null) + { + actionTable.Append(CreateMergedRow(new Text("Condition"), 2, cellHeaderBackground)); + RenderConditionTreeToWordTable(actionTable, step.ConditionTree, 0); + } + else if (!string.IsNullOrEmpty(step.ConditionDescription)) + { + actionTable.Append(CreateMergedRow(new Text("Condition"), 2, cellHeaderBackground)); + actionTable.Append(CreateRow(new Text("Expression"), new Text(step.ConditionDescription))); + } + + // Field assignments / Inputs + if (step.Fields.Count > 0) + { + string inputsLabel = step.StepType switch + { + ClassicWorkflowStepType.Custom => "Inputs", + ClassicWorkflowStepType.SendEmail => "Email Properties", + _ => "Field Assignments" + }; + actionTable.Append(CreateMergedRow(new Text(inputsLabel), 2, cellHeaderBackground)); + + foreach (var field in step.Fields) + { + actionTable.Append(CreateRow( + new Text(field.FieldName ?? ""), + new Text(field.Value ?? "") + )); + } + } + + // Child steps / Subactions + if (step.ChildSteps.Count > 0) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + { + // Render branches inline + foreach (var branch in step.ChildSteps) + { + if (branch.StepType != ClassicWorkflowStepType.ConditionBranch) continue; + string branchLabel = branch.Name ?? "Condition Branch"; + actionTable.Append(CreateMergedRow(new Text(branchLabel), 2, cellHeaderBackground)); + + if (branch.ConditionTree != null) + { + RenderConditionTreeToWordTable(actionTable, branch.ConditionTree, 0); + } + else if (!string.IsNullOrEmpty(branch.ConditionDescription) && branchLabel != "Otherwise (Default)") + { + actionTable.Append(CreateRow(new Text("Expression"), new Text(branch.ConditionDescription))); + } + + foreach (var child in branch.ChildSteps) + { + actionTable.Append(CreateRow(new Text(child.GetStepTypeLabel()), new Text(child.Name ?? ""))); + } + } + } + else + { + actionTable.Append(CreateMergedRow(new Text("Subactions"), 2, cellHeaderBackground)); + + foreach (var child in step.ChildSteps) + { + actionTable.Append(CreateRow(new Text(child.GetStepTypeLabel()), new Text(child.Name ?? ""))); + } + } + } + + // Previous Action(s) + if (prevStep != null) + { + actionTable.Append(CreateMergedRow(new Text("Previous Action(s)"), 2, cellHeaderBackground)); + actionTable.Append(CreateRow( + new Text(prevStep.GetStepTypeLabel()), + new Text(prevStep.Name ?? ""))); + } + + // Next Action(s) + if (nextStep != null) + { + actionTable.Append(CreateMergedRow(new Text("Next Action(s)"), 2, cellHeaderBackground)); + actionTable.Append(CreateRow( + new Text(nextStep.GetStepTypeLabel()), + new Text(nextStep.Name ?? ""))); + } + + body.Append(actionTable); + body.AppendChild(new Paragraph(new Run(new Break()))); + + // Recurse into child steps for their own detail sections + if (step.ChildSteps.Count > 0) + { + AddActionDetailsRecursive(step.ChildSteps, headingLevel + 1); + } + } + } + + private void RenderConditionTreeToWordTable(Table table, ConditionExpression expr, int depth) + { + string indent = new string(' ', depth * 2); + + if (expr.IsLeaf) + { + string entityPart = ""; + string fieldPart = expr.Field ?? ""; + if (fieldPart.Contains(" on ")) + { + int onIdx = fieldPart.IndexOf(" on "); + entityPart = fieldPart.Substring(onIdx + 4); + fieldPart = fieldPart.Substring(0, onIdx); + } + + string condLine = entityPart + " → " + fieldPart + " " + (expr.Operator ?? "") + + (string.IsNullOrEmpty(expr.Value) ? "" : " " + expr.Value); + table.Append(CreateRow(new Text(indent + " "), new Text(condLine))); + } + else + { + // Group: show operator label on top, then render children + foreach (var child in expr.Children) + { + if (child.IsGroup) + { + // Nested group: show its operator as a label, then its children indented + table.Append(CreateRow(new Text(indent + "▼ " + child.LogicalOperator), new Text(""))); + RenderConditionTreeToWordTable(table, child, depth + 1); + } + else + { + RenderConditionTreeToWordTable(table, child, depth); + } + } + } + } + + private string ResolveFieldList(string commaDelimitedFields) + { + var parts = commaDelimitedFields.Split(','); + var resolved = new List(); + foreach (string field in parts) + { + string trimmed = field.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + resolved.Add(content.GetFieldDisplayName(trimmed)); + } + return string.Join(", ", resolved); + } + + private static int CountAllSteps(List steps) + { + int count = 0; + foreach (var step in steps) + { + count++; + count += CountAllSteps(step.ChildSteps); + } + return count; + } + + private static int CountConditions(List steps) + { + int count = 0; + foreach (var step in steps) + { + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) + count++; + count += CountConditions(step.ChildSteps); + } + return count; + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/GraphBuilder.cs b/PowerDocu.ClassicWorkflowDocumenter/GraphBuilder.cs new file mode 100644 index 0000000..72f1b4a --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/GraphBuilder.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using PowerDocu.Common; +using Rubjerg.Graphviz; + +namespace PowerDocu.ClassicWorkflowDocumenter +{ + /// + /// Generates Graphviz flow diagrams for Classic Workflows, showing the step hierarchy + /// with condition branches visualized as Yes/No clusters (matching the Flow documenter style). + /// + public class GraphBuilder + { + private readonly ClassicWorkflowEntity workflow; + private readonly string folderPath; + private HashSet edges; + + public GraphBuilder(ClassicWorkflowEntity workflow, string path) + { + this.workflow = workflow; + folderPath = path; + Directory.CreateDirectory(folderPath); + } + + public void BuildGraph() + { + if (workflow.Steps.Count == 0) return; + + edges = new HashSet(); + RootGraph rootGraph = RootGraph.CreateNew(GraphType.Directed, CharsetHelper.GetSafeName(workflow.GetDisplayName())); + Graph.IntroduceAttribute(rootGraph, "compound", "true"); + Graph.IntroduceAttribute(rootGraph, "fontname", "helvetica"); + Graph.IntroduceAttribute(rootGraph, "rankdir", "TB"); + Node.IntroduceAttribute(rootGraph, "shape", ""); + Node.IntroduceAttribute(rootGraph, "color", ""); + Node.IntroduceAttribute(rootGraph, "style", ""); + Node.IntroduceAttribute(rootGraph, "fillcolor", ""); + Node.IntroduceAttribute(rootGraph, "label", ""); + Node.IntroduceAttribute(rootGraph, "fontname", "helvetica"); + Edge.IntroduceAttribute(rootGraph, "label", ""); + Edge.IntroduceAttribute(rootGraph, "fontname", "helvetica"); + Edge.IntroduceAttribute(rootGraph, "fontsize", "10"); + + // Add trigger node + string triggerLabel = !string.IsNullOrEmpty(workflow.PrimaryEntity) + ? "Trigger: " + workflow.PrimaryEntity + : "Trigger"; + string triggerDetail = workflow.GetTriggerDescription(); + + Node triggerNode = rootGraph.GetOrAddNode("trigger"); + triggerNode.SetAttribute("shape", "plaintext"); + triggerNode.SetAttribute("margin", "0"); + string triggerHtml = triggerLabel; + if (!string.IsNullOrEmpty(triggerDetail) && triggerDetail != "None") + triggerHtml += "
    " + System.Web.HttpUtility.HtmlEncode(triggerDetail) + ""; + triggerNode.SetAttributeHtml("label", GenerateCardHtml(TriggerColor, triggerHtml)); + + // Build step nodes and edges + Node previousNode = triggerNode; + int nodeCounter = 0; + AddStepNodes(rootGraph, workflow.Steps, ref previousNode, null, ref nodeCounter); + + try + { + rootGraph.CreateLayout(); + string filename = "workflow"; + rootGraph.ToPngFile(folderPath + filename + ".png"); + rootGraph.ToSvgFile(folderPath + filename + ".svg"); + + // Embed images as base64 in SVG for Word output compatibility + EmbedSvgImages(folderPath + filename + ".svg"); + + NotificationHelper.SendNotification(" - Created workflow graph " + folderPath + filename + ".png"); + } + catch (Exception ex) + { + NotificationHelper.SendNotification(" - Warning: Could not create workflow graph: " + ex.Message); + } + } + + private void AddStepNodes(RootGraph graph, List steps, ref Node previousNode, SubGraph parentCluster, ref int nodeCounter) + { + foreach (var step in steps) + { + nodeCounter++; + string nodeId = "step_" + nodeCounter; + string accentColor = GetColorForStepType(step.StepType); + string nodeLabel = GetNodeLabel(step); + + if ((step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.Wait) && step.ChildSteps.Count > 0) + { + // Create a cluster for the condition and its branches + SubGraph condCluster = parentCluster != null + ? parentCluster.GetOrAddSubgraph("cluster_" + nodeId) + : graph.GetOrAddSubgraph("cluster_" + nodeId); + condCluster.SafeSetAttribute("style", "filled", ""); + condCluster.SafeSetAttribute("fillcolor", ClusterFillColor, ""); + condCluster.SafeSetAttribute("color", ClusterBorderColor, ""); + condCluster.SafeSetAttribute("label", "", ""); + + // Add the condition node inside the cluster + Node condNode = graph.GetOrAddNode(nodeId); + condNode.SetAttribute("shape", "plaintext"); + condNode.SetAttribute("margin", "0"); + condNode.SetAttributeHtml("label", GenerateCardHtml(accentColor, nodeLabel)); + condCluster.AddExisting(condNode); + + // Connect from previous node + ConnectNodes(graph, previousNode, condNode); + + // Process child branches + Node lastBranchNode = condNode; + foreach (var childStep in step.ChildSteps) + { + if (childStep.StepType == ClassicWorkflowStepType.ConditionBranch && childStep.ChildSteps.Count > 0) + { + nodeCounter++; + string branchClusterId = "cluster_branch_" + nodeCounter; + bool isThenBranch = childStep == step.ChildSteps[0]; // first branch is "Then" + SubGraph branchCluster = condCluster.GetOrAddSubgraph(branchClusterId); + branchCluster.SafeSetAttribute("style", "filled", ""); + branchCluster.SafeSetAttribute("fillcolor", isThenBranch ? YesFillColor : NoFillColor, ""); + branchCluster.SafeSetAttribute("color", isThenBranch ? YesBorderColor : NoBorderColor, ""); + branchCluster.SafeSetAttribute("label", isThenBranch ? "Yes" : "Otherwise", ""); + branchCluster.SafeSetAttribute("fontname", "helvetica", ""); + + // Add nodes within the branch + Node branchPrev = condNode; + AddStepNodes(graph, childStep.ChildSteps, ref branchPrev, branchCluster, ref nodeCounter); + } + else if (childStep.StepType == ClassicWorkflowStepType.ConditionBranch) + { + // Empty branch — skip + } + else + { + // Non-branch child step inside condition + nodeCounter++; + string childNodeId = "step_" + nodeCounter; + Node childNode = graph.GetOrAddNode(childNodeId); + childNode.SetAttribute("shape", "plaintext"); + childNode.SetAttribute("margin", "0"); + string childColor = GetColorForStepType(childStep.StepType); + childNode.SetAttributeHtml("label", GenerateCardHtml(childColor, GetNodeLabel(childStep))); + condCluster.AddExisting(childNode); + ConnectNodes(graph, lastBranchNode, childNode); + lastBranchNode = childNode; + + // Recurse if this step also has children + if (childStep.ChildSteps.Count > 0) + { + AddStepNodes(graph, childStep.ChildSteps, ref lastBranchNode, condCluster, ref nodeCounter); + } + } + } + + previousNode = condNode; // next step connects from the condition node + } + else + { + // Regular step (non-condition) + Node stepNode = graph.GetOrAddNode(nodeId); + stepNode.SetAttribute("shape", "plaintext"); + stepNode.SetAttribute("margin", "0"); + stepNode.SetAttributeHtml("label", GenerateCardHtml(accentColor, nodeLabel)); + + if (parentCluster != null) + parentCluster.AddExisting(stepNode); + + ConnectNodes(graph, previousNode, stepNode); + previousNode = stepNode; + + // Recurse into child steps (for composites, etc.) + if (step.ChildSteps.Count > 0) + { + AddStepNodes(graph, step.ChildSteps, ref previousNode, parentCluster, ref nodeCounter); + } + } + } + } + + private void ConnectNodes(RootGraph graph, Node from, Node to) + { + string edgeName = from.GetName() + "->" + to.GetName(); + if (edges.Add(edgeName)) + { + graph.GetOrAddEdge(from, to, edgeName); + } + } + + private string GetNodeLabel(ClassicWorkflowStep step) + { + string typeLabel = step.GetStepTypeLabel(); + string name = step.Name ?? ""; + string entityInfo = ""; + + if (!string.IsNullOrEmpty(step.TargetEntity)) + entityInfo = "
    " + System.Web.HttpUtility.HtmlEncode(step.TargetEntity) + ""; + + // For certain types, show the type as a prefix + if (step.StepType == ClassicWorkflowStepType.CheckCondition || + step.StepType == ClassicWorkflowStepType.ConditionBranch) + { + return System.Web.HttpUtility.HtmlEncode(name) + entityInfo; + } + + if (name == typeLabel || string.IsNullOrEmpty(name)) + return System.Web.HttpUtility.HtmlEncode(typeLabel) + entityInfo; + + return "" + System.Web.HttpUtility.HtmlEncode(typeLabel) + "
    " + + System.Web.HttpUtility.HtmlEncode(Truncate(name, 60)) + entityInfo; + } + + private static string Truncate(string text, int maxLen) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLen) return text; + return text.Substring(0, maxLen - 3) + "..."; + } + + private static string GenerateCardHtml(string accentColor, string innerHtml) + { + return "" + + "
    " + innerHtml + "
    "; + } + + private static void EmbedSvgImages(string svgPath) + { + try + { + var xmlDoc = new System.Xml.XmlDocument { XmlResolver = null }; + xmlDoc.Load(svgPath); + var elemList = xmlDoc.GetElementsByTagName("image"); + foreach (System.Xml.XmlNode xn in elemList) + { + var href = xn.Attributes?["xlink:href"]; + if (href != null && !href.Value.StartsWith("data:")) + href.Value = "data:image/png;base64," + ImageHelper.GetBase64(href.Value); + } + xmlDoc.Save(svgPath); + } + catch { } + } + + // ── Color palette (matching Flow documenter style) ── + private const string TriggerColor = "#0077ff"; + private const string ConditionColor = "#484f58"; + private const string UpdateColor = "#088142"; + private const string CreateColor = "#088142"; + private const string EmailColor = "#0078d4"; + private const string AssignColor = "#770bd6"; + private const string StatusColor = "#8c3900"; + private const string StopColor = "#f41700"; + private const string WaitColor = "#8c6cff"; + private const string CustomColor = "#486991"; + private const string ClientColor = "#486991"; + private const string DefaultColor = "#0077ff"; + + private const string ClusterFillColor = "#f5f5f5"; + private const string ClusterBorderColor = "#484f58"; + private const string YesFillColor = "#edf9ee"; + private const string YesBorderColor = "#88da8d"; + private const string NoFillColor = "#feedec"; + private const string NoBorderColor = "#fb8981"; + + private static string GetColorForStepType(ClassicWorkflowStepType stepType) + { + return stepType switch + { + ClassicWorkflowStepType.CheckCondition => ConditionColor, + ClassicWorkflowStepType.ConditionBranch => ConditionColor, + ClassicWorkflowStepType.UpdateRecord => UpdateColor, + ClassicWorkflowStepType.CreateRecord => CreateColor, + ClassicWorkflowStepType.SendEmail => EmailColor, + ClassicWorkflowStepType.Assign => AssignColor, + ClassicWorkflowStepType.ChangeStatus => StatusColor, + ClassicWorkflowStepType.Stop => StopColor, + ClassicWorkflowStepType.Wait => WaitColor, + ClassicWorkflowStepType.Custom => CustomColor, + ClassicWorkflowStepType.SetVisibility or + ClassicWorkflowStepType.SetDisplayMode or + ClassicWorkflowStepType.SetFieldRequired or + ClassicWorkflowStepType.SetAttributeValue or + ClassicWorkflowStepType.SetDefaultValue or + ClassicWorkflowStepType.SetMessage => ClientColor, + _ => DefaultColor + }; + } + } +} diff --git a/PowerDocu.ClassicWorkflowDocumenter/PowerDocu.ClassicWorkflowDocumenter.csproj b/PowerDocu.ClassicWorkflowDocumenter/PowerDocu.ClassicWorkflowDocumenter.csproj new file mode 100644 index 0000000..b093d6f --- /dev/null +++ b/PowerDocu.ClassicWorkflowDocumenter/PowerDocu.ClassicWorkflowDocumenter.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + latest + embedded + + + diff --git a/PowerDocu.GUI/CommandLineOptions.cs b/PowerDocu.GUI/CommandLineOptions.cs index d3468dc..2f559e2 100644 --- a/PowerDocu.GUI/CommandLineOptions.cs +++ b/PowerDocu.GUI/CommandLineOptions.cs @@ -56,6 +56,8 @@ public class CommandLineOptions public bool DocumentModelDrivenApps { get; set; } [Option('u', "documentBusinessProcessFlows", HelpText = "Document Business Process Flows", Required = false, Default = true)] public bool DocumentBusinessProcessFlows { get; set; } + [Option('y', "documentClassicWorkflows", HelpText = "Document Classic Workflows", Required = false, Default = true)] + public bool DocumentClassicWorkflows { get; set; } internal string FileFormat => this switch { diff --git a/PowerDocu.GUI/PowerDocuCLI.cs b/PowerDocu.GUI/PowerDocuCLI.cs index 038cfd2..a88d7d5 100644 --- a/PowerDocu.GUI/PowerDocuCLI.cs +++ b/PowerDocu.GUI/PowerDocuCLI.cs @@ -94,6 +94,7 @@ private static void GenerateDocumentation(CommandLineOptions options) configHelper.showAllComponentsInGraph = options.ShowAllComponentsInGraph; configHelper.documentModelDrivenApps = options.DocumentModelDrivenApps; configHelper.documentBusinessProcessFlows = options.DocumentBusinessProcessFlows; + configHelper.documentClassicWorkflows = options.DocumentClassicWorkflows; switch (Path.GetExtension(itemToDocument)) { case ".zip": diff --git a/PowerDocu.GUI/PowerDocuForm.Designer.cs b/PowerDocu.GUI/PowerDocuForm.Designer.cs index cf3d75c..b0a7057 100644 --- a/PowerDocu.GUI/PowerDocuForm.Designer.cs +++ b/PowerDocu.GUI/PowerDocuForm.Designer.cs @@ -326,11 +326,21 @@ private TabPage createSettingsTabPage() }; docOptionsInnerPanel.Controls.Add(desktopFlowsCheckBox); + // Classic Workflows Checkbox + classicWorkflowsCheckBox = new CheckBox() + { + Text = "Classic Workflows", + Location = new Point(convertToDPISpecific(15), desktopFlowsCheckBox.Location.Y + desktopFlowsCheckBox.Height + convertToDPISpecific(10)), + Checked = true, + AutoSize = true, + }; + docOptionsInnerPanel.Controls.Add(classicWorkflowsCheckBox); + // Apps Checkbox appsCheckBox = new CheckBox() { Text = "Canvas Apps", - Location = new Point(convertToDPISpecific(15), desktopFlowsCheckBox.Location.Y + desktopFlowsCheckBox.Height + convertToDPISpecific(10)), + Location = new Point(convertToDPISpecific(15), classicWorkflowsCheckBox.Location.Y + classicWorkflowsCheckBox.Height + convertToDPISpecific(10)), Checked = true, AutoSize = true }; @@ -764,7 +774,7 @@ private int convertToDPISpecific(int number) private TextBox appStatusTextBox; private ComboBox outputFormatComboBox, flowActionSortOrderComboBox; private GroupBox outputFormatGroup, documentationOptionsGroup, otherOptionsGroup; - private CheckBox documentDefaultsCheckBox, documentSampleDataCheckBox, documentDefaultColumnsCheckBox, appPropertiesCheckBox, variablesCheckBox, dataSourcesCheckBox, resourcesCheckBox, controlsCheckBox, appsCheckBox, agentsCheckBox, modelDrivenAppsCheckBox, businessProcessFlowsCheckBox, desktopFlowsCheckBox, flowsCheckBox, solutionCheckBox, checkForUpdatesOnLaunchCheckBox, addTableOfContentsCheckBox, showAllComponentsInGraphCheckBox; + private CheckBox documentDefaultsCheckBox, documentSampleDataCheckBox, documentDefaultColumnsCheckBox, appPropertiesCheckBox, variablesCheckBox, dataSourcesCheckBox, resourcesCheckBox, controlsCheckBox, appsCheckBox, agentsCheckBox, modelDrivenAppsCheckBox, businessProcessFlowsCheckBox, desktopFlowsCheckBox, classicWorkflowsCheckBox, flowsCheckBox, solutionCheckBox, checkForUpdatesOnLaunchCheckBox, addTableOfContentsCheckBox, showAllComponentsInGraphCheckBox; private RadioButton documentChangesOnlyRadioButton, documentEverythingRadioButton; private Label wordTemplateInfoLabel, fileToParseInfoLabel, outputFormatInfoLabel, flowActionSortOrderInfoLabel, newReleaseLabel, updateConnectorIconsLabel, diff --git a/PowerDocu.GUI/PowerDocuForm.cs b/PowerDocu.GUI/PowerDocuForm.cs index fd3c8ac..b4293b3 100644 --- a/PowerDocu.GUI/PowerDocuForm.cs +++ b/PowerDocu.GUI/PowerDocuForm.cs @@ -48,6 +48,7 @@ private void LoadConfig() modelDrivenAppsCheckBox.Checked = configHelper.documentModelDrivenApps; businessProcessFlowsCheckBox.Checked = configHelper.documentBusinessProcessFlows; desktopFlowsCheckBox.Checked = configHelper.documentDesktopFlows; + classicWorkflowsCheckBox.Checked = configHelper.documentClassicWorkflows; flowsCheckBox.Checked = configHelper.documentFlows; appsCheckBox.Checked = configHelper.documentApps; appPropertiesCheckBox.Checked = configHelper.documentAppProperties; @@ -194,6 +195,7 @@ private void SyncConfigHelper() configHelper.documentModelDrivenApps = modelDrivenAppsCheckBox.Checked; configHelper.documentBusinessProcessFlows = businessProcessFlowsCheckBox.Checked; configHelper.documentDesktopFlows = desktopFlowsCheckBox.Checked; + configHelper.documentClassicWorkflows = classicWorkflowsCheckBox.Checked; configHelper.documentFlows = flowsCheckBox.Checked; configHelper.documentApps = appsCheckBox.Checked; configHelper.documentAppProperties = appPropertiesCheckBox.Checked; diff --git a/PowerDocu.SolutionDocumenter/PowerDocu.SolutionDocumenter.csproj b/PowerDocu.SolutionDocumenter/PowerDocu.SolutionDocumenter.csproj index 5db0979..d9e96d1 100644 --- a/PowerDocu.SolutionDocumenter/PowerDocu.SolutionDocumenter.csproj +++ b/PowerDocu.SolutionDocumenter/PowerDocu.SolutionDocumenter.csproj @@ -8,6 +8,7 @@ + diff --git a/PowerDocu.SolutionDocumenter/SolutionDocumentationContent.cs b/PowerDocu.SolutionDocumenter/SolutionDocumentationContent.cs index 5087d85..caa8b79 100644 --- a/PowerDocu.SolutionDocumenter/SolutionDocumentationContent.cs +++ b/PowerDocu.SolutionDocumenter/SolutionDocumentationContent.cs @@ -13,6 +13,7 @@ public class SolutionDocumentationContent public List agents = new List(); public List businessProcessFlows = new List(); public List desktopFlows = new List(); + public List dataflows = new List(); public SolutionEntity solution; public DocumentationContext context; public string folderPath, @@ -31,6 +32,7 @@ string path this.agents = context.Agents ?? new List(); this.businessProcessFlows = context.BusinessProcessFlows ?? new List(); this.desktopFlows = context.DesktopFlows ?? new List(); + this.dataflows = context.Dataflows ?? new List(); filename = CharsetHelper.GetSafeName(solution.UniqueName); folderPath = path; } diff --git a/PowerDocu.SolutionDocumenter/SolutionDocumentationGenerator.cs b/PowerDocu.SolutionDocumenter/SolutionDocumentationGenerator.cs index 6603e35..05565a5 100644 --- a/PowerDocu.SolutionDocumenter/SolutionDocumentationGenerator.cs +++ b/PowerDocu.SolutionDocumenter/SolutionDocumentationGenerator.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using PowerDocu.Common; using PowerDocu.AgentDocumenter; using PowerDocu.AIModelDocumenter; using PowerDocu.AppDocumenter; using PowerDocu.AppModuleDocumenter; using PowerDocu.BPFDocumenter; +using PowerDocu.ClassicWorkflowDocumenter; using PowerDocu.DesktopFlowDocumenter; using PowerDocu.FlowDocumenter; @@ -110,6 +112,41 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation { context.DesktopFlows = context.Customizations.getDesktopFlows() ?? new List(); } + + // Extract Classic Workflows from customizations (Category=0) + if (config.documentClassicWorkflows) + { + context.ClassicWorkflows = context.Customizations.getClassicWorkflows() ?? new List(); + // Parse XAML files to populate steps + if (solutionParser.solution.WorkflowXamlFiles != null) + { + foreach (ClassicWorkflowEntity workflow in context.ClassicWorkflows) + { + if (!string.IsNullOrEmpty(workflow.XamlFileName)) + { + string normalizedPath = workflow.XamlFileName.TrimStart('/'); + foreach (var kvp in solutionParser.solution.WorkflowXamlFiles) + { + if (kvp.Key.Equals(normalizedPath, StringComparison.OrdinalIgnoreCase) || + kvp.Key.EndsWith(System.IO.Path.GetFileName(normalizedPath), StringComparison.OrdinalIgnoreCase)) + { + ClassicWorkflowXamlParser.ParseClassicWorkflowXaml(workflow, kvp.Value); + break; + } + } + } + } + } + + // Enrich custom activity steps with metadata from PluginAssemblies + // and resolve condition field references to display names + _enrichContext = context; + foreach (ClassicWorkflowEntity workflow in context.ClassicWorkflows) + { + EnrichCustomActivityMetadata(workflow.Steps, context.Customizations); + } + _enrichContext = null; + } } } @@ -118,6 +155,7 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation $"Phase 1 complete: {context.Flows.Count} flow(s), {context.Apps.Count} app(s), " + $"{context.Agents.Count} agent(s), {context.AppModules.Count} app module(s), " + $"{context.BusinessProcessFlows.Count} BPF(s), {context.DesktopFlows.Count} desktop flow(s), " + + $"{context.ClassicWorkflows.Count} classic workflow(s), " + $"{context.Tables.Count} table(s), {context.Roles.Count} role(s)." ); @@ -133,6 +171,8 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation progress.Register("BPFs", context.BusinessProcessFlows.Count); if (config.documentDesktopFlows && context.DesktopFlows.Count > 0) progress.Register("DesktopFlows", context.DesktopFlows.Count); + if (config.documentClassicWorkflows && context.ClassicWorkflows.Count > 0) + progress.Register("Classic Workflows", context.ClassicWorkflows.Count); if (config.documentModelDrivenApps && context.AppModules.Count > 0) progress.Register("Model-Driven Apps", context.AppModules.Count); int aiModelCount = context.Customizations?.getAIModels()?.Count ?? 0; @@ -189,6 +229,12 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation DesktopFlowDocumentationGenerator.GenerateOutput(context, solutionBasePath); } + // Generate Classic Workflow documentation + if (config.documentClassicWorkflows) + { + ClassicWorkflowDocumentationGenerator.GenerateOutput(context, solutionBasePath); + } + // Generate solution-level documentation (solution overview, model-driven apps, Dataverse graph) if (config.documentSolution && context.Solution != null) { @@ -202,12 +248,27 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation // Generate solution overview documentation SolutionDocumentationContent solutionContent = new SolutionDocumentationContent(context, solutionPath); - DataverseGraphBuilder dataverseGraphBuilder = new DataverseGraphBuilder(solutionContent); + + try + { + DataverseGraphBuilder dataverseGraphBuilder = new DataverseGraphBuilder(solutionContent); + } + catch (Exception ex) + { + NotificationHelper.SendNotification("Warning: Could not generate Dataverse relationship graph: " + ex.Message); + } // Generate solution component relationship graph - SolutionComponentGraphBuilder componentGraphBuilder = new SolutionComponentGraphBuilder( - solutionContent, solutionPath, config.showAllComponentsInGraph); - componentGraphBuilder.Build(); + try + { + SolutionComponentGraphBuilder componentGraphBuilder = new SolutionComponentGraphBuilder( + solutionContent, solutionPath, config.showAllComponentsInGraph); + componentGraphBuilder.Build(); + } + catch (Exception ex) + { + NotificationHelper.SendNotification("Warning: Could not generate solution component graph: " + ex.Message); + } if (fullDocumentation) { @@ -235,5 +296,191 @@ public static void GenerateDocumentation(string filePath, bool fullDocumentation DateTime endDocGeneration = DateTime.Now; NotificationHelper.SendNotification($"Documentation completed for {filePath}. Total time: {(endDocGeneration - startDocGeneration).TotalSeconds} seconds."); } + + private static void EnrichCustomActivityMetadata(List steps, CustomizationsEntity customizations) + { + foreach (var step in steps) + { + if (step.StepType == ClassicWorkflowStepType.Custom && !string.IsNullOrEmpty(step.CustomActivityClass)) + { + var (friendlyName, description, groupName) = customizations.getWorkflowActivityMetadata(step.CustomActivityClass); + step.CustomActivityFriendlyName = friendlyName; + step.CustomActivityDescription = description; + step.CustomActivityGroupName = groupName; + } + + // Enrich condition tree field references with display names + if (step.ConditionTree != null) + { + EnrichConditionFieldNames(step.ConditionTree); + step.ConditionDescription = step.ConditionTree.ToFlatString(); + } + + // Enrich field assignment values + if (step.Fields.Count > 0) + { + foreach (var field in step.Fields) + { + // Resolve option set integer values: "1" → "Active (1)" + if (!string.IsNullOrEmpty(field.Value) && !string.IsNullOrEmpty(step.TargetEntity) && + field.Value.StartsWith("\"") && field.Value.EndsWith("\"")) + { + string intVal = field.Value.Trim('"'); + if (int.TryParse(intVal, out _)) + { + string resolved = ResolveOptionSetValues(step.TargetEntity, field.FieldName, field.Value); + if (resolved != field.Value) + field.Value = resolved; + } + } + + // Resolve {entity.field} dynamic references to display names + if (!string.IsNullOrEmpty(field.Value)) + { + field.Value = ResolveEntityFieldReferences(field.Value); + } + + // Resolve field logical name to display name for the field label itself + if (!string.IsNullOrEmpty(field.FieldName) && !string.IsNullOrEmpty(step.TargetEntity)) + { + string fieldDisplay = ResolveFieldDisplayName(step.TargetEntity, field.FieldName); + if (fieldDisplay != field.FieldName) + field.FieldName = fieldDisplay + " (" + field.FieldName + ")"; + } + } + } + + if (step.ChildSteps.Count > 0) + EnrichCustomActivityMetadata(step.ChildSteps, customizations); + } + } + + private static DocumentationContext _enrichContext; + + private static void EnrichConditionFieldNames(ConditionExpression expr) + { + if (expr.IsLeaf && !string.IsNullOrEmpty(expr.Field)) + { + // Parse "{entityName.fieldName}" format and resolve to display names + string field = expr.Field.Trim('{', '}'); + int dotIdx = field.IndexOf('.'); + string entityLogical = null; + string fieldLogical = null; + if (dotIdx > 0) + { + entityLogical = field.Substring(0, dotIdx); + // Strip " (Related)" suffix if present + string cleanEntity = entityLogical.Replace(" (Related)", ""); + fieldLogical = field.Substring(dotIdx + 1); + bool isRelated = entityLogical.Contains("(Related)"); + + // Resolve display names + string entityDisplay = _enrichContext?.GetTableDisplayName(cleanEntity) ?? cleanEntity; + string fieldDisplay = ResolveFieldDisplayName(cleanEntity, fieldLogical); + + if (isRelated) + expr.Field = fieldDisplay + " on " + entityDisplay + " (Related)"; + else + expr.Field = fieldDisplay + " on " + entityDisplay; + + entityLogical = cleanEntity; + } + + // Resolve option set values in the Value field + if (!string.IsNullOrEmpty(expr.Value) && !string.IsNullOrEmpty(entityLogical) && !string.IsNullOrEmpty(fieldLogical)) + { + expr.Value = ResolveOptionSetValues(entityLogical, fieldLogical, expr.Value); + } + } + else if (expr.IsGroup) + { + foreach (var child in expr.Children) + EnrichConditionFieldNames(child); + } + } + + private static string ResolveFieldDisplayName(string entityLogicalName, string fieldLogicalName) + { + if (_enrichContext?.Tables == null) return fieldLogicalName; + var table = _enrichContext.Tables.FirstOrDefault(t => + t.getName().Equals(entityLogicalName, System.StringComparison.OrdinalIgnoreCase)); + if (table == null) return fieldLogicalName; + var column = table.GetColumns().FirstOrDefault(c => + c.getLogicalName().Equals(fieldLogicalName, System.StringComparison.OrdinalIgnoreCase)); + if (column == null) return fieldLogicalName; + string displayName = column.getDisplayName(); + if (!string.IsNullOrEmpty(displayName) && !displayName.Equals(fieldLogicalName, System.StringComparison.OrdinalIgnoreCase)) + return displayName + " (" + fieldLogicalName + ")"; + return fieldLogicalName; + } + + private static string ResolveOptionSetValues(string entityLogicalName, string fieldLogicalName, string rawValue) + { + if (_enrichContext?.Tables == null) return rawValue; + var table = _enrichContext.Tables.FirstOrDefault(t => + t.getName().Equals(entityLogicalName, System.StringComparison.OrdinalIgnoreCase)); + if (table == null) return rawValue; + var column = table.GetColumns().FirstOrDefault(c => + c.getLogicalName().Equals(fieldLogicalName, System.StringComparison.OrdinalIgnoreCase)); + if (column == null) return rawValue; + + // Value may be a single quoted value like "100000002" or comma-separated like "100000001", "100000002" + string[] parts = rawValue.Split(','); + var resolved = new List(); + foreach (string part in parts) + { + string trimmed = part.Trim().Trim('"'); + string label = column.GetOptionSetLabel(trimmed); + if (!string.IsNullOrEmpty(label)) + resolved.Add(label + " (" + trimmed + ")"); + else + resolved.Add("\"" + trimmed + "\""); + } + return string.Join(", ", resolved); + } + + /// + /// Resolves {entity.field} references in a value string to friendly display names. + /// Handles patterns like: + /// {account.name} → Account Name (name) on Account (account) + /// {systemuser (Related).address1_composite} → Address (address1_composite) on User (systemuser) (Related) + /// {Process.Execution Time} → {Process.Execution Time} (unchanged) + /// Concatenated: {account.name} + "literal" → resolved individually + /// + private static string ResolveEntityFieldReferences(string value) + { + if (string.IsNullOrEmpty(value) || _enrichContext?.Tables == null) return value; + + // Match all {entity.field} patterns, including those with " (Related)" suffix + return System.Text.RegularExpressions.Regex.Replace(value, + @"\{([^}]+)\}", + match => + { + string inner = match.Groups[1].Value; + + // Skip system/process references like {Process.Execution Time} + if (inner.StartsWith("Process.", System.StringComparison.OrdinalIgnoreCase)) + return match.Value; + + int dotIdx = inner.IndexOf('.'); + if (dotIdx <= 0) return match.Value; + + string entityPart = inner.Substring(0, dotIdx); + string fieldPart = inner.Substring(dotIdx + 1); + + // Handle "(Related)" suffix + bool isRelated = entityPart.Contains("(Related)"); + string cleanEntity = entityPart.Replace(" (Related)", "").Trim(); + + // Resolve display names + string entityDisplay = _enrichContext.GetTableDisplayName(cleanEntity); + if (string.IsNullOrEmpty(entityDisplay)) entityDisplay = cleanEntity; + + string fieldDisplay = ResolveFieldDisplayName(cleanEntity, fieldPart); + + string relatedSuffix = isRelated ? " (Related)" : ""; + return fieldDisplay + " on " + entityDisplay + relatedSuffix; + }); + } } } \ No newline at end of file diff --git a/PowerDocu.sln b/PowerDocu.sln index 41253f9..e19c46c 100644 --- a/PowerDocu.sln +++ b/PowerDocu.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerDocu.BPFDocumenter", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerDocu.DesktopFlowDocumenter", "PowerDocu.DesktopFlowDocumenter\PowerDocu.DesktopFlowDocumenter.csproj", "{AFA444CF-CC6C-40B3-B4FE-6F627A3FF888}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerDocu.ClassicWorkflowDocumenter", "PowerDocu.ClassicWorkflowDocumenter\PowerDocu.ClassicWorkflowDocumenter.csproj", "{1E92CCC5-56CF-46C9-BB4F-EDBC3D4FCC57}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/modules/PowerDocu.Common b/modules/PowerDocu.Common index ba5c71e..03408e5 160000 --- a/modules/PowerDocu.Common +++ b/modules/PowerDocu.Common @@ -1 +1 @@ -Subproject commit ba5c71ea25d84366c5b6ed8c245484cb2f53cdc8 +Subproject commit 03408e5750085495cea370b78086e844dbcb863b