diff --git a/Advanced/DaxUDF Macros/Create Time Intel Functions.cs b/Advanced/DaxUDF Macros/Create Time Intel Functions.cs new file mode 100644 index 0000000..d923b33 --- /dev/null +++ b/Advanced/DaxUDF Macros/Create Time Intel Functions.cs @@ -0,0 +1,455 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + +using Microsoft.VisualBasic; +// 2025-09-29/B.Agullo +// Creates Time Intelligence functions (CY, PY, YOY, YOYPCT) in the model if they do not exist. +// It also creates a hidden calculated column and measure in the date table to handle cases where the fact table has no data for some dates. +// The script assumes there is a date table and a fact table in the model. +// The script will prompt the user to select the main date column in the fact table if there are multiple date columns. +// More details at: https://www.esbrina-ba.com/industrializing-model-dependent-dax-udfs-with-tabular-editor-c-scripting-time-intel-reloaded/ +if(Model.Database.CompatibilityLevel < 1702) +{ + if(Fx.IsAnswerYes("The model compatibility level is below 1702. Time Intelligence functions are only supported in 1702 or higher. Do to change the compatibility level to 1702?")) + { + Model.Database.CompatibilityLevel = 1702; + } + else + { + Info("Operation cancelled."); + return; + } +} +Table dateTable = Fx.GetDateTable(model: Model); +if (dateTable == null) return; +Column dateColumn = Fx.GetDateColumn(dateTable); +if (dateColumn == null) return; +Table factTable = Fx.GetFactTable(model: Model); +if (factTable == null) return; +Column factTableDateColumn = null; +IEnumerable factTableDateColumns = + factTable.Columns.Where( + c => c.DataType == DataType.DateTime); +if(factTableDateColumns.Count() == 0) +{ + Error("No Date columns found in fact table " + factTable.Name); + return; +} +if(factTableDateColumns.Count() == 1) +{ + factTableDateColumn = factTableDateColumns.First(); +} +else +{ + factTableDateColumn = factTableDateColumns.First( + c=> Model.Relationships.Any( + r => ((r.FromColumn == dateColumn && r.ToColumn == c) + || (r.ToColumn == dateColumn && r.FromColumn == c) + && r.IsActive))); + factTableDateColumn = SelectColumn(factTableDateColumns, factTableDateColumn, "Select main date column from the fact table"); +} +if(factTableDateColumn == null) return; +string dateTableAuxColumnName = "DateWith" + factTable.Name.Replace(" ", ""); +string dateTableAuxColumnExpression = String.Format(@"{0} <= MAX({1})", dateColumn.DaxObjectFullName, factTableDateColumn.DaxObjectFullName); +CalculatedColumn dateTableAuxColumn = dateTable.AddCalculatedColumn(dateTableAuxColumnName, dateTableAuxColumnExpression); +dateTableAuxColumn.FormatDax(); +dateTableAuxColumn.IsHidden = true; +string dateTableAuxMeasureName = "ShowValueForDates"; +string dateTableAuxMeasureExpression = + String.Format( + @"VAR LastDateWithData = + CALCULATE ( + MAX ( {0} ), + REMOVEFILTERS () + ) + VAR FirstDateVisible = + MIN ( {1} ) + VAR Result = + FirstDateVisible <= LastDateWithData + RETURN + Result", + factTableDateColumn.DaxObjectFullName, + dateColumn.DaxObjectFullName); +Measure dateTableAuxMeasure = dateTable.AddMeasure(dateTableAuxMeasureName, dateTableAuxMeasureExpression); +dateTableAuxMeasure.IsHidden = true; +dateTableAuxMeasure.FormatDax(); +//CY --just for the sake of completion +string CYfunctionName = "Local.TimeIntel.CY"; +string CYfunctionExpression = "(baseMeasure) => baseMeasure"; +Function CYfunction = Model.AddFunction(CYfunctionName); +CYfunction.Expression = CYfunctionExpression; +CYfunction.FormatDax(); +CYfunction.SetAnnotation("displayFolder", @"baseMeasureDisplayFolder\baseMeasureName TimeIntel"); +CYfunction.SetAnnotation("formatString", "baseMeasureFormatStringFull"); +CYfunction.SetAnnotation("outputType", "Measure"); +CYfunction.SetAnnotation("nameTemplate", "baseMeasureName CY"); +CYfunction.SetAnnotation("outputDestination", "baseMeasureTable"); +//PY +string PYfunctionName = "Local.TimeIntel.PY"; +string PYfunctionExpression = + String.Format( + @"(baseMeasure: ANYREF) => + IF( + {0}, + CALCULATE( + baseMeasure, + CALCULATETABLE( + DATEADD( + {1}, + -1, + YEAR + ), + {2} = TRUE + ) + ) + )", + dateTableAuxMeasure.DaxObjectFullName, + dateColumn.DaxObjectFullName, + dateTableAuxColumn.DaxObjectFullName); +Function PYfunction = Model.AddFunction(PYfunctionName); +PYfunction.Expression = PYfunctionExpression; +PYfunction.FormatDax(); +PYfunction.SetAnnotation("displayFolder", @"baseMeasureDisplayFolder\baseMeasureName TimeIntel"); +PYfunction.SetAnnotation("formatString", "baseMeasureFormatStringFull"); +PYfunction.SetAnnotation("outputType", "Measure"); +PYfunction.SetAnnotation("nameTemplate", "baseMeasureName PY"); +PYfunction.SetAnnotation("outputDestination", "baseMeasureTable"); +//YOY +string YOYfunctionName = "Local.TimeIntel.YOY"; +string YOYfunctionExpression = + @"(baseMeasure: ANYREF) => + VAR ValueCurrentPeriod = Local.TimeIntel.CY(baseMeasure) + VAR ValuePreviousPeriod = Local.TimeIntel.PY(baseMeasure) + VAR Result = + IF( + NOT ISBLANK( ValueCurrentPeriod ) + && NOT ISBLANK( ValuePreviousPeriod ), + ValueCurrentPeriod + - ValuePreviousPeriod + ) + RETURN + Result"; +Function YOYfunction = Model.AddFunction(YOYfunctionName); +YOYfunction.Expression = YOYfunctionExpression; +YOYfunction.FormatDax(); +YOYfunction.SetAnnotation("displayFolder", @"baseMeasureDisplayFolder\baseMeasureName TimeIntel"); +YOYfunction.SetAnnotation("formatString", "+baseMeasureFormatStringRoot;-baseMeasureFormatStringRoot;-"); +YOYfunction.SetAnnotation("outputType", "Measure"); +YOYfunction.SetAnnotation("nameTemplate", "baseMeasureName YOY"); +YOYfunction.SetAnnotation("outputDestination", "baseMeasureTable"); +//YOY% +string YOYPfunctionName = "Local.TimeIntel.YOYPCT"; +string YOYPfunctionExpression = + @"(baseMeasure: ANYREF) => + VAR ValueCurrentPeriod = Local.TimeIntel.CY(baseMeasure) + VAR ValuePreviousPeriod = Local.TimeIntel.PY(baseMeasure) + VAR CurrentMinusPreviousPeriod = + IF( + NOT ISBLANK( ValueCurrentPeriod ) + && NOT ISBLANK( ValuePreviousPeriod ), + ValueCurrentPeriod + - ValuePreviousPeriod + ) + VAR Result = + DIVIDE( + CurrentMinusPreviousPeriod, + ValuePreviousPeriod + ) + RETURN + Result"; +Function YOYPfunction = Model.AddFunction(YOYPfunctionName); +YOYPfunction.Expression = YOYPfunctionExpression; +YOYPfunction.FormatDax(); +YOYPfunction.SetAnnotation("displayFolder", @"baseMeasureDisplayFolder\baseMeasureName TimeIntel"); +YOYPfunction.SetAnnotation("formatString", "+0.0%;-0.0%;-"); +YOYPfunction.SetAnnotation("outputType", "Measure"); +YOYPfunction.SetAnnotation("nameTemplate", "baseMeasureName YOY%"); +YOYPfunction.SetAnnotation("outputDestination", "baseMeasureTable"); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} diff --git a/Advanced/DaxUDF Macros/create measures from daxudfs.csx b/Advanced/DaxUDF Macros/create measures from daxudfs.csx new file mode 100644 index 0000000..8c7c2c6 --- /dev/null +++ b/Advanced/DaxUDF Macros/create measures from daxudfs.csx @@ -0,0 +1,642 @@ +#r "Microsoft.VisualBasic" +//2025-09-26/B.Agullo/ fixed bug that would not store annotations if initialized during runtime +//2025-09-16/B.Agullo/ +//Creates measures based on DAX UDFs +//Check the blog post for futher information: https://www.esbrina-ba.com/automatically-create-measures-with-dax-user-defined-functions/ +using System.Windows.Forms; + +using Microsoft.VisualBasic; +using System.Text.RegularExpressions; +// PSEUDOCODE / PLAN: +// 1. Verify that the user has selected one or more functions (Selected.Functions). +// 2. If none selected, show error and abort. +// 3. Create FunctionExtended objects for each selected function and keep them in a list. +// 4. Extract all parameters from the selected functions and build a distinct list by name. +// 5. For each distinct parameter name: +// - Prompt the user once with Fx.SelectAnyObjects to choose the objects to iterate for that parameter. +// - If the user cancels or selects nothing, abort the whole operation. +// - Store the resulting IList in a dictionary keyed by parameter name so it can be retrieved later. +// 6. Example usage: create a sample FunctionExtended (as the original example does), +// then when iterating the function parameters use the previously built dictionary to obtain the list +// of objects for each parameter name (do not prompt again). +// 7. Build measure names/expressions by iterating the parameter-object combinations and create measures. +// +// NOTES: +// - All Fx.SelectAnyObjects calls must use parameter names on the call. +// - The dictionary is Dictionary> parameterObjectsMap. +// - Abort execution if any required selection is cancelled. +// Validate selection +if (Selected.Functions.Count() == 0) +{ + Error("Select one or more functions and try again."); + return; +} +// Create FunctionExtended objects for each selected function and store them for later iteration +IList selectedFunctions = new List(); +foreach (var f in Selected.Functions) +{ + // Create the FunctionExtended and add to list + FunctionExtended fe = FunctionExtended.CreateFunctionExtended(f); + selectedFunctions.Add(fe); +} +// Flatten all parameters from selected functions +var allParametersFlat = selectedFunctions + .SelectMany(sf => sf.Parameters ?? new List()) + .ToList(); +// Build distinct FunctionParameter objects (first occurrence per name) +IList distinctParameters = allParametersFlat + .GroupBy(p => p.Name) + .Select(g => g.First()) + .ToList(); +// For each distinct parameter, ask the user once which objects should be iterated and store mapping +var parameterObjectsMap = new Dictionary Values, string Type)>(); +foreach (var param in distinctParameters) +{ + string selectionType = null; + if (param.Name.ToUpper().Contains("MEASURE")) + { + selectionType = "Measure"; + } + else if (param.Name.ToUpper().Contains("COLUMN")) + { + selectionType = "Column"; + } + else if (param.Name.ToUpper().Contains("TABLE")) + { + selectionType = "Table"; + } + (IList Values,string Type) selectedObjectsForParam = Fx.SelectAnyObjects( + Model, + selectionType: selectionType, + prompt1: String.Format(@"Select object type for {0} parameter", param.Name), + prompt2: String.Format(@"Select item for {0} parameter", param.Name), + placeholderValue: param.Name + ); + if (selectedObjectsForParam.Type == null || selectedObjectsForParam.Values.Count == 0) + { + Info(String.Format("No objects selected for parameter '{0}'. Operation cancelled.", param.Name)); + return; + } + parameterObjectsMap[param.Name] = selectedObjectsForParam; +} +foreach (var func in selectedFunctions) +{ + string delimiter = ""; + IList previousList = new List() { func.Name + "(" }; + IList currentList = new List(); + IList previousListNames = new List() { func.OutputNameTemplate }; + IList currentListNames = new List(); + IList previousDestinations = new List() { func.OutputDestination }; + IList currentDestinations = new List(); + IList previousDisplayFolders = new List() { func.OutputDisplayFolder }; + IList currentDisplayFolders = new List(); + IList previousFormatStrings = new List() { func.OutputFormatString }; + IList currentFormatStrings = new List(); + // When iterating the parameters of this specific function, use the mapping created for distinct parameters. + foreach (var param in func.Parameters) + { + currentList = new List(); //reset current list + currentListNames = new List(); + currentFormatStrings = new List(); + currentDestinations = new List(); + currentDisplayFolders = new List(); + // Retrieve the objects list for this parameter name from the map (prompting was done earlier) + (IList Values, string Type) paramObject; + if (!parameterObjectsMap.TryGetValue(param.Name, out paramObject) || paramObject.Type == null || paramObject.Values.Count == 0) + { + Error(String.Format("No objects were selected earlier for parameter '{0}'.", param.Name)); + return; + } + for (int i = 0; i < previousList.Count; i++) + { + string s = previousList[i]; + string sName = previousListNames[i]; + string sFormatString = previousFormatStrings[i]; + string sDisplayFolder = previousDisplayFolders[i]; + string sDestination = previousDestinations[i]; + foreach (var o in paramObject.Values) + { + //extract original name and format string if the parameter is a measure + string paramName = o; + string paramFormatStringFull = ""; + string paramFormatStringRoot = ""; + string paramDisplayFolder = ""; + string paramTable = ""; + //prepare placeholder + string paramNamePlaceholder = param.Name + "Name"; + string paramFormatStringRootPlaceholder = param.Name + "FormatStringRoot"; + string paramFormatStringFullPlaceholder = param.Name + "FormatStringFull"; + string paramDisplayFolderPlaceholder = param.Name + "DisplayFolder"; + string paramTablePlaceholder = ""; + if (paramObject.Type == "Measure") + { + Measure m = Model.AllMeasures.FirstOrDefault(m => m.DaxObjectFullName == o); + paramName = m.Name; + paramFormatStringFull = m.FormatString; + paramDisplayFolder = m.DisplayFolder; + paramTable = m.Table.DaxObjectFullName; + paramTablePlaceholder = param.Name + "Table"; + } + else if (paramObject.Type == "Column") + { + Column c = Model.AllColumns.FirstOrDefault(c => c.DaxObjectFullName == o); + paramName = c.Name; + paramFormatStringFull = c.FormatString; + paramDisplayFolder = c.DisplayFolder; + paramTable = c.Table.DaxObjectFullName; + paramTablePlaceholder = param.Name + "Table"; + } + else if (paramObject.Type == "Table") + { + Table t = Model.Tables.FirstOrDefault(t => t.DaxObjectFullName == o); + paramName = t.Name; + paramFormatStringFull = ""; + paramDisplayFolder = ""; + paramTable = t.DaxObjectFullName; + paramTablePlaceholder = param.Name; + } + if (paramFormatStringFull.Contains(";")) + { + paramFormatStringRoot = paramFormatStringFull.Split(';')[0]; + } + else + { + paramFormatStringRoot = paramFormatStringFull; + } + currentList.Add(s + delimiter + o); + currentListNames.Add(sName.Replace(paramNamePlaceholder, paramName)); + currentFormatStrings.Add( + sFormatString + .Replace(paramFormatStringFullPlaceholder, paramFormatStringFull) + .Replace(paramFormatStringRootPlaceholder, paramFormatStringRoot)); + currentDisplayFolders.Add( + sDisplayFolder + .Replace(paramNamePlaceholder, paramName) + .Replace(paramDisplayFolderPlaceholder, paramDisplayFolder)); + currentDestinations.Add( + sDestination.Replace(paramTablePlaceholder, paramTable)); + } + } + delimiter = ", "; + previousList = currentList; + previousListNames = currentListNames; + previousDestinations = currentDestinations; + previousDisplayFolders = currentDisplayFolders; + previousFormatStrings = currentFormatStrings; + } + IList
currentDestinationTables = new List
(); + if(func.OutputType == "Measure" || func.OutputType == "Column") + { + for (int i = 0; i < currentDestinations.Count; i++) + { + //transform to actual tables, initialize if necessary + Table destinationTable = Model.Tables.Where( + t => t.DaxObjectFullName == currentDestinations[i]) + .FirstOrDefault(); + if (destinationTable == null) + { + destinationTable = SelectTable(label: "Select destinatoin table for " + func.OutputType + " " + currentListNames[i]); + if (destinationTable == null) return; + } + currentDestinationTables.Add(destinationTable); + } + } + if (func.OutputType == "Measure") + { + for (int i = 0; i < currentList.Count; i++) + { + //It normalizes a folder/display-folder string by collapsing repeated slashes, removing leading/trailing backslashes and trimming whitespace. + string cleanCurrentDisplayFolder = Regex.Replace(currentDisplayFolders[i], @"[/]+", @"").Trim('\\').Trim(); + Measure measure = currentDestinationTables[i].AddMeasure(currentListNames[i], currentList[i] + ")"); + measure.FormatDax(); + measure.Description = String.Format("Measure created with {0} function. Check function for details.", func.Name); + measure.DisplayFolder = cleanCurrentDisplayFolder; + measure.FormatString = currentFormatStrings[i]; + } + } + else if (func.OutputType == "Column") + { + for (int i = 0; i < currentList.Count; i++) + { + //It normalizes a folder/display-folder string by collapsing repeated slashes, removing leading/trailing backslashes and trimming whitespace. + string cleanCurrentDisplayFolder = Regex.Replace(currentDisplayFolders[i], @"[/]+", @"").Trim('\\').Trim(); + Column column = currentDestinationTables[i].AddCalculatedColumn(currentListNames[i], currentList[i] + ")"); + //column.FormatDax(); + column.Description = String.Format("Column created with {0} function. Check function for details.", func.Name); + column.DisplayFolder = cleanCurrentDisplayFolder; + column.FormatString = currentFormatStrings[i]; + } + } + else + { + Info("Not implemented yet for output types other than Measure."); + } +} + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + + + + public class FunctionExtended + { + public string Name { get; set; } + public string Expression { get; set; } + public string Description { get; set; } + public string OutputFormatString { get; set; } + public string OutputNameTemplate { get; set; } + public string OutputType { get; set; } + public string OutputDisplayFolder { get; set; } + + public string OutputDestination { get; set; } + public Function OriginalFunction { get; set; } + public List Parameters { get; set; } + private static List ExtractParametersFromExpression(string expression) + { + // Find the first set of parentheses before the "=>" + int arrowIndex = expression.IndexOf("=>"); + if (arrowIndex == -1) + return new List(); + + int openParenIndex = expression.LastIndexOf('(', arrowIndex); + int closeParenIndex = expression.IndexOf(')', openParenIndex); + if (openParenIndex == -1 || closeParenIndex == -1 || closeParenIndex > arrowIndex) + return new List(); + + string paramSection = expression.Substring(openParenIndex + 1, closeParenIndex - openParenIndex - 1); + var paramList = new List(); + var paramStrings = paramSection.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var param in paramStrings) + { + var trimmed = param.Trim(); + var nameParams = trimmed.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries); + //var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + var fp = new FunctionParameter(); + fp.Name = nameParams.Length > 0 ? nameParams[0] : param; + fp.Type = + (nameParams.Length == 1) ? "ANYVAL" : + (nameParams.Length > 1) ? + (nameParams[1].IndexOf("anyVal", StringComparison.OrdinalIgnoreCase) >= 0 ? "ANYVAL" : + nameParams[1].IndexOf("Scalar", StringComparison.OrdinalIgnoreCase) >= 0 ? "SCALAR" : + nameParams[1].IndexOf("Table", StringComparison.OrdinalIgnoreCase) >= 0 ? "TABLE" : + nameParams[1].IndexOf("AnyRef", StringComparison.OrdinalIgnoreCase) >= 0 ? "ANYREF" : + "ANYVAL") + : "ANYVAL"; + + fp.Subtype = + (fp.Type == "SCALAR" && nameParams.Length > 1) ? + ( + nameParams[1].IndexOf("variant", StringComparison.OrdinalIgnoreCase) >= 0 ? "VARIANT" : + nameParams[1].IndexOf("int64", StringComparison.OrdinalIgnoreCase) >= 0 ? "INT64" : + nameParams[1].IndexOf("decimal", StringComparison.OrdinalIgnoreCase) >= 0 ? "DECIMAL" : + nameParams[1].IndexOf("double", StringComparison.OrdinalIgnoreCase) >= 0 ? "DOUBLE" : + nameParams[1].IndexOf("string", StringComparison.OrdinalIgnoreCase) >= 0 ? "STRING" : + nameParams[1].IndexOf("datetime", StringComparison.OrdinalIgnoreCase) >= 0 ? "DATETIME" : + nameParams[1].IndexOf("boolean", StringComparison.OrdinalIgnoreCase) >= 0 ? "BOOLEAN" : + nameParams[1].IndexOf("numeric", StringComparison.OrdinalIgnoreCase) >= 0 ? "NUMERIC" : + null + ) + : null; + + // ParameterMode: check for VAL or EXPR (any casing) in the parameter string + string paramMode = null; + if (trimmed.IndexOf("VAL", StringComparison.OrdinalIgnoreCase) >= 0) + paramMode = "VAL"; + else if (trimmed.IndexOf("EXPR", StringComparison.OrdinalIgnoreCase) >= 0) + paramMode = "EXPR"; + else + paramMode = "VAL"; + fp.ParameterMode = paramMode; + + paramList.Add(fp); + } + + return paramList; + } + public static FunctionExtended CreateFunctionExtended(Function function) + { + + FunctionExtended emptyFunction = null as FunctionExtended; + List Parameters = ExtractParametersFromExpression (function.Expression); + + string nameTemplateDefault = ""; + string formatStringDefault = ""; + string displayFolderDefault = ""; + string functionNameShort = function.Name; + string destinationDefault = ""; + + if(function.Name.IndexOf(".") > 0) + { + functionNameShort = function.Name.Substring(function.Name.LastIndexOf(".") + 1); + } + + if (Parameters.Count == 0) { + nameTemplateDefault = function.Name; + formatStringDefault = ""; + displayFolderDefault = ""; + destinationDefault = ""; + } + else + { + nameTemplateDefault = string.Join(" ", Parameters.Select(p => p.Name + "Name")); + if(function.Name.Contains("Pct")) + { + formatStringDefault = "+0.0%;-0.0%;-"; + } + else + { + formatStringDefault = Parameters[0].Name + "FormatStringRoot"; + } + + + + displayFolderDefault = + String.Format( + @"{0}DisplayFolder/{1}Name {2}", + Parameters[0].Name, + Parameters[0].Name, + functionNameShort); + + if (Parameters[0].Name.ToUpper().Contains("TABLE")) + { + destinationDefault = Parameters[0].Name; + } + else if (Parameters[0].Name.ToUpper().Contains("MEASURE") || Parameters[0].Name.ToUpper().Contains("COLUMN")) + { + destinationDefault = Parameters[0].Name + "Table"; + } + else + { + destinationDefault = "Custom"; + } + + + }; + + + + + string myOutputType = function.GetAnnotation("outputType"); + string myNameTemplate = function.GetAnnotation("nameTemplate"); + string myFormatString = function.GetAnnotation("formatString"); + string myDisplayFolder = function.GetAnnotation("displayFolder"); + string myOutputDestination = function.GetAnnotation("outputDestination"); + + if (string.IsNullOrEmpty(myOutputType)) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "None" }; + myOutputType = + Fx.ChooseString( + OptionList: selectionTypeOptions, + label: "Choose output type for function" + function.Name, + customWidth: 600); + if (string.IsNullOrEmpty(myOutputType)) return emptyFunction; + function.SetAnnotation("outputType", myOutputType); + } + + if (string.IsNullOrEmpty(myNameTemplate)) + { + myNameTemplate = Fx.GetNameFromUser(Prompt:"Enter output name template for function " + function.Name, "Name Template", nameTemplateDefault); + if (string.IsNullOrEmpty(myNameTemplate)) return emptyFunction; + function.SetAnnotation("nameTemplate", myNameTemplate); + } + if(string.IsNullOrEmpty(myFormatString)) + { + myFormatString = Fx.GetNameFromUser(Prompt: "Enter output format string for function " + function.Name, "Format String", formatStringDefault); + if (string.IsNullOrEmpty(myFormatString)) return emptyFunction; + function.SetAnnotation("formatString", myFormatString); + + } + if(string.IsNullOrEmpty(myDisplayFolder)) + { + myDisplayFolder = + Fx.GetNameFromUser( + Prompt: "Enter output display folder for function " + function.Name, + Title:"Display Folder", + DefaultResponse: displayFolderDefault); + + if (string.IsNullOrEmpty(myDisplayFolder)) return emptyFunction; + function.SetAnnotation("displayFolder", myDisplayFolder); + } + + if (string.IsNullOrEmpty(myOutputDestination)) + { + if(myOutputType == "Table") + { + myOutputDestination = "Model"; + } + else if(myOutputType == "Column" || myOutputType == "Measure") + { + myOutputDestination = + Fx.GetNameFromUser( + Prompt: "Enter Destination template for " + function.Name, + Title:"Destination", + DefaultResponse: destinationDefault); + + if(string.IsNullOrEmpty(myOutputDestination)) return emptyFunction; + function.SetAnnotation("outputDestination", destinationDefault); + } + } + + + var functionExtended = new FunctionExtended + { + Name = function.Name, + Expression = function.Expression, + Description = function.Description, + Parameters = Parameters, + OutputFormatString = myFormatString, + OutputNameTemplate = myNameTemplate, + OutputType = myOutputType, + OutputDisplayFolder = myDisplayFolder, + OutputDestination = myOutputDestination, + OriginalFunction = function + + }; + + return functionExtended; + } + + + } + + + public class FunctionParameter + { + public string Name { get; set; } + public string Type { get; set; } + public string Subtype { get; set; } + public string ParameterMode { get; set; } + } diff --git a/Advanced/One-Click Macros/Create Aribrary Period Comparison Calc Group.csx b/Advanced/One-Click Macros/Create Aribrary Period Comparison Calc Group.csx new file mode 100644 index 0000000..6609a01 --- /dev/null +++ b/Advanced/One-Click Macros/Create Aribrary Period Comparison Calc Group.csx @@ -0,0 +1,272 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + +using Microsoft.VisualBasic; +string calcGroupName = Fx.GetNameFromUser("Input Calc Group Name",DefaultResponse: "Model"); +string selectedCalcItemName = Fx.GetNameFromUser("Input Selected calc Item name", DefaultResponse: "Selected"); +string referenceCalcItemName = Fx.GetNameFromUser("Input Reference calc Item name", DefaultResponse: "Reference"); +string comparisonCalcItemName = Fx.GetNameFromUser("Input Comparison calc item name", DefaultResponse: "Comparison"); +string comparisonPctCalcItemName = Fx.GetNameFromUser("Input Comparison % name", DefaultResponse: "Comparison %"); +string daysMeasureName = Fx.GetNameFromUser("Input Days Selected Measure name", DefaultResponse: "Days Selected"); +string referenceDaysRawMeasureName = Fx.GetNameFromUser("Input Days Reference raw Measure name", DefaultResponse: "Days Reference Raw"); +string referenceDaysMeasureName = Fx.GetNameFromUser("Input Days Refference name", DefaultResponse: "Days Reference"); +string daysDifferenceMeasureName = Fx.GetNameFromUser("Input Days Difference name", DefaultResponse: "Days Difference"); +IEnumerable
dateTables = Fx.GetDateTables(Model); +if (dateTables == null) return; +if (dateTables.Count() < 2) +{ + Error("Less than 2 date tables detected in your model. A minimum of 2 date tables (marked as date tables) are required to run this script"); + return; +} +Table dateTable = SelectTable(tables: dateTables, preselect: dateTables.First(), label: "Select main date table"); +if (dateTable == null) +{ + Error("No table selected."); + return; +} +Func weekColFunc = c => c.Name.Contains("Week") || c.Name.Contains("Semana"); +IEnumerable dayOfWeekColumns = Fx.GetFilteredColumns(dateTable.Columns, weekColFunc); + +Column dayOfWeekColumn = SelectColumn(dayOfWeekColumns, dayOfWeekColumns.First(), label: "Select Day of Week column"); +if (dayOfWeekColumn == null) { Error("No column selected"); return; } +Table referenceDateTable = SelectTable(tables: dateTables, preselect: dateTables.Last(), label: "Select reference date table"); +if (referenceDateTable == null) +{ + Error("No table selected."); + return; +} +IEnumerable referenceDayOfWeekColumns = Fx.GetFilteredColumns(referenceDateTable.Columns, weekColFunc); +Column referenceDayOfWeekColumn = SelectColumn(referenceDayOfWeekColumns, referenceDayOfWeekColumns.First(), label: "Select Day of Week column of reference Date Table"); +if (referenceDayOfWeekColumn == null) { Error("No column selected"); return; } +CalculationGroupTable calcGroup = Model.AddCalculationGroup(calcGroupName); +Column calcGroupColumn = calcGroup.Columns[0]; +calcGroupColumn.Name = calcGroup.Name; +string selectedCalcItemExpression = + String.Format( + @"CALCULATE( + SELECTEDMEASURE( ), + REMOVEFILTERS( {0} ) + )", + referenceDateTable.DaxObjectFullName); +CalculationItem selectedCalcItem = calcGroup.AddCalculationItem(selectedCalcItemName, selectedCalcItemExpression); +selectedCalcItem.FormatDax(); +selectedCalcItem.Ordinal = 0; +string referenceCalcItemExpression = + String.Format( + @"CALCULATE( + CALCULATE( SELECTEDMEASURE( ), REMOVEFILTERS( {0} ) ), + TREATAS( + VALUES( {1} ), + {2} + ) + )", + dateTable.DaxObjectFullName, + dayOfWeekColumn.DaxObjectFullName, + referenceDayOfWeekColumn.DaxObjectFullName + ); +CalculationItem referenceCalcItem = calcGroup.AddCalculationItem(referenceCalcItemName, referenceCalcItemExpression); +referenceCalcItem.FormatDax(); +referenceCalcItem.Ordinal = 1; +string comparisonCalcItemExpression = + String.Format( + @"VAR _selection = + CALCULATE( + SELECTEDMEASURE( ), + REMOVEFILTERS( {0} ) + ) + VAR _refrence = + CALCULATE( + CALCULATE( SELECTEDMEASURE( ),REMOVEFILTERS( {1} )), + TREATAS( + VALUES( {2} ), + {3} + ) + ) + VAR _result = + IF( + ISBLANK( _selection ) || ISBLANK( _refrence ), + BLANK( ), + _selection - _refrence + ) + RETURN + _result", + referenceDateTable.DaxObjectFullName, + dateTable.DaxObjectFullName, + dayOfWeekColumn.DaxObjectFullName, + referenceDayOfWeekColumn.DaxObjectFullName); +string comparisonCalcItemFormatStringExpression = + @"VAR _fs = SELECTEDMEASUREFORMATSTRING() + RETURN ""+"" & _fs & "";-"" & _fs & "";-"" "; +CalculationItem comparisonCalcItem = calcGroup.AddCalculationItem(comparisonCalcItemName, comparisonCalcItemExpression); +comparisonCalcItem.FormatStringExpression = comparisonCalcItemFormatStringExpression; +comparisonCalcItem.FormatDax(); +comparisonCalcItem.Ordinal = 2; +string comparisonPctCalcItemExpression = + String.Format( + @"VAR _selection = + CALCULATE( + SELECTEDMEASURE( ), + REMOVEFILTERS( {0} ) + ) + VAR _refrence = + CALCULATE( + CALCULATE( SELECTEDMEASURE( ),REMOVEFILTERS( {1} ) ), + TREATAS( + VALUES( {2} ), + {3} + ) + ) + VAR _result = + IF( + ISBLANK( _selection ) || ISBLANK( _refrence ), + BLANK( ), + DIVIDE( _selection - _refrence, _refrence ) + ) + RETURN + _result", + referenceDateTable.DaxObjectFullName, + dateTable.DaxObjectFullName, + dayOfWeekColumn.DaxObjectFullName, + referenceDayOfWeekColumn.DaxObjectFullName); +string comparisonPctCalcItemFormatStringExpression = @"""+0 %;-0 %;-"""; +CalculationItem comparisonPctCalcItem = calcGroup.AddCalculationItem(comparisonPctCalcItemName, comparisonPctCalcItemExpression); +comparisonPctCalcItem.FormatStringExpression = comparisonPctCalcItemFormatStringExpression; +comparisonPctCalcItem.FormatDax(); +comparisonPctCalcItem.Ordinal = 3; +string daysMeasureExpression = + String.Format( + @"COUNTROWS({0})", + dateTable.DaxObjectFullName); +Measure daysMeasure = dateTable.AddMeasure(name: daysMeasureName, expression: daysMeasureExpression); +daysMeasure.FormatString = @"""0"""; +daysMeasure.FormatDax(); +string referenceDaysRawMeasureExpression = + String.Format( + @"COUNTROWS({0})", + referenceDateTable.DaxObjectFullName); +Measure referenceDaysRawMeasure = referenceDateTable.AddMeasure(name: referenceDaysRawMeasureName, expression: referenceDaysRawMeasureExpression); +referenceDaysRawMeasure.FormatString = @"""0"""; +referenceDaysRawMeasure.FormatDax(); +string referenceDaysMeasureExpression = + String.Format( + @"CALCULATE({0},{1}=""{2}"")", + referenceDaysRawMeasure.DaxObjectFullName, + calcGroupColumn.DaxObjectFullName, + referenceCalcItem.Name); +Measure referenceDaysMeasure = referenceDateTable.AddMeasure(name: referenceDaysMeasureName, expression: referenceDaysMeasureExpression); +referenceDaysMeasure.FormatDax(); +string daysDifferenceMeasureExpression = + String.Format( + @"{0} - {1}", + daysMeasure.DaxObjectFullName, + referenceDaysMeasure.DaxObjectFullName); +Measure differenceDaysMeasure = referenceDateTable.AddMeasure(name: daysDifferenceMeasureName, expression: daysDifferenceMeasureExpression); +differenceDaysMeasure.FormatDax(); +differenceDaysMeasure.FormatString = @"""0"""; + +public static class Fx +{ + public static IEnumerable
GetDateTables(Model model) + { + IEnumerable
dateTables = null as IEnumerable
; + if (model.Tables.Any(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true))) + { + dateTables = model.Tables.Where(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true && c.DataType == DataType.DateTime)); + } + else + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + if(matchTables == null) + { + return null; + } + else + { + return matchTables.First(); + } + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + if (tables.Any(t => lambda(t))) + { + return tables.Where(t => lambda(t)); + } + else + { + return null as IEnumerable
; + } + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + if (columns.Any(c => lambda(c))) + { + return columns.Where(c => lambda(c)); + } + else + { + if(returnAllIfNoneFound) + { + return columns; + } + else + { + return null as IEnumerable; + } + } + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + if(!model.Tables.Any(t => t.Name == tableName)) + { + return model.AddCalculatedTable(tableName, tableExpression); + } + else + { + return model.Tables.Where(t => t.Name == tableName).First(); + } + } + public static string GetNameFromUser(string Prompt, string Title ="", string DefaultResponse = "") + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList) + { + Func, string, string> SelectString = (IList options, string title) => + { + var form = new Form(); + form.Text = title; + var buttonPanel = new Panel(); + buttonPanel.Dock = DockStyle.Bottom; + buttonPanel.Height = 30; + var okButton = new Button() { DialogResult = DialogResult.OK, Text = "OK" }; + var cancelButton = new Button() { DialogResult = DialogResult.Cancel, Text = "Cancel", Left = 80 }; + var listbox = new ListBox(); + listbox.Dock = DockStyle.Fill; + listbox.Items.AddRange(options.ToArray()); + listbox.SelectedItem = options[0]; + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + var result = form.ShowDialog(); + if (result == DialogResult.Cancel) return null; + return listbox.SelectedItem.ToString(); + }; + //let the user select the name of the macro to copy + String select = SelectString(OptionList, "Choose a macro"); + //check that indeed one macro was selected + if (select == null) + { + Info("You cancelled!"); + } + return select; + } +} diff --git a/Advanced/One-Click Macros/Data Problems Button Measures.csx b/Advanced/One-Click Macros/Data Problems Button Measures.csx new file mode 100644 index 0000000..07534da --- /dev/null +++ b/Advanced/One-Click Macros/Data Problems Button Measures.csx @@ -0,0 +1,154 @@ +#r "Microsoft.VisualBasic" +using Microsoft.VisualBasic; + +// '2021-05-26 / B.Agullo / +// '2021-10-13 / B.Agullo / dynamic parameters for one-click operation +// '2022-10-18 / B.Agullo / Bug fixes +// '2024-07-12 / B.Agullo / annotations for execution with "Create Data Problems Button.csx" + +// by Bernat Agulló +// www.esbrina-ba.com +// Instructions: +//select the measures that counts the number of "data problems" the model has and then run the script or as macro +//when adding macro select measure context for execution +// +// ----- do not modify script below this line ----- +// +if (Selected.Measures.Count != 1) +{ + Error("Select one and only one measure"); + return; +}; + +string navigationTableName = Interaction.InputBox("Provide a name for navigation measures table name", "Navigation Table Name", "Navigation", 740, 400); +if(navigationTableName == "") return; + +if(Model.Tables.Any(Table => Table.Name == navigationTableName)) { + Error(navigationTableName + " already exists!"); + return; +}; + +string annotationLabel = "DataProblemsMeasures"; +string annotationValueNavigation = "ButtonNavigationMeasure"; +string annotationValueText = "ButtonTextMeasure"; +string annotationValueBackground = "ButtonBackgroundMeasure"; + +string navigationTableName = Interaction.InputBox("Provide a name for navigation measures table name", "Navigation Table Name", "Navigation", 740, 400); +if (navigationTableName == "") return; +string buttonTextMeasureName = Interaction.InputBox("Name for your button text measure", "Button text measure name", "Button Text", 740, 400); +if (buttonTextMeasureName == "") return; +string buttonTextPattern = Interaction.InputBox("Provide a pattern for your button text", "Button text pattern (# = no. of problems)", "There are # data problems", 740, 400); +if (buttonTextPattern == "") return; +string buttonBackgroundMeasureName = Interaction.InputBox("Name your button background measure", "Button Background Measure", "Button Background", 740, 400); +if (buttonBackgroundMeasureName == "") return; +string buttonNavigationMeasureName = Interaction.InputBox("Name your button navigation measure", "Button Navigation Measure", "Button Navigation", 740, 400); +if (buttonNavigationMeasureName == "") return; +string thereAreDataProblemsMeasureName = Interaction.InputBox("Name your data problems flag measure", "Data problems Flag Measure", "There are Data Problems", 740, 400); +if (thereAreDataProblemsMeasureName == "") return; +string dataProblemsSheetName = Interaction.InputBox("Where are the data problems detail?", "Data problems Sheet", "Data Problems", 740, 400); +if (dataProblemsSheetName == "") return; +// colors will be created if not present +string buttonColorMeasureNameWhenVisible = Interaction.InputBox("What's the color measure name when the button is visible?", "Visible color measure name", "Warning Color", 740, 400); +if(buttonColorMeasureNameWhenVisible == "") return; + +string buttonColorMeasureValueWhenVisible = Interaction.InputBox("What's the color code of " + buttonColorMeasureNameWhenVisible + "?", "Visible color code", "#D64554", 740, 400); +if(buttonColorMeasureValueWhenVisible == "") return; +buttonColorMeasureValueWhenVisible = "\"" + buttonColorMeasureValueWhenVisible + "\""; + +string buttonColorMeasureNameWhenInvisible = Interaction.InputBox("What's the color measure name when button is invisible?", "Invisible color measure name", "Report Background Color", 740, 400); +if(buttonColorMeasureNameWhenInvisible == "") return; + +string buttonColorMeasureValueWhenInvisible = Interaction.InputBox("What's the color code of " + buttonColorMeasureNameWhenInvisible + "?", "Invisible color measure name", "#FFFFFF", 740, 400); +if(buttonColorMeasureValueWhenInvisible == "") return; +buttonColorMeasureValueWhenInvisible = "\"" + buttonColorMeasureValueWhenInvisible + "\""; + + +// prepare array to iterate on new measure names +string[] newMeasureNames = + { + buttonTextMeasureName, + buttonBackgroundMeasureName, + buttonNavigationMeasureName, + thereAreDataProblemsMeasureName + }; +//check none of the new measure names already exist as such +foreach (string measureName in newMeasureNames) +{ + if (Model.AllMeasures.Any(Measure => Measure.Name == measureName)) + { + Error(measureName + " already exists!"); + return; + }; +}; +var dataProblemsMeasure = Selected.Measure; +string navigationTableExpression = + "FILTER({1},[Value] = 0)"; +var navigationTable = + Model.AddCalculatedTable(navigationTableName, navigationTableExpression); +navigationTable.FormatDax(); +navigationTable.Description = + "Table to store the measures for the dynamic button that leads to the data problems sheet"; +navigationTable.IsHidden = true; +if (!Model.AllMeasures.Any(Measure => Measure.Name == buttonColorMeasureNameWhenVisible)) +{ + navigationTable.AddMeasure(buttonColorMeasureNameWhenVisible, buttonColorMeasureValueWhenVisible); +}; +if (!Model.AllMeasures.Any(Measure => Measure.Name == buttonColorMeasureNameWhenInvisible)) +{ + navigationTable.AddMeasure(buttonColorMeasureNameWhenInvisible, "\"#FFFFFF00\""); +}; +string thereAreDataProblemsMeasureExpression = + "[" + dataProblemsMeasure.Name + "]>0"; +var thereAreDataProblemsMeasure = + navigationTable.AddMeasure( + thereAreDataProblemsMeasureName, + thereAreDataProblemsMeasureExpression + ); +thereAreDataProblemsMeasure.FormatDax(); +thereAreDataProblemsMeasure.Description = "Boolean measure, if true, the button leading to data problems sheet should show (internal use only)"; +string buttonBackgroundMeasureExpression = + "VAR colorCode = " + + " IF(" + + " [" + thereAreDataProblemsMeasureName + "]," + + " [" + buttonColorMeasureNameWhenVisible + "]," + + " [" + buttonColorMeasureNameWhenInvisible + "]" + + " )" + + "RETURN " + + " FORMAT(colorCode,\"@\")"; +Measure buttonBackgroundMeasure = + navigationTable.AddMeasure( + buttonBackgroundMeasureName, + buttonBackgroundMeasureExpression + ); +buttonBackgroundMeasure.FormatDax(); +buttonBackgroundMeasure.Description = "Use this measure for conditional formatting of button background"; +buttonBackgroundMeasure.SetAnnotation(annotationLabel, annotationValueBackground); +string buttonNavigationMeasureExpression = + "IF(" + + " [" + thereAreDataProblemsMeasureName + "]," + + " \"" + dataProblemsSheetName + "\"," + + " \"\"" + + ")"; +Measure buttonNavigationMeasure = + navigationTable.AddMeasure( + buttonNavigationMeasureName, + buttonNavigationMeasureExpression + ); +buttonNavigationMeasure.FormatDax(); +buttonNavigationMeasure.Description = "Use this measure for conditional page navigation"; +buttonNavigationMeasure.SetAnnotation(annotationLabel, annotationValueNavigation); +string buttonTextMeasureExpression = + "IF(" + + " [" + thereAreDataProblemsMeasureName + "]," + + " SUBSTITUTE(\"" + buttonTextPattern + "\",\"#\",FORMAT([" + dataProblemsMeasure.Name + "],0))," + + " \"\"" + + ")"; +var buttonTextMeasure = + navigationTable.AddMeasure( + buttonTextMeasureName, + buttonTextMeasureExpression + ); +buttonTextMeasure.FormatDax(); +buttonTextMeasure.Description = "Use this measure for dynamic button text"; +buttonTextMeasure.SetAnnotation(annotationLabel, annotationValueText); +//dataProblemsMeasure.MoveTo(navigationTable); diff --git a/Advanced/One-Click Macros/Duplicate Date Table.csx b/Advanced/One-Click Macros/Duplicate Date Table.csx new file mode 100644 index 0000000..96cbc1f --- /dev/null +++ b/Advanced/One-Click Macros/Duplicate Date Table.csx @@ -0,0 +1,173 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + +using Microsoft.VisualBasic; +string annotationLabel = "createSecondaryDateTable"; +string annotationValue1 = "Main"; +string annotationValue2 = "Secondary"; +Table dateTable = Fx.GetTablesWithAnnotation(Model.Tables, annotationLabel, annotationValue1); +Table dateTable2 = Fx.GetTablesWithAnnotation(Model.Tables, annotationLabel, annotationValue2); +if (dateTable == null || dateTable2 == null) +{ + IEnumerable
dateTables = Fx.GetDateTables(Model); + if (dateTables == null) return; + if (dateTables.Count() != 1) + { + dateTable = SelectTable(dateTables, dateTables.First(), "Select Date table to duplicate"); + if (dateTable == null) return; + } + else + { + dateTable = dateTables.First(); + } + string dateTable2Name = Fx.GetNameFromUser("Secondary Date Table Name", "Name", dateTable.Name + " comparison"); + dateTable2 = Model.AddCalculatedTable(name: dateTable2Name, expression: dateTable.DaxObjectFullName); + dateTable2.DataCategory = dateTable.DataCategory; + var te2 = dateTable2.Columns.Count == 0; + for (int i = 0; i < dateTable2.Columns.Count(); i++) + { + Column c = dateTable.Columns[i]; + Column c2 = te2 ? dateTable2.AddCalculatedColumn(c.Name, String.Format("[Value{0}]", i)) : dateTable2.Columns[i]; + } + dateTable.SetAnnotation(annotationLabel, annotationValue1); + dateTable2.SetAnnotation(annotationLabel, annotationValue2); + Info("Save changes back to the model, recalculate and run again"); +} +else +{ + for (int i = 0; i < dateTable2.Columns.Count(); i++) + { + Column c = dateTable.Columns[i]; + Column c2 = dateTable2.Columns[i]; + c2.IsKey = c.IsKey; + c2.SortByColumn = c.SortByColumn; + } + IEnumerable dateTableRelatioships = + Model.Relationships.Where(r => r.FromTable.Name == dateTable.Name + || r.ToTable.Name == dateTable.Name); + foreach (SingleColumnRelationship r in dateTableRelatioships) + { + SingleColumnRelationship newR = Model.AddRelationship(); + if (r.FromTable.Name == dateTable.Name) + { + newR.FromColumn = dateTable2.Columns[r.FromColumn.Name]; + newR.ToColumn = r.ToColumn; + }else + { + newR.ToColumn = dateTable2.Columns[r.ToColumn.Name]; + newR.FromColumn = r.FromColumn; + } + newR.FromCardinality = r.FromCardinality; + newR.ToCardinality = r.ToCardinality; + newR.CrossFilteringBehavior = r.CrossFilteringBehavior; + newR.IsActive = r.IsActive; + } + Info("Metadata updated"); +} + +public static class Fx +{ + public static IEnumerable
GetDateTables(Model model) + { + IEnumerable
dateTables = null as IEnumerable
; + if (model.Tables.Any(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true))) + { + dateTables = model.Tables.Where(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true && c.DataType == DataType.DateTime)); + } + else + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + if(matchTables == null) + { + return null; + } + else + { + return matchTables.First(); + } + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + if (tables.Any(t => lambda(t))) + { + return tables.Where(t => lambda(t)); + } + else + { + return null as IEnumerable
; + } + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + if (columns.Any(c => lambda(c))) + { + return columns.Where(c => lambda(c)); + } + else + { + if(returnAllIfNoneFound) + { + return columns; + } + else + { + return null as IEnumerable; + } + } + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + if(!model.Tables.Any(t => t.Name == tableName)) + { + return model.AddCalculatedTable(tableName, tableExpression); + } + else + { + return model.Tables.Where(t => t.Name == tableName).First(); + } + } + public static string GetNameFromUser(string Prompt, string Title ="", string DefaultResponse = "") + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList) + { + Func, string, string> SelectString = (IList options, string title) => + { + var form = new Form(); + form.Text = title; + var buttonPanel = new Panel(); + buttonPanel.Dock = DockStyle.Bottom; + buttonPanel.Height = 30; + var okButton = new Button() { DialogResult = DialogResult.OK, Text = "OK" }; + var cancelButton = new Button() { DialogResult = DialogResult.Cancel, Text = "Cancel", Left = 80 }; + var listbox = new ListBox(); + listbox.Dock = DockStyle.Fill; + listbox.Items.AddRange(options.ToArray()); + listbox.SelectedItem = options[0]; + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + var result = form.ShowDialog(); + if (result == DialogResult.Cancel) return null; + return listbox.SelectedItem.ToString(); + }; + //let the user select the name of the macro to copy + String select = SelectString(OptionList, "Choose a macro"); + //check that indeed one macro was selected + if (select == null) + { + Info("You cancelled!"); + } + return select; + } +} diff --git a/Advanced/One-Click Macros/Dynamic Header Field Parameter.csx b/Advanced/One-Click Macros/Dynamic Header Field Parameter.csx new file mode 100644 index 0000000..8adaa95 --- /dev/null +++ b/Advanced/One-Click Macros/Dynamic Header Field Parameter.csx @@ -0,0 +1,613 @@ +#r "Microsoft.VisualBasic" +using Microsoft.VisualBasic; +using System.Windows.Forms; + + +/* '2023-01-26 / B.Agullo / creates a field parameter of measures filtered by calc group and values of a column with a name defined by a measure evaluated in the filtered value and calc item */ + +/* DYNAMIC HEADER FIELD PARAMETER SCRIPT */ + +/* see https://www.esbrina-ba.com/dynamic-headers-in-power-bi-sort-of/ */ +/* select measures and execute, you will need to run it twice */ +/* first time to create aux calc group, second time to actually create measuree*/ +/* remove aux calc group before going to production, do the right thing */ + +string auxCgTag = "@AgulloBernat"; +string auxCgTagValue = "CG to extract format strings"; + +string auxCalcGroupName = "DELETE AUX CALC GROUP"; +string auxCalcItemName = "Get Format String"; + +string baseMeasureAnnotationName = "Base Measure"; +string calcItemAnnotationName = "Calc Item"; +string calcItemSortOrderName = "Sort Order"; +string calcItemSortOrderValue = String.Empty; + +string filterValueAnnotationName = String.Empty; +string dynamicNameAnnotationName = "Dynamic Name"; + + +string scriptAnnotationName = "Script"; +string scriptAnnotationValue = "Create Measures with a Calculation Group " + DateTime.Now.ToString("yyyyMMddHHmmss") ; + +bool generateFieldParameter; + +DialogResult dialogResult = MessageBox.Show("Generate Field Parameter?", "Field Parameter", MessageBoxButtons.YesNo); +generateFieldParameter = (dialogResult == DialogResult.Yes); + +/*check if any measures are selected*/ +if (Selected.Measures.Count == 0) +{ + Error("No measures selected"); + return; +} + +/*find any regular CGs (excluding the one we might have created)*/ +var regularCGs = Model.Tables.Where( + x => x.ObjectType == ObjectType.CalculationGroupTable + & x.GetAnnotation(auxCgTag) != auxCgTagValue); + +if (regularCGs.Count() == 0) +{ + Error("No Calculation Groups Found"); + return; +}; + + + + +//the lambda expression will be avaluated for all calc groups to find a matching calc group +//CalculationGroupTable auxCg = Fx.SelectCalculationGroup(model:Model,lambdaExpression:lambda,selectFirst:true, showErrorIfNoTablesFound:false); + +bool calcGroupWasCreated = false; + +//the calc group will only be created if not found, and when so the boolean will point to it +CalculationGroupTable auxCg = Fx.AddCalculationGroupExt(model: Model, calcGroupWasCreated: out calcGroupWasCreated, + defaultName: auxCalcGroupName, customCalcGroupName: false, annotationName: auxCgTag, annotationValue: auxCgTagValue); + +if (calcGroupWasCreated) +{ + CalculationItem cItem = Fx.AddCalculationItemExt(cg: auxCg, calcItemName: auxCalcItemName, valueExpression: "SELECTEDMEASUREFORMATSTRING()"); + auxCg.IsHidden = true; + + Info("Save changes to the model, recalculate the model, and launch the script again."); + return; +} + +//to avoid showing the aux calc group in the list +Func lambda = (x) => x.GetAnnotation(auxCgTag) != auxCgTagValue; + +CalculationGroupTable regularCg = Fx.SelectCalculationGroup(model: Model, lambdaExpression: lambda); +if (regularCg == null) return; + + +Table filterTable = Fx.SelectTableExt(model: Model, excludeCalcGroups: true, label:"Select table of filter field",showErrorIfNoSelection:true); +if(filterTable == null) return; +Column filterColumn = SelectColumn(filterTable,label:"Select filter Field"); +if (filterColumn == null) return; + +filterValueAnnotationName = filterColumn.Name; + +String filterQuery = String.Format("EVALUATE DISTINCT({0})", filterColumn.DaxObjectFullName); + +List filterValues = new List(); + +using (var filterReader = Model.Database.ExecuteReader(filterQuery)) +{ + + while (filterReader.Read()) + { + + filterValues.Add(filterReader.GetValue(0).ToString()); + } +} + +string name = String.Empty; +if (generateFieldParameter) +{ + name = Interaction.InputBox("Provide a name for the field parameter", "Field Parameter", regularCg.Name + " Measures", 740, 400); + if (name == "") { Error("Execution Aborted"); return; }; +}; + +Measure dynamicNameMeasure = SelectMeasure(label: "Select measure for dynamic name, cancel if none"); + + +/*iterates through each selected measure*/ +foreach (Measure m in Selected.Measures) +{ + /*check that base measure has a proper format string*/ + if (m.FormatString == "") + { + Error("Define FormatString for " + m.Name + " and try again"); + return; + }; + + /*prepares a displayfolder to store all new measures*/ + string displayFolderName = m.Name + " Measures"; + + /*iterates thorough all calculation items of the selected calc group*/ + foreach (CalculationItem calcItem in regularCg.CalculationItems) + { + + string measureNamePrefix = string.Concat(Enumerable.Repeat("\u200B", calcItem.Ordinal)); + + foreach (string filterValue in filterValues) + { + + + + /*measure name*/ + string measureName = measureName = m.Name + " " + calcItem.Name + " " + filterValue; + + string dynamicMeasureName = String.Empty; + + if (dynamicNameMeasure == null) + { + dynamicMeasureName = measureName; + } + else + { + + string measureNameQuery = String.Empty; + + if (filterColumn.DataType == DataType.String) + { + + measureNameQuery = + String.Format("EVALUATE {{CALCULATE({0},{1}=\"{2}\",{3}=\"{4}\") & \"\"}}", + dynamicNameMeasure.DaxObjectFullName, + filterColumn.DaxObjectFullName, + filterValue, + regularCg.Columns[0].DaxObjectFullName, + calcItem.Name); + } + else + { + measureNameQuery = + String.Format("EVALUATE {{CALCULATE({0},{1}={2},{3}=\"{4}\") & \"\"}}", + dynamicNameMeasure.DaxObjectFullName, + filterColumn.DaxObjectFullName, + filterValue, + regularCg.Columns[0].DaxObjectFullName, + calcItem.Name); + } + + + + using (var reader = Model.Database.ExecuteReader(measureNameQuery)) + { + while (reader.Read()) + { + dynamicMeasureName = reader.GetString(0).ToString(); + + } + } + + dynamicMeasureName = m.Name + " " + measureNamePrefix + dynamicMeasureName; + + + } + + + + //only if the measure is not yet there (think of reruns) + if (!Model.AllMeasures.Any(x => x.Name == measureName)) + { + + /*prepares a query to calculate the resulting format when applying the calculation item on the measure*/ + string query = string.Format( + "EVALUATE {{CALCULATE({0},{1},{2})}}", + m.DaxObjectFullName, + string.Format( + "{0}=\"{1}\"", + regularCg.Columns[0].DaxObjectFullName, + calcItem.Name), + string.Format( + "{0}=\"{1}\"", + auxCg.Columns[0].DaxObjectFullName, + auxCalcItemName) + ); + + /*executes the query*/ + using (var reader = Model.Database.ExecuteReader(query)) + { + // resultset should contain just one row, with the format string + while (reader.Read()) + { + + + /*retrive the formatstring from the query*/ + string formatString = reader.GetValue(0).ToString(); + + + + + + + /*build the expression of the measure*/ + string measureExpression = String.Empty; + + if(filterColumn.DataType == DataType.String) + { + measureExpression = string.Format( + "CALCULATE({0},{1}=\"{2}\",KEEPFILTERS({3}=\"{4}\"))", + m.DaxObjectName, + regularCg.Columns[0].DaxObjectFullName, + calcItem.Name, + filterColumn.DaxObjectFullName, + filterValue + ); + } + else + { + measureExpression = string.Format( + "CALCULATE({0},{1}=\"{2}\",KEEPFILTERS({3}={4}))", + m.DaxObjectName, + regularCg.Columns[0].DaxObjectFullName, + calcItem.Name, + filterColumn.DaxObjectFullName, + filterValue + ); + } + + + + + + + + /*actually build the measure*/ + Measure newMeasure = + m.Table.AddMeasure( + name: measureName, + expression: measureExpression); + + + /*the all important format string!*/ + newMeasure.FormatString = formatString; + + /*final polish*/ + newMeasure.DisplayFolder = displayFolderName; + newMeasure.FormatDax(); + + /*add annotations for the creation of the field parameter*/ + newMeasure.SetAnnotation(baseMeasureAnnotationName, m.Name); + newMeasure.SetAnnotation(calcItemAnnotationName, calcItem.Name); + newMeasure.SetAnnotation(scriptAnnotationName, scriptAnnotationValue); + newMeasure.SetAnnotation(calcItemSortOrderName, calcItem.Ordinal.ToString("000")); + newMeasure.SetAnnotation(filterValueAnnotationName, filterValue); + newMeasure.SetAnnotation(dynamicNameAnnotationName, dynamicMeasureName); + + + } + } + } + } + + + } +} + + +if (!generateFieldParameter) +{ + //end of execution + return; +}; + + +// Before running the script, select the measures or columns that you +// would like to use as field parameters (hold down CTRL to select multiple +// objects). Also, you may change the name of the field parameter table +// below. NOTE: If used against Power BI Desktop, you must enable unsupported +// features under File > Preferences (TE2) or Tools > Preferences (TE3). + + +if (Selected.Columns.Count == 0 && Selected.Measures.Count == 0) throw new Exception("No columns or measures selected!"); + +// Construct the DAX for the calculated table based on the measures created previously by the script +var objects = Model.AllMeasures + .Where(x => x.GetAnnotation(scriptAnnotationName) == scriptAnnotationValue) + .OrderBy(x => x.GetAnnotation(baseMeasureAnnotationName) + x.GetAnnotation(calcItemSortOrderName)); + +var dax = "{\n " + string.Join(",\n ", objects.Select((c, i) => string.Format("(\"{6}\", NAMEOF('{1}'[{0}]), {2},\"{3}\",\"{4}\",\"{5}\")", + c.Name, c.Table.Name, i, + Model.Tables[c.Table.Name].Measures[c.Name].GetAnnotation(baseMeasureAnnotationName), + Model.Tables[c.Table.Name].Measures[c.Name].GetAnnotation(calcItemAnnotationName), + Model.Tables[c.Table.Name].Measures[c.Name].GetAnnotation(filterValueAnnotationName), + Model.Tables[c.Table.Name].Measures[c.Name].GetAnnotation(dynamicNameAnnotationName) + ))) + "\n}"; + +// Add the calculated table to the model: +var table = Model.AddCalculatedTable(name, dax); + +// In TE2 columns are not created automatically from a DAX expression, so +// we will have to add them manually: +var te2 = table.Columns.Count == 0; +var nameColumn = te2 ? table.AddCalculatedTableColumn(name, "[Value1]") : table.Columns["Value1"] as CalculatedTableColumn; +var fieldColumn = te2 ? table.AddCalculatedTableColumn(name + " Fields", "[Value2]") : table.Columns["Value2"] as CalculatedTableColumn; +var orderColumn = te2 ? table.AddCalculatedTableColumn(name + " Order", "[Value3]") : table.Columns["Value3"] as CalculatedTableColumn; + +if (!te2) +{ + // Rename the columns that were added automatically in TE3: + nameColumn.IsNameInferred = false; + nameColumn.Name = name; + fieldColumn.IsNameInferred = false; + fieldColumn.Name = name + " Fields"; + orderColumn.IsNameInferred = false; + orderColumn.Name = name + " Order"; +} +// Set remaining properties for field parameters to work +// See: https://twitter.com/markbdi/status/1526558841172893696 +nameColumn.SortByColumn = orderColumn; +nameColumn.GroupByColumns.Add(fieldColumn); +fieldColumn.SortByColumn = orderColumn; +fieldColumn.SetExtendedProperty("ParameterMetadata", "{\"version\":3,\"kind\":2}", ExtendedPropertyType.Json); +fieldColumn.IsHidden = true; +orderColumn.IsHidden = true; + + +public static class Fx +{ + + + + + //in TE2 (at least up to 2.17.2) any method that accesses or modifies the model needs a reference to the model + //the following is an example method where you can build extra logic + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.AddCalculatedTable(name:tableName,expression:tableExpression); + } + + public static Table SelectTableExt(Model model, string possibleName = null, string annotationName = null, string annotationValue = null, + Func lambdaExpression = null, string label = "Select Table", bool skipDialogIfSingleMatch = true, bool showOnlyMatchingTables = true, + IEnumerable
candidateTables = null, bool showErrorIfNoTablesFound = false, string errorMessage = "No tables found", bool selectFirst = false, + bool showErrorIfNoSelection = true, string noSelectionErrorMessage = "No table was selected", bool excludeCalcGroups = false,bool returnNullIfNoTablesFound = false) + { + + Table table = null as Table; + + if (lambdaExpression == null) + { + if (possibleName != null) { + lambdaExpression = (t) => t.Name == possibleName; + } else if(annotationName!= null && annotationValue != null) + { + lambdaExpression = (t) => t.GetAnnotation(annotationName) == annotationValue; + } + else + { + lambdaExpression = (t) => true; //no filtering + } + } + + //use candidateTables if passed as argument + IEnumerable
tables = null as IEnumerable
; + + if(candidateTables != null) + { + tables = candidateTables; + } + else + { + tables = model.Tables; + } + + if(lambdaExpression != null) + { + tables = tables.Where(lambdaExpression); + } + + if (excludeCalcGroups) + { + tables = tables.Where(t => t.ObjectType != ObjectType.CalculationGroupTable); + } + + //none found, let the user choose from all tables + if (tables.Count() == 0) + { + + if (returnNullIfNoTablesFound) + { + if (showErrorIfNoTablesFound) Error(errorMessage); + return table; + } + else + { + + table = SelectTable(tables: model.Tables, label: label); + } + + } + else if (tables.Count() == 1 && !skipDialogIfSingleMatch) + { + + table = SelectTable(tables: model.Tables, preselect: tables.First(), label: label); + } + else if (tables.Count() == 1 && skipDialogIfSingleMatch) + { + table = tables.First(); + } + else if (tables.Count() > 1) + + { + if (selectFirst) + { + table = tables.First(); + } + else if (showOnlyMatchingTables) + { + + table = SelectTable(tables: tables, preselect: tables.First(), label: label); + } + else + { + + table = SelectTable(tables: model.Tables, preselect: tables.First(), label: label); + } + + } + else + { + Error(@"Unexpected logic in ""SelectTableExt"""); + return null; + } + + if(showErrorIfNoSelection && table == null) + { + Error(noSelectionErrorMessage); + } + + return table; + + } + + + public static CalculationGroupTable SelectCalculationGroup(Model model, string possibleName = null, string annotationName = null, string annotationValue = null, + Func lambdaExpression = null, string label = "Select Table", bool skipDialogIfSingleMatch = true, bool showOnlyMatchingTables = true, + bool showErrorIfNoTablesFound = true, string errorMessage = "No calculation groups found",bool selectFirst = false, + bool showErrorIfNoSelection = true, string noSelectionErrorMessage = "No calculation group was selected", bool returnNullIfNoTablesFound = false) + { + + CalculationGroupTable calculationGroupTable = null as CalculationGroupTable; + + Func lambda = (x) => x.ObjectType == ObjectType.CalculationGroupTable; + if (!model.Tables.Any(lambda)) return calculationGroupTable; + + IEnumerable
tables = model.Tables.Where(lambda); + + Table table = Fx.SelectTableExt( + model:model, + possibleName:possibleName, + annotationName:annotationName, + annotationValue:annotationValue, + lambdaExpression:lambdaExpression, + label:label, + skipDialogIfSingleMatch:skipDialogIfSingleMatch, + showOnlyMatchingTables:showOnlyMatchingTables, + showErrorIfNoTablesFound:showErrorIfNoTablesFound, + errorMessage:errorMessage, + selectFirst:selectFirst, + showErrorIfNoSelection:showErrorIfNoSelection, + noSelectionErrorMessage:noSelectionErrorMessage, + returnNullIfNoTablesFound:returnNullIfNoTablesFound, + candidateTables:tables); + + if(table == null) return calculationGroupTable; + + calculationGroupTable = table as CalculationGroupTable; + + return calculationGroupTable; + + } + + public static CalculationGroupTable AddCalculationGroupExt(Model model, out bool calcGroupWasCreated, string defaultName = "New Calculation Group", + string annotationName = null, string annotationValue = null, bool createOnlyIfNotFound = true, + string prompt = "Name", string Title = "Provide a name for the Calculation Group", bool customCalcGroupName = true) + { + + Func lambda = null as Func; + CalculationGroupTable cg = null as CalculationGroupTable; + calcGroupWasCreated = false; + string calcGroupName = String.Empty; + + if (createOnlyIfNotFound) + { + + if (annotationName == null && annotationValue == null) + { + + if (customCalcGroupName) + { + calcGroupName = Interaction.InputBox(Prompt: "Name", Title: "Provide a name for the Calculation Group"); + } + else + { + calcGroupName = defaultName; + } + + cg = Fx.SelectCalculationGroup(model: model, possibleName: calcGroupName, showErrorIfNoTablesFound: false, selectFirst: true); + + } + else + { + cg = Fx.SelectCalculationGroup(model: model, + showErrorIfNoTablesFound: false, + annotationName: annotationName, + annotationValue: annotationValue, + returnNullIfNoTablesFound: true); + } + + if (cg != null) return cg; + } + + if (calcGroupName == String.Empty) + { + if (customCalcGroupName) + { + calcGroupName = Interaction.InputBox(Prompt: "Name", Title: "Provide a name for the Calculation Group"); + } + else + { + calcGroupName = defaultName; + } + } + + cg = model.AddCalculationGroup(name: calcGroupName); + + if (annotationName != null && annotationValue != null) + { + cg.SetAnnotation(annotationName,annotationValue); + } + + calcGroupWasCreated = true; + + return cg; + + } + + public static CalculationItem AddCalculationItemExt(CalculationGroupTable cg, string calcItemName, string valueExpression = "SELECTEDMEASURE()", + string formatStringExpression = "", bool createOnlyIfNotFound = true, bool rewriteIfFound = false) + { + + CalculationItem calcItem = null as CalculationItem; + + Func lambda = (ci) => ci.Name == calcItemName; + + if(createOnlyIfNotFound) + { + if (cg.CalculationItems.Any(lambda)) + { + + calcItem = cg.CalculationItems.Where(lambda).FirstOrDefault(); + + if (!rewriteIfFound) + { + return calcItem; + } + } + } + + + if(calcItem == null) + { + calcItem = cg.AddCalculationItem(name: calcItemName, expression: valueExpression); + } + else + { + //rewrite the found calcItem + calcItem.Expression = valueExpression; + } + + if(formatStringExpression != String.Empty) + { + calcItem.FormatStringExpression = formatStringExpression; + } + + return calcItem; + + } + +} \ No newline at end of file diff --git a/Advanced/One-Click Macros/Generate Calc Group to Sort a Matrix by a Calc Item Column.csx b/Advanced/One-Click Macros/Generate Calc Group to Sort a Matrix by a Calc Item Column.csx new file mode 100644 index 0000000..59ff52b --- /dev/null +++ b/Advanced/One-Click Macros/Generate Calc Group to Sort a Matrix by a Calc Item Column.csx @@ -0,0 +1,61 @@ +//'2023-07-27 / B.Agullo / +// Generate Calc Group to Sort a Matrix by a Calc Item Column +// Automation of the Calculation group introduced in this blog post: https://www.esbrina-ba.com/sorting-a-matrix-by-a-calculation-item-column/ +// this script was written live in the Seattle Modern Excel & Power BI User Group on July 26th 2023. +// To execute select a calculation group table and click execute +// It es recommented to store as macro for Calculation Group Table +string noSortCalcItemName = "No Sort"; +if (Selected.Tables.Count != 1) +{ + Error("Please select a single calculation group and try again."); + return; +} +if (Selected.Table.ObjectTypeName != "Calculation Group Table") +{ + Error("This is not a calculation group"); + return; +} +CalculationGroupTable calculationGroupTable = + Selected.Table as CalculationGroupTable; +calculationGroupTable.AddCalculationItem(noSortCalcItemName, "1"); +string calcTableName = calculationGroupTable.Name + " Names"; +string calcTableExpression = calculationGroupTable.DaxObjectFullName; +CalculatedTable calculatedTable = + Model.AddCalculatedTable(calcTableName, calcTableExpression); +bool te2 = (calculatedTable.Columns.Count == 0); +Column firstCalcTableColumn = + te2 ? calculatedTable.AddCalculatedColumn( + calculationGroupTable.Columns[0].Name, "1") + : calculatedTable.Columns[0]; +string sortCalcGroupName = "Sort"; +string sortCalcItemExpression = + String.Format(@" + VAR inTotal = + NOT HASONEVALUE ( {0} ) + VAR sortBy = + SELECTEDVALUE ( {1}, ""{2}"" ) + VAR result = + IF ( + inTotal, + CALCULATE ( + SELECTEDMEASURE (), + {0} = sortBy + ), + SELECTEDMEASURE () + ) + RETURN + result", + calculationGroupTable.Columns[0].DaxObjectFullName, + calculatedTable.Columns[0].DaxObjectFullName, + noSortCalcItemName); +CalculationGroupTable sortCalcGroup = + Model.AddCalculationGroup(sortCalcGroupName); +CalculationItem sortCalcItem = + sortCalcGroup.AddCalculationItem( + sortCalcGroupName, + sortCalcItemExpression); +sortCalcItem.FormatDax(); +if (te2) +{ + calculatedTable.Columns[0].Delete(); +} diff --git a/Advanced/One-Click Macros/Multi-Total Calc Group (add total script).csx b/Advanced/One-Click Macros/Multi-Total Calc Group (add total script).csx new file mode 100644 index 0000000..59d8d08 --- /dev/null +++ b/Advanced/One-Click Macros/Multi-Total Calc Group (add total script).csx @@ -0,0 +1,70 @@ +string calcGroupTypeLabel = "CalcGroupType"; +string calcGroupTypeValue = "MultiTotal"; +IEnumerable
multiTotalCalcGroups = + Model.Tables.Where( + t => + t.GetAnnotation(calcGroupTypeLabel) + == calcGroupTypeValue); +Table calcGroupAsTable = null as Table; +if(multiTotalCalcGroups.Count() == 0) +{ + Error("No multi-total calc group found. " + + "Run the macro to create a multi-total " + + "calc group first and try again"); + return; +} else if(multiTotalCalcGroups.Count() == 1) +{ + calcGroupAsTable = multiTotalCalcGroups.First(); +} +else +{ + calcGroupAsTable = SelectTable(multiTotalCalcGroups, label: "Select Multi-total Calc Group to use"); + if(calcGroupAsTable == null) + { + Error("You cancelled the execution."); + return; + } +} +if(Selected.CalculationItems.Count() == 0) +{ + Error("Select one or more calculation items and try again."); + return; +} +string calcGroupValuesFieldLabel = "ValuesField"; +string multiTotalBreakDownColumnCode = calcGroupAsTable.GetAnnotation(calcGroupValuesFieldLabel); +CalculationGroupTable calcGroup = calcGroupAsTable as CalculationGroupTable; +foreach(CalculationItem calcItem in Selected.CalculationItems) +{ + string calcItemName = calcItem.Name; + string calcItemExpression = + String.Format( + @"IF( + NOT ISINSCOPE( {0} ), + CALCULATE( + SELECTEDMEASURE( ), + {1} = ""{2}"" + ) + )", + multiTotalBreakDownColumnCode, + calcItem.CalculationGroupTable.Columns[0].DaxObjectFullName, + calcItem.Name); + CalculationItem customTotalCalcItem = + calcGroup.AddCalculationItem( + name:calcItemName, + expression:calcItemExpression); + string calcItemFormatStringExpression = + String.Format( + @"IF( + NOT ISINSCOPE( {0} ), + CALCULATE( + SELECTEDMEASUREFORMATSTRING( ), + {1} = ""{2}"" + ) + )", + multiTotalBreakDownColumnCode, + calcItem.CalculationGroupTable.Columns[0].DaxObjectFullName, + calcItem.Name); + customTotalCalcItem.FormatStringExpression = + calcItemFormatStringExpression; + customTotalCalcItem.FormatDax(); +} diff --git a/Advanced/One-Click Macros/Multi-Total Calc Group (base script).csx b/Advanced/One-Click Macros/Multi-Total Calc Group (base script).csx new file mode 100644 index 0000000..4e6b0c1 --- /dev/null +++ b/Advanced/One-Click Macros/Multi-Total Calc Group (base script).csx @@ -0,0 +1,73 @@ +// '2023-07-09 / B.Agullo / +// +// Multi-Total Calc Group: Base script +// +// The development of this script is shown here https://www.esbrina-ba.com/industrializing-calculation-groups/ +// store as macro and select only column as target object +// to use the right click on a single column you want to use to slice your data from the columns section of your matrix. +// to add custom totals to the matrix using this calc group as top level column field, you need to run +// the script shared in the file "Multi-Total Calc Group (add total script).csx" in this same repository folder. +// +// Follow Bernat on LinkedIn and Twitter +// https://www.linkedin.com/in/bernatagullo/ +// https://twitter.com/AgulloBernat + +#r "Microsoft.VisualBasic" +using Microsoft.VisualBasic; + +if (Selected.Columns.Count() != 1) +{ + Error("Select only 1 column and try again"); + return; +} +Column column = Selected.Column; +string suggestedCalcGroupName = column.Name + " Multi-Totals"; +string calcGroupName = Interaction.InputBox( + Prompt:"Please provide the name of the multi-total calc group.", + DefaultResponse:suggestedCalcGroupName); +if (calcGroupName == "") +{ + Error("No name provided"); + return; +}; +CalculationGroupTable calcGroup = + Model.AddCalculationGroup( + calcGroupName); +string valuesCalcItemName = "Values"; +string valuesCalcItemExpression = + String.Format( + @"IF( + ISINSCOPE( {0} ), + SELECTEDMEASURE() + )", column.DaxObjectFullName); +CalculationItem valuesCalcItem = + calcGroup.AddCalculationItem( + name: valuesCalcItemName, + expression: valuesCalcItemExpression); +valuesCalcItem.FormatDax(); +valuesCalcItem.Description = "This calculation item is to show the breakdown by " + column.Name; +valuesCalcItem.Ordinal = 0; +string totalCalcItemName = "Total"; +string totalCalcItemExpression = + String.Format( + @"IF( + NOT ISINSCOPE( {0} ), + SELECTEDMEASURE() + )", column.DaxObjectFullName); +CalculationItem totalCalcItem = + calcGroup.AddCalculationItem( + name: totalCalcItemName, + expression: totalCalcItemExpression); +totalCalcItem.FormatDax(); +totalCalcItem.Description = "This calculation item is to show the regular total as a calculation item along with different totals that will be added to this calculation group"; +totalCalcItem.Ordinal = 1; +string calcGroupTypeLabel = "CalcGroupType"; +string calcGroupTypeValue = "MultiTotal"; +calcGroup.SetAnnotation( + calcGroupTypeLabel, + calcGroupTypeValue); +string calcGroupValuesFieldLabel = "ValuesField"; +string calcGroupValuesFieldValue = column.DaxObjectFullName; +calcGroup.SetAnnotation( + calcGroupValuesFieldLabel, + calcGroupValuesFieldValue); diff --git a/Advanced/One-Click Macros/Number Format Calc Group.csx b/Advanced/One-Click Macros/Number Format Calc Group.csx new file mode 100644 index 0000000..b235cac --- /dev/null +++ b/Advanced/One-Click Macros/Number Format Calc Group.csx @@ -0,0 +1,113 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + +using Microsoft.VisualBasic; +string cgAnnotationLabel = "MadeWith"; +string cgAnnotationValue = "NumberFormatCalcGroup"; +if (Selected.Measures.Count == 0) +{ + Error("Select one or more measures and try again"); + return; +}; +string measureList = string.Join(",", Selected.Measures.Select(x => x.DaxObjectFullName)); +string measureListName = string.Join(",", Selected.Measures.Select(x => x.Name)); +CalculationGroupTable cg = null as CalculationGroupTable; +if (Model.Tables.Any(t => t.GetAnnotation(cgAnnotationLabel) == cgAnnotationValue)) +{ + cg = (CalculationGroupTable) Model.Tables.Where(t => t.GetAnnotation(cgAnnotationLabel) == cgAnnotationValue).First(); +} +else +{ + string calcGroupName = Fx.GetNameFromUser("Choose name for the number format Calculation Group", "Atention", "Number Format"); + if (calcGroupName == "") return; // in case user cancelled + cg = Model.AddCalculationGroup(name: calcGroupName); + cg.Columns[0].Name = cg.Name; + cg.SetAnnotation(cgAnnotationLabel, cgAnnotationValue); +} +List formatList = new List(); +formatList.Add("in milions"); +formatList.Add("in thousands"); +string selectedFormat = Fx.ChooseString(formatList); +if (selectedFormat == null) return; +string formatString = ""; +switch (selectedFormat) +{ + case "in milions": + // code block + formatString = @"""#,##0,,.0"""; + break; + case "in thousands": + // code block + formatString = @"""#,##0,.0"""; + break; + default: + // code block + break; +} +string ciValueExpression = "SELECTEDMEASURE()"; +string ciFormatStringExpression = + string.Format( + @"IF( + ISSELECTEDMEASURE({0}), + {1}, + SELECTEDMEASUREFORMATSTRING() + )", + measureList, + formatString + ); +string ciName = string.Format("{1} ({0})", measureListName, selectedFormat); +CalculationItem ci = cg.AddCalculationItem(name:ciName ,expression:ciValueExpression); +ci.FormatStringExpression = ciFormatStringExpression; +ci.FormatDax(); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + if(!model.Tables.Any(t => t.Name == tableName)) + { + return model.AddCalculatedTable(tableName, tableExpression); + } + else + { + return model.Tables.Where(t => t.Name == tableName).First(); + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList) + { + Func, string, string> SelectString = (IList options, string title) => + { + var form = new Form(); + form.Text = title; + var buttonPanel = new Panel(); + buttonPanel.Dock = DockStyle.Bottom; + buttonPanel.Height = 30; + var okButton = new Button() { DialogResult = DialogResult.OK, Text = "OK" }; + var cancelButton = new Button() { DialogResult = DialogResult.Cancel, Text = "Cancel", Left = 80 }; + var listbox = new ListBox(); + listbox.Dock = DockStyle.Fill; + listbox.Items.AddRange(options.ToArray()); + listbox.SelectedItem = options[0]; + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + var result = form.ShowDialog(); + if (result == DialogResult.Cancel) return null; + return listbox.SelectedItem.ToString(); + }; + //let the user select the name of the macro to copy + String select = SelectString(OptionList, "Choose a macro"); + //check that indeed one macro was selected + if (select == null) + { + Info("You cancelled!"); + } + return select; + } +} diff --git a/Advanced/One-Click Macros/Referential Integrity Check Measures.csx b/Advanced/One-Click Macros/Referential Integrity Check Measures.csx index 492239e..40b5a3a 100644 --- a/Advanced/One-Click Macros/Referential Integrity Check Measures.csx +++ b/Advanced/One-Click Macros/Referential Integrity Check Measures.csx @@ -1,26 +1,47 @@ -//Select the desired table to store all data quality measures - - -string overallCounterExpression = ""; +// 2023-02-15 / B.Agulló / Added useRelationship in the expression to check also inactive relationships +// 2023-12-22 / B.Agulló / Added suggestions by Ed Hansberry +// 2024-07-13 / B.Agulló / Add annotations for the report-layer script, possible to execute without selected table, subtitles measures +// +// Instructions: +// Select the desired table to store all data quality measures +// or execute just on the model and a new table will be created for you +// See https://www.esbrina-ba.com/easy-management-of-referential-integrity/ +// +// To create the report sheet check out: +// https://www.esbrina-ba.com/building-a-referential-integrity-report-page-with-a-c-script/ + +//change the resulting variable names if you want string overallCounterName = "Total Unmapped Items"; - -string overallDetailExpression = "\"\""; string overallDetailName = "Data Problems"; - -Table tableToStoreMeasures = Selected.Tables.First(); - +string targetTableNameIfCreated = "Referential Integrity"; +//do not modify the script below +string overallCounterExpression = ""; +string overallDetailExpression = "\"\""; +string annLabel = "ReferencialIntegrityMeasures"; +string annValueTotal = "TotalUnmappedItems"; +string annValueDetail = "DataProblems"; +string annValueDataQualityMeasures = "DataQualityMeasure"; +string annValueDataQualityTitles = "DataQualityTitle"; +string annValueDataQualitySubtitles = "DataQualitySubitle"; +string annValueFactColumn = "FactColumn"; +Table tableToStoreMeasures = null as Table; +if (Selected.Tables.Count() == 0) +{ + tableToStoreMeasures = Model.AddCalculatedTable(targetTableNameIfCreated, "{0}"); +} +else +{ + tableToStoreMeasures = Selected.Tables.First(); +} +int measureIndex = 0; foreach (var r in Model.Relationships) { - - bool isOneToMany = r.FromCardinality == RelationshipEndCardinality.One - & r.ToCardinality == RelationshipEndCardinality.Many; - + && r.ToCardinality == RelationshipEndCardinality.Many; bool isManyToOne = r.FromCardinality == RelationshipEndCardinality.Many - & r.ToCardinality == RelationshipEndCardinality.One; - + && r.ToCardinality == RelationshipEndCardinality.One; Column manyColumn = null as Column; Column oneColumn = null as Column; bool isOneToManyOrManyToOne = true; @@ -28,7 +49,6 @@ foreach (var r in Model.Relationships) { manyColumn = r.ToColumn; oneColumn = r.FromColumn; - } else if (isManyToOne) { @@ -39,39 +59,52 @@ foreach (var r in Model.Relationships) { isOneToManyOrManyToOne = false; } - if (isOneToManyOrManyToOne) { - + measureIndex++; //increment index + //add measure counting how many different items in the fact table are not present in the dimension string orphanCountExpression = "CALCULATE(" + "SUMX(VALUES(" + manyColumn.DaxObjectFullName + "),1)," - + oneColumn.DaxObjectFullName + " = BLANK()" + + "ISBLANK(" + oneColumn.DaxObjectFullName + ")," + + "USERELATIONSHIP(" + manyColumn.DaxObjectFullName + "," + oneColumn.DaxObjectFullName + ")," + + "ALLEXCEPT(" + manyColumn.Table.DaxObjectFullName + "," + manyColumn.DaxObjectFullName + ")" + ")"; string orphanMeasureName = manyColumn.Name + " not mapped in " + manyColumn.Table.Name; - - Measure newCounter = tableToStoreMeasures.AddMeasure(name: orphanMeasureName, expression: orphanCountExpression,displayFolder:"_Data quality Measures"); - newCounter.FormatDax(); - - string orphanTableTitleMeasureExpression = newCounter.DaxObjectFullName + " & \" " + newCounter.Name + "\""; + Measure newCounter = tableToStoreMeasures.AddMeasure(name: orphanMeasureName, expression: orphanCountExpression, displayFolder: "_Data quality Measures"); + newCounter.FormatString = "#,##0"; + newCounter.FormatDax(); + newCounter.SetAnnotation(annLabel, annValueDataQualityMeasures + "_" + measureIndex.ToString()); + //add annotation to trace back the fact table when building the report + manyColumn.SetAnnotation(annLabel, annValueFactColumn + "_" + measureIndex.ToString()); + //add measure saying how many are not mapped in the fact table + string orphanTableTitleMeasureExpression = "FORMAT(" + newCounter.DaxObjectFullName +"+0,\"" + newCounter.FormatString + "\") & \" " + newCounter.Name + "\""; string orphanTableTitleMeasureName = newCounter.Name + " Title"; - Measure newTitle = tableToStoreMeasures.AddMeasure(name: orphanTableTitleMeasureName, expression: orphanTableTitleMeasureExpression, displayFolder: "_Data quality Titles"); newTitle.FormatDax(); - + newTitle.SetAnnotation(annLabel, annValueDataQualityTitles + "_" + measureIndex.ToString()); + //add measure for subtitle saying how many need to be added to the dimension table + string orphanTableSubtitleMeasureExpression = + String.Format( + @"FORMAT({0}+0,""{1}"") & "" values missing in "" & ""{2}""", + newCounter.DaxObjectFullName, + newCounter.FormatString, + oneColumn.Table.Name); + string orphanTableSubitleMeasureName = newCounter.Name + " Subtitle"; + Measure newSubtitle = tableToStoreMeasures.AddMeasure(name: orphanTableSubitleMeasureName, expression: orphanTableSubtitleMeasureExpression, displayFolder: "_Data quality Subtitles"); + newSubtitle.FormatDax(); + newSubtitle.SetAnnotation(annLabel, annValueDataQualitySubtitles + "_" + measureIndex.ToString()); overallCounterExpression = overallCounterExpression + "+" + newCounter.DaxObjectFullName; overallDetailExpression = overallDetailExpression + " & IF(" + newCounter.DaxObjectFullName + "> 0," + newTitle.DaxObjectFullName + " & UNICHAR(10))"; - }; - }; - Measure counter = tableToStoreMeasures.AddMeasure(name: overallCounterName, expression: overallCounterExpression); +counter.FormatString = "#,##0"; counter.FormatDax(); - - +counter.SetAnnotation(annLabel, annValueTotal); Measure descr = tableToStoreMeasures.AddMeasure(name: overallDetailName, expression: overallDetailExpression); -descr.FormatDax(); \ No newline at end of file +descr.FormatDax(); +descr.SetAnnotation(annLabel, annValueDetail); diff --git a/Advanced/One-Click Macros/Time Intelligence Calculation Group Creation.csx b/Advanced/One-Click Macros/Time Intelligence Calculation Group Creation.csx index ead43f1..37232a3 100644 --- a/Advanced/One-Click Macros/Time Intelligence Calculation Group Creation.csx +++ b/Advanced/One-Click Macros/Time Intelligence Calculation Group Creation.csx @@ -1,5 +1,6 @@ #r "Microsoft.VisualBasic" using Microsoft.VisualBasic; + // // CHANGELOG: // '2021-05-01 / B.Agullo / @@ -9,6 +10,8 @@ using Microsoft.VisualBasic; // '2021-09-23 / B.Agullo / added code to prompt for parameters (code credit to Daniel Otykier) // '2021-09-27 / B.Agullo / added code for general name // '2022-10-11 / B.Agullo / added MMT and MWT calc item groups +// '2023-01-24 / B.Agullo / added Date Range Measure and completed dynamic label for existing items +// '2025-05-14 / B.Agullo / some refactoring + month based standard calculations // // by Bernat Agulló // twitter: @AgulloBernat @@ -22,658 +25,828 @@ using Microsoft.VisualBasic; // // THANKS: // shout out to Johnny Winter for the base script and SQLBI for daxpatterns.com - //select the measures that you want to be affected by the calculation group //before running the script. //measure names can also be included in the following array (no need to select them) -string[] preSelectedMeasures = {}; //include measure names in double quotes, like: {"Profit","Total Cost"}; - +string[] preSelectedMeasures = { }; //include measure names in double quotes, like: {"Profit","Total Cost"}; //AT LEAST ONE MEASURE HAS TO BE AFFECTED!, //either by selecting it or typing its name in the preSelectedMeasures Variable - - - // // ----- do not modify script below this line ----- // - - string affectedMeasures = "{"; - -int i = 0; - -for (i=0;i cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group")) { +string calcGroupName = String.Empty; +string columnName = String.Empty; +if (Model.CalculationGroups.Any(cg => cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group")) +{ calcGroupName = Model.CalculationGroups.Where(cg => cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group").First().Name; - -}else { +} +else +{ calcGroupName = Interaction.InputBox("Provide a name for your Calc Group", "Calc Group Name", "Time Intelligence", 740, 400); -}; - -if(calcGroupName == "") return; - - -if(Model.CalculationGroups.Any(cg => cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group")) { +}; +if (calcGroupName == String.Empty) return; +if (Model.CalculationGroups.Any(cg => cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group")) +{ columnName = Model.Tables.Where(cg => cg.GetAnnotation("@AgulloBernat") == "Time Intel Calc Group").First().Columns.First().Name; - -}else { +} +else +{ columnName = Interaction.InputBox("Provide a name for your Calc Group Column", "Calc Group Column Name", calcGroupName, 740, 400); -}; - -if(columnName == "") return; - -string affectedMeasuresTableName; - -if(Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) { +}; +if (columnName == String.Empty) return; +string affectedMeasuresTableName = String.Empty; +if (Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) +{ affectedMeasuresTableName = Model.Tables.Where(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table").First().Name; - -} else { - affectedMeasuresTableName = Interaction.InputBox("Provide a name for affected measures table", "Affected Measures Table Name", calcGroupName + " Affected Measures", 740, 400); - +} +else +{ + affectedMeasuresTableName = Interaction.InputBox("Provide a name for affected measures table", "Affected Measures Table Name", calcGroupName + " Affected Measures", 740, 400); }; - -if(affectedMeasuresTableName == "") return; - - -if(Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) { +if (affectedMeasuresTableName ==String.Empty) return; +string affectedMeasuresColumnName = String.Empty; +if (Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) +{ affectedMeasuresColumnName = Model.Tables.Where(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table").First().Columns.First().Name; - -} else { +} +else +{ affectedMeasuresColumnName = Interaction.InputBox("Provide a name for affected measures column", "Affected Measures Table Column Name", "Measure", 740, 400); - }; - - - -string affectedMeasuresColumnName = Interaction.InputBox("Provide a name for affected measures table column name", "Affected Measures Table Column Name", "Measure", 740, 400); - -if(affectedMeasuresColumnName == "") return; +if (affectedMeasuresColumnName == String.Empty) return; //string affectedMeasuresColumnName = "Measure"; - -string labelAsValueMeasureName = "Label as Value Measure"; -string labelAsFormatStringMeasureName = "Label as format string"; - - - // '2021-09-24 / B.Agullo / model object selection prompts! +string labelAsValueMeasureName = "Label as Value Measure"; +string labelAsFormatStringMeasureName = "Label as format string"; +// '2021-09-24 / B.Agullo / model object selection prompts! var factTable = SelectTable(label: "Select your fact table"); -if(factTable == null) return; - +if (factTable == null) return; var factTableDateColumn = SelectColumn(factTable.Columns, label: "Select the main date column"); -if(factTableDateColumn == null) return; - +if (factTableDateColumn == null) return; Table dateTableCandidate = null; - -if(Model.Tables.Any - (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table" - || x.Name == "Date" - || x.Name == "Calendar")){ - dateTableCandidate = Model.Tables.Where - (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table" - || x.Name == "Date" - || x.Name == "Calendar").First(); - +if (Model.Tables.Any + (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table" + || x.Name == "Date" + || x.Name == "Calendar")) +{ + dateTableCandidate = Model.Tables.Where + (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table" + || x.Name == "Date" + || x.Name == "Calendar").First(); }; - -var dateTable = +var dateTable = SelectTable( label: "Select your date table", - preselect:dateTableCandidate); - -if(dateTable == null) { - Error("You just aborted the script"); + preselect: dateTableCandidate); +if (dateTable == null) +{ + Error("You just aborted the script"); return; -} else { - dateTable.SetAnnotation("@AgulloBernat","Time Intel Date Table"); -}; - - -Column dateTableDateColumnCandidate = null; - -if(dateTable.Columns.Any - (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Date Column" || x.Name == "Date")){ +} +else +{ + dateTable.SetAnnotation("@AgulloBernat", "Time Intel Date Table"); +}; +Column dateTableDateColumnCandidate = null; +if (dateTable.Columns.Any + (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Date Column" || x.Name == "Date")) +{ dateTableDateColumnCandidate = dateTable.Columns.Where (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Date Column" || x.Name == "Date").First(); }; - -var dateTableDateColumn = +var dateTableDateColumn = SelectColumn( - dateTable.Columns, + dateTable.Columns, label: "Select the date column", preselect: dateTableDateColumnCandidate); - -if(dateTableDateColumn == null) { - Error("You just aborted the script"); +if (dateTableDateColumn == null) +{ + Error("You just aborted the script"); return; -} else { - dateTableDateColumn.SetAnnotation("@AgulloBernat","Time Intel Date Table Date Column"); -}; - +} +else +{ + dateTableDateColumn.SetAnnotation("@AgulloBernat", "Time Intel Date Table Date Column"); +}; Column dateTableYearColumnCandidate = null; -if(dateTable.Columns.Any(x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Year Column" || x.Name == "Year")){ +if (dateTable.Columns.Any(x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Year Column" || x.Name == "Year")) +{ dateTable.Columns.Where (x => x.GetAnnotation("@AgulloBernat") == "Time Intel Date Table Year Column" || x.Name == "Year").First(); }; - -var dateTableYearColumn = +var dateTableYearColumn = SelectColumn( - dateTable.Columns, - label: "Select the year column", - preselect:dateTableYearColumnCandidate); - -if(dateTableYearColumn == null) { - Error("You just abourted the script"); + dateTable.Columns, + label: "Select the year column", + preselect: dateTableYearColumnCandidate); +if (dateTableYearColumn == null) +{ + Error("You just abourted the script"); return; -} else { - dateTableYearColumn.SetAnnotation("@AgulloBernat","Time Intel Date Table Year Column"); +} +else +{ + dateTableYearColumn.SetAnnotation("@AgulloBernat", "Time Intel Date Table Year Column"); }; - - //these names are for internal use only, so no need to be super-fancy, better stick to datpatterns.com model string ShowValueForDatesMeasureName = "ShowValueForDates"; string dateWithSalesColumnName = "DateWith" + factTable.Name; - // '2021-09-24 / B.Agullo / I put the names back to variables so I don't have to tough the script string factTableName = factTable.Name; string factTableDateColumnName = factTableDateColumn.Name; string dateTableName = dateTable.Name; string dateTableDateColumnName = dateTableDateColumn.Name; -string dateTableYearColumnName = dateTableYearColumn.Name; - +string dateTableYearColumnName = dateTableYearColumn.Name; // '2021-09-24 / B.Agullo / this is for internal use only so better leave it as is -string flagExpression = "UNICHAR( 8204 )"; - +string flagExpression = "UNICHAR( 8204 )"; string calcItemProtection = ""; //default value if user has selected no measures string calcItemFormatProtection = ""; //default value if user has selected no measures - // check if there's already an affected measure table -if(Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) { +if (Model.Tables.Any(t => t.GetAnnotation("@AgulloBernat") == "Time Intel Affected Measures Table")) +{ //modifying an existing calculated table is not risk-free Info("Make sure to include measure names to the table " + affectedMeasuresTableName); -} else { +} +else +{ // create calculated table containing all names of affected measures // this is why you need to enable - if(affectedMeasures != "{") { - + if (affectedMeasures != "{") + { affectedMeasures = affectedMeasures + "}"; - - string affectedMeasureTableExpression = + string affectedMeasureTableExpression = "SELECTCOLUMNS(" + affectedMeasures + ",\"" + affectedMeasuresColumnName + "\",[Value])"; - - var affectedMeasureTable = - Model.AddCalculatedTable(affectedMeasuresTableName,affectedMeasureTableExpression); - - affectedMeasureTable.FormatDax(); - affectedMeasureTable.Description = - "Measures affected by " + calcGroupName + " calculation group." ; - - affectedMeasureTable.SetAnnotation("@AgulloBernat","Time Intel Affected Measures Table"); - + var affectedMeasureTable = + Model.AddCalculatedTable(affectedMeasuresTableName, affectedMeasureTableExpression); + affectedMeasureTable.FormatDax(); + affectedMeasureTable.Description = + "Measures affected by " + calcGroupName + " calculation group."; + affectedMeasureTable.SetAnnotation("@AgulloBernat", "Time Intel Affected Measures Table"); // this causes error // affectedMeasureTable.Columns[affectedMeasuresColumnName].SetAnnotation("@AgulloBernat","Time Intel Affected Measures Table Column"); - - affectedMeasureTable.IsHidden = true; - + affectedMeasureTable.IsHidden = true; }; }; - //if there where selected or preselected measures, prepare protection code for expresion and formatstring string affectedMeasuresValues = "VALUES('" + affectedMeasuresTableName + "'[" + affectedMeasuresColumnName + "])"; - -calcItemProtection = - "SWITCH(" + - " TRUE()," + - " SELECTEDMEASURENAME() IN " + affectedMeasuresValues + "," + - " ," + - " ISSELECTEDMEASURE([" + labelAsValueMeasureName + "])," + - " ," + - " SELECTEDMEASURE() " + +calcItemProtection = + "SWITCH(" + + " TRUE()," + + " SELECTEDMEASURENAME() IN " + affectedMeasuresValues + "," + + " ," + + " ISSELECTEDMEASURE([" + labelAsValueMeasureName + "])," + + " ," + + " SELECTEDMEASURE() " + ")"; - - -calcItemFormatProtection = - "SWITCH(" + - " TRUE() ," + - " SELECTEDMEASURENAME() IN " + affectedMeasuresValues + "," + - " ," + - " ISSELECTEDMEASURE([" + labelAsFormatStringMeasureName + "])," + +calcItemFormatProtection = + "SWITCH(" + + " TRUE() ," + + " SELECTEDMEASURENAME() IN " + affectedMeasuresValues + "," + + " ," + + " ISSELECTEDMEASURE([" + labelAsFormatStringMeasureName + "])," + " ," + - " SELECTEDMEASUREFORMATSTRING() " + + " SELECTEDMEASUREFORMATSTRING() " + ")"; - - -string dateColumnWithTable = "'" + dateTableName + "'[" + dateTableDateColumnName + "]"; -string yearColumnWithTable = "'" + dateTableName + "'[" + dateTableYearColumnName + "]"; +string dateColumnWithTable = "'" + dateTableName + "'[" + dateTableDateColumnName + "]"; +string yearColumnWithTable = "'" + dateTableName + "'[" + dateTableYearColumnName + "]"; string factDateColumnWithTable = "'" + factTableName + "'[" + factTableDateColumnName + "]"; string dateWithSalesWithTable = "'" + dateTableName + "'[" + dateWithSalesColumnName + "]"; string calcGroupColumnWithTable = "'" + calcGroupName + "'[" + columnName + "]"; - //check to see if a table with this name already exists //if it doesnt exist, create a calculation group with this name -if (!Model.Tables.Contains(calcGroupName)) { - var cg = Model.AddCalculationGroup(calcGroupName); - cg.Description = "Calculation group for time intelligence. Availability of data is taken from " + factTableName + "."; - cg.SetAnnotation("@AgulloBernat","Time Intel Calc Group"); +if (!Model.Tables.Contains(calcGroupName)) +{ + var cg = Model.AddCalculationGroup(calcGroupName); + cg.Description = "Calculation group for time intelligence. Availability of data is taken from " + factTableName + "."; + cg.SetAnnotation("@AgulloBernat", "Time Intel Calc Group"); }; - //set variable for the calc group Table calcGroup = Model.Tables[calcGroupName]; - //if table already exists, make sure it is a Calculation Group type -if (calcGroup.SourceType.ToString() != "CalculationGroup") { - Error("Table exists in Model but is not a Calculation Group. Rename the existing table or choose an alternative name for your Calculation Group."); - return; +if (calcGroup.SourceType.ToString() != "CalculationGroup") +{ + Error("Table exists in Model but is not a Calculation Group. Rename the existing table or choose an alternative name for your Calculation Group."); + return; }; - //adds the two measures that will be used for label as value, label as format string -var labelAsValueMeasure = calcGroup.AddMeasure(labelAsValueMeasureName,""); -labelAsValueMeasure.Description = "Use this measure to show the year evaluated in tables"; - -var labelAsFormatStringMeasure = calcGroup.AddMeasure(labelAsFormatStringMeasureName,"0"); -labelAsFormatStringMeasure.Description = "Use this measure to show the year evaluated in charts"; - +var labelAsValueMeasure = calcGroup.AddMeasure(labelAsValueMeasureName, ""); +labelAsValueMeasure.Description = "Use this measure to show the year evaluated in tables"; +var labelAsFormatStringMeasure = calcGroup.AddMeasure(labelAsFormatStringMeasureName, "0"); +labelAsFormatStringMeasure.Description = "Use this measure to show the year evaluated in charts"; //by default the calc group has a column called Name. If this column is still called Name change this in line with specfied variable -if (calcGroup.Columns.Contains("Name")) { - calcGroup.Columns["Name"].Name = columnName; - +if (calcGroup.Columns.Contains("Name")) +{ + calcGroup.Columns["Name"].Name = columnName; }; - calcGroup.Columns[columnName].Description = "Select value(s) from this column to apply time intelligence calculations."; -calcGroup.Columns[columnName].SetAnnotation("@AgulloBernat","Time Intel Calc Group Column"); - - +calcGroup.Columns[columnName].SetAnnotation("@AgulloBernat", "Time Intel Calc Group Column"); //Only create them if not in place yet (reruns) -if(!Model.Tables[dateTableName].Columns.Any(C => C.GetAnnotation("@AgulloBernat") == "Date with Data Column")){ - string DateWithSalesCalculatedColumnExpression = +if (!Model.Tables[dateTableName].Columns.Any(C => C.GetAnnotation("@AgulloBernat") == "Date with Data Column")) +{ + string DateWithSalesCalculatedColumnExpression = dateColumnWithTable + " <= MAX ( " + factDateColumnWithTable + ")"; - - Column dateWithDataColumn = dateTable.AddCalculatedColumn(dateWithSalesColumnName,DateWithSalesCalculatedColumnExpression); - dateWithDataColumn.SetAnnotation("@AgulloBernat","Date with Data Column"); + Column dateWithDataColumn = dateTable.AddCalculatedColumn(dateWithSalesColumnName, DateWithSalesCalculatedColumnExpression); + dateWithDataColumn.SetAnnotation("@AgulloBernat", "Date with Data Column"); }; - -if(!Model.Tables[dateTableName].Measures.Any(M => M.Name == ShowValueForDatesMeasureName)) { - string ShowValueForDatesMeasureExpression = - "VAR LastDateWithData = " + - " CALCULATE ( " + - " MAX ( " + factDateColumnWithTable + " ), " + - " REMOVEFILTERS () " + - " )" + - "VAR FirstDateVisible = " + - " MIN ( " + dateColumnWithTable + " ) " + - "VAR Result = " + - " FirstDateVisible <= LastDateWithData " + - "RETURN " + - " Result "; - - var ShowValueForDatesMeasure = dateTable.AddMeasure(ShowValueForDatesMeasureName,ShowValueForDatesMeasureExpression); - +if (!Model.Tables[dateTableName].Measures.Any(M => M.Name == ShowValueForDatesMeasureName)) +{ + string ShowValueForDatesMeasureExpression = String.Format( + @"VAR LastDateWithData = + CALCULATE( + MAX({0}), + REMOVEFILTERS() + ) + VAR FirstDateVisible = + MIN({1}) + VAR Result = + FirstDateVisible <= LastDateWithData + RETURN + Result", + factDateColumnWithTable, + dateColumnWithTable + ); + var ShowValueForDatesMeasure = dateTable.AddMeasure(ShowValueForDatesMeasureName, ShowValueForDatesMeasureExpression); ShowValueForDatesMeasure.FormatDax(); }; - - - -//defining expressions and formatstring for each calc item -string CY = - "/*CY*/ " + - "SELECTEDMEASURE()"; - -string CYlabel = - "SELECTEDVALUE(" + yearColumnWithTable + ")"; - - -string PY = - "/*PY*/ " + - "IF (" + - " [" + ShowValueForDatesMeasureName + "], " + - " CALCULATE ( " + - " "+ CY + ", " + - " CALCULATETABLE ( " + - " DATEADD ( " + dateColumnWithTable + " , -1, YEAR ), " + - " " + dateWithSalesWithTable + " = TRUE " + - " ) " + - " ) " + - ") "; - - -string PYlabel = - "/*PY*/ " + - "IF (" + - " [" + ShowValueForDatesMeasureName + "], " + - " CALCULATE ( " + - " "+ CYlabel + ", " + - " CALCULATETABLE ( " + - " DATEADD ( " + dateColumnWithTable + " , -1, YEAR ), " + - " " + dateWithSalesWithTable + " = TRUE " + - " ) " + - " ) " + - ") "; - - -string YOY = - "/*YOY*/ " + - "VAR ValueCurrentPeriod = " + CY + " " + - "VAR ValuePreviousPeriod = " + PY + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod - ValuePreviousPeriod" + - " ) " + - "RETURN " + - " Result "; - -string YOYlabel = - "/*YOY*/ " + - "VAR ValueCurrentPeriod = " + CYlabel + " " + - "VAR ValuePreviousPeriod = " + PYlabel + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod & \" vs \" & ValuePreviousPeriod" + - " ) " + - "RETURN " + - " Result "; - -string YOYpct = - "/*YOY%*/ " + - "VAR ValueCurrentPeriod = " + CY + " " + - "VAR ValuePreviousPeriod = " + PY + " " + - "VAR CurrentMinusPreviousPeriod = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod - ValuePreviousPeriod" + - " ) " + - "VAR Result = " + - "DIVIDE ( " + - " CurrentMinusPreviousPeriod," + - " ValuePreviousPeriod" + - ") " + - "RETURN " + - " Result"; - -string YOYpctLabel = - "/*YOY%*/ " + - "VAR ValueCurrentPeriod = " + CYlabel + " " + - "VAR ValuePreviousPeriod = " + PYlabel + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod & \" vs \" & ValuePreviousPeriod & \" (%)\"" + - " ) " + - "RETURN " + - " Result"; - -string YTD = - "/*YTD*/" + - "IF (" + - " [" + ShowValueForDatesMeasureName + "]," + - " CALCULATE (" + - " " + CY+ "," + - " DATESYTD (" + dateColumnWithTable + " )" + - " )" + - ") "; - - -string YTDlabel = CYlabel + "& \" YTD\""; - - -string PYTD = - "/*PYTD*/" + - "IF ( " + - " [" + ShowValueForDatesMeasureName + "], " + - " CALCULATE ( " + - " " + YTD + "," + - " CALCULATETABLE ( " + - " DATEADD ( " + dateColumnWithTable + ", -1, YEAR ), " + - " " + dateWithSalesWithTable + " = TRUE " + - " )" + - " )" + - ") "; - -string PYTDlabel = PYlabel + "& \" YTD\""; - - -string YOYTD = - "/*YOYTD*/" + - "VAR ValueCurrentPeriod = " + YTD + " " + - "VAR ValuePreviousPeriod = " + PYTD + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod - ValuePreviousPeriod" + - " ) " + - "RETURN " + - " Result "; - - -string YOYTDlabel = - "/*YOYTD*/" + - "VAR ValueCurrentPeriod = " + YTDlabel + " " + - "VAR ValuePreviousPeriod = " + PYTDlabel + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod & \" vs \" & ValuePreviousPeriod" + - " ) " + - "RETURN " + - " Result "; - - - -string YOYTDpct = - "/*YOYTD%*/" + - "VAR ValueCurrentPeriod = " + YTD + " " + - "VAR ValuePreviousPeriod = " + PYTD + " " + - "VAR CurrentMinusPreviousPeriod = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod - ValuePreviousPeriod" + - " ) " + - "VAR Result = " + - "DIVIDE ( " + - " CurrentMinusPreviousPeriod," + - " ValuePreviousPeriod" + - ") " + - "RETURN " + - " Result"; - - -string YOYTDpctLabel = - "/*YOY%*/ " + - "VAR ValueCurrentPeriod = " + YTDlabel + " " + - "VAR ValuePreviousPeriod = " + PYTDlabel + " " + - "VAR Result = " + - "IF ( " + - " NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), " + - " ValueCurrentPeriod & \" vs \" & ValuePreviousPeriod & \" (%)\"" + - " ) " + - "RETURN " + - " Result"; - - -string MAT = - " /*TAM*/" + - " IF (" + - " [" + ShowValueForDatesMeasureName + "], " + - " CALCULATE (" + - " SELECTEDMEASURE()," + - " DATESINPERIOD (" + - " " + dateColumnWithTable + " ," + - " MAX ( " + dateColumnWithTable + " )," + - " -1," + - " YEAR" + - " )" + - " " + - " )" + - " )"; - - -string MATlabel = "\"MAT\""; - -string MATminus1 = - " /*TAM*/" + - " IF (" + - " [" + ShowValueForDatesMeasureName + "], " + - " CALCULATE (" + - " SELECTEDMEASURE()," + - " DATESINPERIOD (" + - " " + dateColumnWithTable + "," + - " LASTDATE( DATEADD( " + dateColumnWithTable + ", - 1, YEAR ) )," + - " -1," + - " YEAR" + - " )" + - " )" + - " )"; - -string MATminus1label = "\"MAT-1\""; - -string MATvsMATminus1 = - " /*MAT vs MAT-1*/\r\n" + - " VAR MAT = " + MAT + "\r\n" + - " VAR MAT_1 =" + MATminus1 + "\r\n" + - " RETURN \r\n" + - " IF( ISBLANK( MAT ) || ISBLANK( MAT_1 ), BLANK(), MAT - MAT_1 )"; - -string MATvsMATminus1label = "\"MAT vs MAT-1\""; - -string MATvsMATminus1pct = - " /*MAT vs MAT-1(%)*/" + - " VAR MAT = " + MAT+ "\r\n" + - " VAR MAT_1 =" + MATminus1 + "\r\n" + - " RETURN" + - " IF(" + - " ISBLANK( MAT ) || ISBLANK( MAT_1 )," + - " BLANK()," + - " DIVIDE( MAT - MAT_1, MAT_1 )" + - " )"; - -string MATvsMATminus1pctlabel = "\"MAT vs MAT-1 (%)\""; - +// Defining expressions and format strings for each calc item +string CY = @"/*CY*/ SELECTEDMEASURE()"; +string CYlabel = String.Format(@"SELECTEDVALUE({0})", yearColumnWithTable); +string PY = String.Format( + @"/*PY*/ + IF ( + [{0}], + CALCULATE ( + {1}, + CALCULATETABLE ( + DATEADD ( {2}, -1, YEAR ), + {3} = TRUE + ) + ) + )", + ShowValueForDatesMeasureName, CY, dateColumnWithTable, dateWithSalesWithTable); +string PYlabel = String.Format( + @"/*PY*/ + IF ( + [{0}], + CALCULATE ( + {1}, + CALCULATETABLE ( + DATEADD ( {2}, -1, YEAR ), + {3} = TRUE + ) + ) + )", + ShowValueForDatesMeasureName, CYlabel, dateColumnWithTable, dateWithSalesWithTable); +string YOY = String.Format( + @"/*YOY*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + RETURN + Result", + CY, PY); +string YOYlabel = String.Format( + @"/*YOY*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod + ) + RETURN + Result", + CYlabel, PYlabel); +string YOYpct = String.Format( + @"/*YOY%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR CurrentMinusPreviousPeriod = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + VAR Result = + DIVIDE ( + CurrentMinusPreviousPeriod, + ValuePreviousPeriod + ) + RETURN + Result", + CY, PY); +string YOYpctLabel = String.Format( + @"/*YOY%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod & "" (%)"" + ) + RETURN + Result", + CYlabel, PYlabel); +string YTD = String.Format( + @"/*YTD*/ + IF ( + [{0}], + CALCULATE ( + {1}, + DATESYTD ({2}) + ) + )", + ShowValueForDatesMeasureName, CY, dateColumnWithTable); +string YTDlabel = String.Format(@"{0} & "" YTD""", CYlabel); +string PYTD = String.Format( + @"/*PYTD*/ + IF ( + [{0}], + CALCULATE ( + {1}, + CALCULATETABLE ( + DATEADD ( {2}, -1, YEAR ), + {3} = TRUE + ) + ) + )", + ShowValueForDatesMeasureName, YTD, dateColumnWithTable, dateWithSalesWithTable); +string PYTDlabel = String.Format(@"{0} & "" YTD""", PYlabel); +string YOYTD = String.Format( + @"/*YOYTD*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + RETURN + Result", + YTD, PYTD); +string YOYTDlabel = String.Format( + @"/*YOYTD*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod + ) + RETURN + Result", + YTDlabel, PYTDlabel); +string YOYTDpct = String.Format( + @"/*YOYTD%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR CurrentMinusPreviousPeriod = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + VAR Result = + DIVIDE ( + CurrentMinusPreviousPeriod, + ValuePreviousPeriod + ) + RETURN + Result", + YTD, PYTD); +string YOYTDpctLabel = String.Format( + @"/*YOY%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod & "" (%)"" + ) + RETURN + Result", + YTDlabel, PYTDlabel); + string CM = @"/*CM*/ SELECTEDMEASURE()"; + string CMlabel = String.Format(@"SELECTEDVALUE({0}, ""Current Month"")", dateColumnWithTable); + string PM = String.Format( + @"/*PM*/ + IF ( + [{0}], + CALCULATE ( + {1}, + CALCULATETABLE ( + DATEADD ( {2}, -1, MONTH ), + {3} = TRUE + ) + ) + )", + ShowValueForDatesMeasureName, CM, dateColumnWithTable, dateWithSalesWithTable); + string PMlabel = String.Format( + @"/*PM*/ + IF ( + [{0}], + CALCULATE ( + {1}, + CALCULATETABLE ( + DATEADD ( {2}, -1, MONTH ), + {3} = TRUE + ) + ) + )", + ShowValueForDatesMeasureName, CMlabel, dateColumnWithTable, dateWithSalesWithTable); + string MOM = String.Format( + @"/*MOM*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + RETURN + Result", + CM, PM); + string MOMlabel = String.Format( + @"/*MOM*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod + ) + RETURN + Result", + CMlabel, PMlabel); + string MOMpct = String.Format( + @"/*MOM%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR CurrentMinusPreviousPeriod = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod - ValuePreviousPeriod + ) + VAR Result = + DIVIDE ( + CurrentMinusPreviousPeriod, + ValuePreviousPeriod + ) + RETURN + Result", + CM, PM); + string MOMpctLabel = String.Format( + @"/*MOM%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK ( ValueCurrentPeriod ) && NOT ISBLANK ( ValuePreviousPeriod ), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod & "" (%)"" + ) + RETURN + Result", + CMlabel, PMlabel); + string MTD = String.Format( + @"/*MTD*/ + IF ( + [{0}], + CALCULATE ( + SELECTEDMEASURE(), + DATESMTD({1}) + ) + )", + ShowValueForDatesMeasureName, dateColumnWithTable); + string MTDlabel = String.Format( + @"/*MTD*/ + IF ( + [{0}], + CALCULATE ( + ""Month to date"", + DATESMTD({1}) + ) + )", + ShowValueForDatesMeasureName, dateColumnWithTable); + string PMTD = String.Format( + @"/*PMTD*/ + IF ( + [{0}], + CALCULATE ( + SELECTEDMEASURE(), + DATESMTD(DATEADD({1}, -1, MONTH)) + ) + )", + ShowValueForDatesMeasureName, dateColumnWithTable); + string PMTDlabel = String.Format( + @"/*PMTD*/ + IF ( + [{0}], + CALCULATE ( + ""Previous month to date"", + DATESMTD(DATEADD({1}, -1, MONTH)) + ) + )", + ShowValueForDatesMeasureName, dateColumnWithTable); + string MOMTD = String.Format( + @"/*MOMTD*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK(ValueCurrentPeriod) && NOT ISBLANK(ValuePreviousPeriod), + ValueCurrentPeriod - ValuePreviousPeriod + ) + RETURN + Result", + MTD, PMTD); + string MOMTDlabel = String.Format( + @"/*MOMTD*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK(ValueCurrentPeriod) && NOT ISBLANK(ValuePreviousPeriod), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod + ) + RETURN + Result", + MTDlabel, PMTDlabel); + string MOMTDpct = String.Format( + @"/*MOMTD%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR CurrentMinusPreviousPeriod = + IF ( + NOT ISBLANK(ValueCurrentPeriod) && NOT ISBLANK(ValuePreviousPeriod), + ValueCurrentPeriod - ValuePreviousPeriod + ) + VAR Result = + DIVIDE( + CurrentMinusPreviousPeriod, + ValuePreviousPeriod + ) + RETURN + Result", + MTD, PMTD); + string MOMTDpctlabel = String.Format( + @"/*MOMTD%*/ + VAR ValueCurrentPeriod = {0} + VAR ValuePreviousPeriod = {1} + VAR Result = + IF ( + NOT ISBLANK(ValueCurrentPeriod) && NOT ISBLANK(ValuePreviousPeriod), + ValueCurrentPeriod & "" vs "" & ValuePreviousPeriod & "" (%)"" + ) + RETURN + Result", + MTDlabel, PMTDlabel); +string MAT = String.Format(@" +/*TAM*/ +IF ( + [{0}], + CALCULATE ( + SELECTEDMEASURE(), + DATESINPERIOD ( +{1}, +MAX({1}), +-1, +YEAR + ) + ) +)", ShowValueForDatesMeasureName, dateColumnWithTable); +string MATlabel = String.Format(@" +/*TAM*/ +IF ( + [{0}], + CALCULATE ( + ""Year ending "" & FORMAT(MAX('Date'[Date]), ""d-MMM-yyyy"", ""en-US""), + DATESINPERIOD ( +{1}, +MAX({1}), +-1, +YEAR + ) + ) +)", ShowValueForDatesMeasureName, dateColumnWithTable); +string MATminus1 = String.Format(@" +/*TAM*/ +IF ( + [{0}], + CALCULATE ( + SELECTEDMEASURE(), + DATESINPERIOD ( +{1}, +LASTDATE(DATEADD({1}, -1, YEAR)), +-1, +YEAR + ) + ) +)", ShowValueForDatesMeasureName, dateColumnWithTable); +string MATminus1label = String.Format(@" +/*MAT-1*/ +IF ( + [{0}], + CALCULATE ( + ""Year ending "" & FORMAT(MAX('Date'[Date]), ""d-MMM-yyyy"", ""en-US""), + DATESINPERIOD ( +{1}, +LASTDATE(DATEADD({1}, -1, YEAR)), +-1, +YEAR + ) + ) +)", ShowValueForDatesMeasureName, dateColumnWithTable); +string MATvsMATminus1 = String.Format(@" +/*MAT vs MAT-1*/ +VAR MAT = {0} +VAR MAT_1 = {1} +RETURN + IF(ISBLANK(MAT) || ISBLANK(MAT_1), BLANK(), MAT - MAT_1) +", MAT, MATminus1); +string MATvsMATminus1label = String.Format(@" +/*MAT vs MAT-1*/ +VAR MAT = {0} +VAR MAT_1 = {1} +RETURN + IF(ISBLANK(MAT) || ISBLANK(MAT_1), BLANK(), MAT & "" vs "" & MAT_1) +", MATlabel, MATminus1label); +string MATvsMATminus1pct = String.Format(@" +/*MAT vs MAT-1(%)*/ +VAR MAT = {0} +VAR MAT_1 = {1} +RETURN + IF( + ISBLANK(MAT) || ISBLANK(MAT_1), + BLANK(), + DIVIDE(MAT - MAT_1, MAT_1) + ) +", MAT, MATminus1); +string MATvsMATminus1pctlabel = String.Format(@" +/*MAT vs MAT-1 (%)*/ +VAR MAT = {0} +VAR MAT_1 = {1} +RETURN + IF(ISBLANK(MAT) || ISBLANK(MAT_1), BLANK(), MAT & "" vs "" & MAT_1 & "" (%)"") +", MATlabel, MATminus1label); string MMT = String.Format( - @"/*MMT*/ + @"/*MMT*/ IF( - [{0}], - CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, MAX( {1} ), -1, MONTH ) ) - )",ShowValueForDatesMeasureName,dateColumnWithTable); - -string MMTlabel = "\"MMT\""; - +[{0}], +CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, MAX( {1} ), -1, MONTH ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable); +string MMTlabel = String.Format( + @"/*MMT*/ + IF( +[{0}], +CALCULATE( {2}, DATESINPERIOD( {1}, MAX( {1} ), -1, MONTH ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable, "\"Month ending \" & FORMAT(MAX( 'Date'[Date] ),\"d-MMM-yyyy\",\"en-US\")"); string MMTminus1 = String.Format( - @"/*MMT*/ + @"/*MMT*/ IF( - [{0}], - CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -1, MONTH ) ), -1, MONTH ) ) - )",ShowValueForDatesMeasureName,dateColumnWithTable); - -string MMTminus1label = "\"MMT-1\""; - -string MMTvsMMTminus1 = - " /*MMT vs MMT-1*/\r\n" + - " VAR MMT = " + MMT + "\r\n" + - " VAR MMT_1 =" + MMTminus1 + "\r\n" + - " RETURN \r\n" + - " IF( ISBLANK( MMT ) || ISBLANK( MMT_1 ), BLANK(), MMT - MMT_1 )"; - -string MMTvsMMTminus1label = "\"MMT vs MMT-1\""; - -string MMTvsMMTminus1pct = - " /*MMT vs MMT-1(%)*/" + - " VAR MMT = " + MMT+ "\r\n" + - " VAR MMT_1 =" + MMTminus1 + "\r\n" + - " RETURN" + - " IF(" + - " ISBLANK( MMT ) || ISBLANK( MMT_1 )," + - " BLANK()," + - " DIVIDE( MMT - MMT_1, MMT_1 )" + - " )"; - -string MMTvsMMTminus1pctlabel = "\"MMT vs MMT-1 (%)\""; - - - +[{0}], +CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -1, MONTH ) ), -1, MONTH ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable); +string MMTminus1label = String.Format( + @"/*MMT-1*/ + IF( +[{0}], +CALCULATE( {2}, DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -1, MONTH ) ), -1, MONTH ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable, "\"Month ending \" & FORMAT(MAX( 'Date'[Date] ),\"d-MMM-yyyy\",\"en-US\")"); +string MMTvsMMTminus1 = String.Format( + @"/*MMT vs MMT-1*/ + VAR MMT = {0} + VAR MMT_1 = {1} + RETURN + IF( ISBLANK( MMT ) || ISBLANK( MMT_1 ), BLANK(), MMT - MMT_1 )", + MMT, MMTminus1); +string MMTvsMMTminus1label = String.Format( + @"/*MMT vs MMT-1*/ + VAR MMT = {0} + VAR MMT_1 = {1} + RETURN + IF( ISBLANK( MMT ) || ISBLANK( MMT_1 ), BLANK(), MMT & "" vs "" & MMT_1 )", + MMTlabel, MMTminus1label); +string MMTvsMMTminus1pct = String.Format( + @"/*MMT vs MMT-1(%)*/ + VAR MMT = {0} + VAR MMT_1 = {1} + RETURN + IF( + ISBLANK( MMT ) || ISBLANK( MMT_1 ), + BLANK(), + DIVIDE( MMT - MMT_1, MMT_1 ) + )", + MMT, MMTminus1); +string MMTvsMMTminus1pctlabel = String.Format( + @"/*MMT vs MMT-1(%)*/ + VAR MMT = {0} + VAR MMT_1 = {1} + RETURN + IF( ISBLANK( MMT ) || ISBLANK( MMT_1 ), BLANK(), MMT & "" vs "" & MMT_1 & "" (%)"")", + MMTlabel, MMTminus1label); string MWT = String.Format( @"/*MWT*/ IF( - [{0}], - CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, MAX( {1} ), -7, DAY ) ) - )",ShowValueForDatesMeasureName,dateColumnWithTable); - -string MWTlabel = "\"MWT\""; - +[{0}], +CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, MAX( {1} ), -7, DAY ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable); +string MWTlabel = "/*MWT*/" + + String.Format( + @"/*MWT*/ + IF( +[{0}], +CALCULATE( {2}, DATESINPERIOD( {1}, MAX( {1} ), -7, DAY ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable, "\"Week ending \" & FORMAT(MAX( 'Date'[Date] ),\"d-MMM-yyyy\",\"en-US\")"); ; string MWTminus1 = String.Format( @"/*MWT*/ IF( - [{0}], - CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -7, DAY ) ), -7, DAY ) ) - )",ShowValueForDatesMeasureName,dateColumnWithTable); - -string MWTminus1label = "\"MWT-1\""; - -string MWTvsMWTminus1 = - " /*MWT vs MWT-1*/\r\n" + - " VAR MWT = " + MWT + "\r\n" + - " VAR MWT_1 =" + MWTminus1 + "\r\n" + - " RETURN \r\n" + - " IF( ISBLANK( MWT ) || ISBLANK( MWT_1 ), BLANK(), MWT - MWT_1 )"; - -string MWTvsMWTminus1label = "\"MWT vs MWT-1\""; - -string MWTvsMWTminus1pct = - " /*MWT vs MWT-1(%)*/" + - " VAR MWT = " + MWT+ "\r\n" + - " VAR MWT_1 =" + MWTminus1 + "\r\n" + - " RETURN" + - " IF(" + - " ISBLANK( MWT ) || ISBLANK( MWT_1 )," + - " BLANK()," + - " DIVIDE( MWT - MWT_1, MWT_1 )" + - " )"; - -string MWTvsMWTminus1pctlabel = "\"MWT vs MWT-1 (%)\""; - - - +[{0}], +CALCULATE( SELECTEDMEASURE( ), DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -7, DAY ) ), -7, DAY ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable); +string MWTminus1label = "/*MWT-1*/" + + String.Format( + @"/*MWT*/ + IF( +[{0}], +CALCULATE( {2}, DATESINPERIOD( {1}, LASTDATE( DATEADD( {1}, -7, DAY ) ), -7, DAY ) ) + )", ShowValueForDatesMeasureName, dateColumnWithTable, "\"Week ending \" & FORMAT(MAX( 'Date'[Date] ),\"d-MMM-yyyy\",\"en-US\")"); +string MWTvsMWTminus1 = String.Format( + @"/*MWT vs MWT-1*/ + VAR MWT = {0} + VAR MWT_1 = {1} + RETURN + IF( ISBLANK( MWT ) || ISBLANK( MWT_1 ), BLANK(), MWT - MWT_1 )", + MWT, MWTminus1); +string MWTvsMWTminus1label = String.Format( + @"/*MWT vs MWT-1*/ + VAR MWT = {0} + VAR MWT_1 = {1} + RETURN + IF( ISBLANK( MWT ) || ISBLANK( MWT_1 ), BLANK(), MWT & "" vs "" & MWT_1 )", + MWTlabel, MWTminus1label); +string MWTvsMWTminus1pct = String.Format( + @"/*MWT vs MWT-1(%)*/ + VAR MWT = {0} + VAR MWT_1 = {1} + RETURN + IF( + ISBLANK( MWT ) || ISBLANK( MWT_1 ), + BLANK(), + DIVIDE( MWT - MWT_1, MWT_1 ) + )", + MWT, MWTminus1); +string MWTvsMWTminus1pctlabel = String.Format( + @"/*MWT vs MWT-1 (%)*/ + VAR MWT = {0} + VAR MWT_1 = {1} + RETURN + IF( ISBLANK( MWT ) || ISBLANK( MWT_1 ), BLANK(), MWT & "" vs "" & MWT_1 & "" (%)"")", + MWTlabel, MWTminus1label); string defFormatString = "SELECTEDMEASUREFORMATSTRING()"; - //if the flag expression is already present in the format string, do not change it, otherwise apply % format. -string pctFormatString = -"IF(" + -"\n FIND( "+ flagExpression + ", SELECTEDMEASUREFORMATSTRING(), 1, - 1 ) <> -1," + -"\n SELECTEDMEASUREFORMATSTRING()," + -"\n \"#,##0.# %\"" + -"\n)"; - - +string pctFormatString = String.Format( + @"IF( + FIND( {0}, SELECTEDMEASUREFORMATSTRING(), 1, -1 ) <> -1, + SELECTEDMEASUREFORMATSTRING(), + ""#,##0.# %"" + )", + flagExpression); //the order in the array also determines the ordinal position of the item -string[ , ] calcItems = +string[,] calcItems = { {"CY", CY, defFormatString, "Current year", CYlabel}, {"PY", PY, defFormatString, "Previous year", PYlabel}, @@ -683,6 +856,14 @@ string[ , ] calcItems = {"PYTD", PYTD, defFormatString, "Previous year-to-date", PYTDlabel}, {"YOYTD", YOYTD, defFormatString, "Year-over-year-to-date", YOYTDlabel}, {"YOYTD%", YOYTDpct, pctFormatString, "Year-over-year-to-date%", YOYTDpctLabel}, + {"CM", CM, defFormatString, "Current month", CMlabel}, + {"PM", PM, defFormatString, "Previous month", PMlabel}, + {"MOM", MOM, defFormatString, "Month-over-month", MOMlabel}, + {"MOM%", MOMpct, pctFormatString, "Month-over-month%", MOMpctLabel}, + {"MTD", MTD, defFormatString, "Month-to-date", MTDlabel}, + {"PMTD", PMTD, defFormatString, "Previous month-to-date", PMTDlabel}, + {"MOMTD", MOMTD, defFormatString, "Month-over-month-to-date", MOMTDlabel}, + {"MOMTD%", MOMTDpct, pctFormatString, "Month-over-month-to-date%", MOMTDpctlabel}, {"MAT", MAT, defFormatString, "Moving Anual Total", MATlabel}, {"MAT-1", MATminus1, defFormatString, "Moving Anual Total -1 year", MATminus1label}, {"MAT vs MAT-1", MATvsMATminus1, defFormatString, "Moving Anual Total vs Moving Anual Total -1 year", MATvsMATminus1label}, @@ -696,44 +877,31 @@ string[ , ] calcItems = {"MWT vs MWT-1", MWTvsMWTminus1, defFormatString, "Moving Weekly Total vs Moving Weekly Total -1 month", MWTvsMWTminus1label}, {"MWT vs MWT-1(%)", MWTvsMWTminus1pct, pctFormatString, "Moving Weekly Total vs Moving Weekly Total -1 week (%)", MWTvsMWTminus1pctlabel} }; - - int j = 0; - - //create calculation items for each calculation with formatstring and description -foreach(var cg in Model.CalculationGroups) { - if (cg.Name == calcGroupName) { - for (j = 0; j < calcItems.GetLength(0); j++) { - - string itemName = calcItems[j,0]; - - string itemExpression = calcItemProtection.Replace("",calcItems[j,1]); - itemExpression = itemExpression.Replace("",calcItems[j,4]); - - string itemFormatExpression = calcItemFormatProtection.Replace("",calcItems[j,2]); - itemFormatExpression = itemFormatExpression.Replace("","\"\"\"\" & " + calcItems[j,4] + " & \"\"\"\""); - +foreach (var cg in Model.CalculationGroups) +{ + if (cg.Name == calcGroupName) + { + for (j = 0; j < calcItems.GetLength(0); j++) + { + string itemName = calcItems[j, 0]; + string itemExpression = calcItemProtection.Replace("", calcItems[j, 1]); + itemExpression = itemExpression.Replace("", calcItems[j, 4]); + string itemFormatExpression = calcItemFormatProtection.Replace("", calcItems[j, 2]); + itemFormatExpression = itemFormatExpression.Replace("", "\"\"\"\" & " + calcItems[j, 4] + " & \"\"\"\""); //if(calcItems[j,2] != defFormatString) { // itemFormatExpression = calcItemFormatProtection.Replace("",calcItems[j,2]); //}; - - string itemDescription = calcItems[j,3]; - - if (!cg.CalculationItems.Contains(itemName)) { + string itemDescription = calcItems[j, 3]; + if (!cg.CalculationItems.Contains(itemName)) + { var nCalcItem = cg.AddCalculationItem(itemName, itemExpression); nCalcItem.FormatStringExpression = itemFormatExpression; nCalcItem.FormatDax(); - nCalcItem.Ordinal = j; + nCalcItem.Ordinal = j; nCalcItem.Description = itemDescription; - }; - - - - }; - - }; }; diff --git a/Advanced/Report Layer Macros/Add Bilingual Layer Visuals.csx b/Advanced/Report Layer Macros/Add Bilingual Layer Visuals.csx new file mode 100644 index 0000000..85f8e5b --- /dev/null +++ b/Advanced/Report Layer Macros/Add Bilingual Layer Visuals.csx @@ -0,0 +1,2253 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +//2025-06-23/B.Agullo +//this script adds a bilingual layer to the report, allowing the user to select the language of the report. +//this will only prepare the report for an extraction of the definition as descrived in https://www.esbrina-ba.com/transforming-a-regular-report-into-a-bilingual-one-part-2-extracting-display-names-of-measures-and-field-prameters/ +ReportExtended report = Rx.InitReport(); +if (report == null) return; +string altTextFlag = Fx.GetNameFromUser( + Prompt: "Enter the flag for the original language (e.g., 'EN' for English):", + Title: "Alternative Language Flag", + DefaultResponse: "EN" +); +if (string.IsNullOrEmpty(altTextFlag)) +{ + Info("Operation cancelled."); + return; +} +int totalCount = 0; +// For each page, process visuals +foreach (var pageExt in report.Pages) +{ + var visuals = (pageExt.Visuals ?? new List()) + .OrderBy(v => v.Content.Position.Y) + .ThenBy(v => v.Content.Position.X) + .ToList(); + int bilingualCounter = 1; + foreach (var visual in visuals) + { + // Skip if already in a bilingual group or if it's a group itself + if (visual.IsInBilingualVisualGroup()) continue; + if (visual.isVisualGroup) continue; + // Duplicate the visual (deep copy) + VisualExtended duplicate = Rx.DuplicateVisual(visual); + // Add the duplicate to the page + pageExt.Visuals.Add(duplicate); + // Prepare bilingual group name, ensure uniqueness + string pagePrefix = String.Format("P{0:00}", visual.ParentPage.PageIndex + 1); + string groupSuffix = String.Format("{0:000}", bilingualCounter); + string bilingualGroupDisplayName = pagePrefix + "-" + groupSuffix; + // Check for existing group with the same display name and increment counter if needed + while (pageExt.Visuals.Any(v => + v.isVisualGroup && + v.Content.VisualGroup != null && + v.Content.VisualGroup.DisplayName == bilingualGroupDisplayName)) + { + bilingualCounter++; + groupSuffix = String.Format("{0:000}", bilingualCounter); + bilingualGroupDisplayName = pagePrefix + "-" + groupSuffix; + } + string originalVisualGroupName = visual.Content.ParentGroupName; + List visualsToGroup = new List { visual, duplicate }; + // Create bilingual visual group + VisualExtended visualGroup = Rx.GroupVisuals(visualsToGroup, groupDisplayName: bilingualGroupDisplayName); + //configure the original visual group if existed + if (originalVisualGroupName != null) + { + visualGroup.Content.ParentGroupName = originalVisualGroupName; + } + //set the altText flag + string currentAltText = visual.AltText ?? ""; + if (!currentAltText.StartsWith(altTextFlag)) + { + visual.AltText = String.Format(@"{0} {1}", altTextFlag, currentAltText).Trim(); + } + // Remove flag from duplicate's altText if present + string duplicateAltText = duplicate.AltText ?? ""; + if (duplicateAltText.StartsWith(altTextFlag)) + { + duplicate.AltText = duplicateAltText.Substring(altTextFlag.Length).TrimStart(); + } + //hide the original visual + visual.Content.IsHidden = true; + Rx.SaveVisual(visual); + Rx.SaveVisual(duplicate); + Rx.SaveVisual(visualGroup); + bilingualCounter++; + totalCount++; + } +} +Output(String.Format("Bilingual visual groups created for {0} visuals.",totalCount)); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + Width = customWidth, + Height = customHeight, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect }; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + public static VisualExtended SelectVisual(ReportExtended report) + + { + + return SelectVisualInternal(report, Multiselect: false) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report) + + { + + return SelectVisualInternal(report, Multiselect: true) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect) + + { + + // Step 1: Build selection list + + var visualSelectionList = report.Pages + + .SelectMany(p => p.Visuals.Select(v => new + + { + + //use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)v.Content.Position.X, + + (int)v.Content.Position.Y), + + + + + + Page = p, + + Visual = v + + })) + + .ToList(); + + + + if(visualSelectionList.Count == 0) + + { + + Error("No visuals found in the report."); + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public object FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + [JsonProperty("tabOrder")] public int TabOrder { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + + + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public Field Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + + public Boolean isVisualGroup => Content?.VisualGroup != null; + public Boolean isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if(Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + // Ensure the structure exists + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig)); + + return fields.Where(f => f != null); + } + + private IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color != null && + prop.Color.Expr != null && + prop.Color.Expr.FillRule != null && + prop.Color.Expr.FillRule.Input != null) + { + yield return prop.Color.Expr.FillRule.Input; + } + + if (prop.Solid != null && + prop.Solid.Color != null && + prop.Solid.Color.Expr != null && + prop.Solid.Color.Expr.FillRule != null && + prop.Solid.Color.Expr.FillRule.Input != null) + { + yield return prop.Solid.Color.Expr.FillRule.Input; + } + + var solidExpr = prop.Solid != null && + prop.Solid.Color != null + ? prop.Solid.Color.Expr + : null; + + if (solidExpr != null) + { + if (solidExpr.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + + if (solidExpr.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(object filterConfig) + { + var fields = new List(); + + if (filterConfig is JObject jObj) + { + foreach (var token in jObj.DescendantsAndSelf().OfType()) + { + var table = token["table"]?.ToString(); + var property = token["column"]?.ToString() ?? token["measure"]?.ToString(); + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(property)) + { + var field = new VisualDto.Field(); + + if (token["measure"] != null) + { + field.Measure = new VisualDto.MeasureObject + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + else if (token["column"] != null) + { + field.Column = new VisualDto.ColumnField + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + + fields.Add(field); + } + } + } + + return fields; + } + + + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure) + { + f.Measure = newField.Measure; + f.Column = null; + wasModified = true; + } + else + { + f.Column = newField.Column; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? newField.Measure.Expression?.SourceRef?.Entity + : newField.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? newField.Measure.Property + : newField.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + //proj.NativeQueryRef = prop; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure = newField.Measure; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column = newField.Column; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure = newField.Measure; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column = newField.Column; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure = newField.Measure; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column = newField.Column; + solidInput.Measure = null; + wasModified = true; + } + } + + // ✅ NEW: handle direct measure/column under solid.color.expr + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure = newField.Measure; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column = newField.Column; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (Content.FilterConfig != null) + { + var filterConfigString = Content.FilterConfig.ToString(); + string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + string oldPattern = oldFieldKey; + string newPattern = $"'{table}'[{prop}]"; + + if (filterConfigString.Contains(oldPattern)) + { + Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + wasModified = true; + } + } + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + + } + + public void ReplaceInFilterConfigRaw( + Dictionary tableMap, + Dictionary fieldMap, + HashSet modifiedVisuals = null) + { + if (Content.FilterConfig == null) return; + + string originalJson = JsonConvert.SerializeObject(Content.FilterConfig); + string updatedJson = originalJson; + + foreach (var kv in tableMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + foreach (var kv in fieldMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + // Only update and track if something actually changed + if (updatedJson != originalJson) + { + Content.FilterConfig = JsonConvert.DeserializeObject(updatedJson); + modifiedVisuals?.Add(this); + } + } + + } + + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Adjust Line above Columns.cs b/Advanced/Report Layer Macros/Adjust Line above Columns.cs new file mode 100644 index 0000000..40772df --- /dev/null +++ b/Advanced/Report Layer Macros/Adjust Line above Columns.cs @@ -0,0 +1,3243 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; + +// 2025-10-11 / B.Agullo +// Adjusts the line and column axis min/max values in a Line and Clustered Column Combo Chart to ensure that the line is always fully visible. +// It creates a "Formatting" calculation table with measures and functions to calculate the axis min/max values dynamically based on the data in the visual. +// It assumes the visual has at least one field in the x-axis, one for the columns and one for the line. +// It modifies the visual to use these calculations for the axis min/max values. +// It is recommended to create a backup of the model before running this script as it modifies both the model and the report. +// The script only works with "lineClusteredColumnComboChart" visuals. +// see https://www.esbrina-ba.com/industrializing-chart-modifications-with-visual-calcs-dax-udfs-and-c-scripts/ + +// Check model compatibility level +Fx.CheckCompatibilityVersion(Model, 1702, "Time Intelligence functions are only supported in 1702 or higher.Do you want to change the compatibility level to 1702?"); +if (Model.Database.CompatibilityLevel < 1702) return; + +//check if there's a matching visual in the report +ReportExtended report = Rx.InitReport(); +if (report == null) return; +List visualTypes = new List() { "lineClusteredColumnComboChart" }; +VisualExtended selectedVisual = Rx.SelectVisual(report, visualTypes); +if (selectedVisual == null) return; +var queryState = selectedVisual.Content?.Visual?.Query?.QueryState; +var categories = queryState?.Category?.Projections ?? new List(); +var columns = queryState?.Y?.Projections ?? new List(); +var lines = queryState?.Y2?.Projections ?? new List(); +if (categories.Count() == 0 || columns.Count() == 0 || lines.Count() == 0) +{ + Error("Chart not completely configured. Please configure at least a field for x-axis, one for the columns and one for the line"); + return; +} +Table formattingTable = Fx.CreateCalcTable(Model,"Formatting"); +if(formattingTable == null) return; +bool globalPaddingCreated = false; +bool lineChartHeightCreated = false; +bool secondaryMaxFunctionCreated = false; +bool secondaryMinFunctionCreated = false; +bool primaryMaxFunctionCreated = false; +Measure globalPaddingMeasure = Fx.CreateMeasure( + table: formattingTable, + measureName: "GlobalPadding", + measureExpression: "0.1", + out globalPaddingCreated, + description: "Global padding to apply to secondary axis max calculation (10% by default)", + annotationLabel: "@AgulloBernat", + annotationValue: "GlobalPadding", + isHidden: true); +if(globalPaddingMeasure == null) return; +Measure lineChartHeight = Fx.CreateMeasure( + table: formattingTable, + measureName: "LineChartHeight", + measureExpression: "0.4", + out lineChartHeightCreated, + description: "Percent of the chart height for the line chart", + annotationLabel: "@AgulloBernat", + annotationValue: "LineChartHeight", + isHidden: true); +if(lineChartHeight == null) return; +string secondaryMaxName = "Formatting.AxisMaxMin.SecondaryMax"; +string secondaryMaxExpression = + @"( + lineMaxExpression: ANYREF EXPR, + xAxisColumn: ANYREF EXPR, + paddingScalar:ANYVAL + ) => + EXPAND( MAXX( ROWS, lineMaxExpression ), xAxisColumn ) * ( 1 + paddingScalar )"; +string secondaryMaxAnnotationLabel = "@AgulloBernat"; +string secondaryMaxAnnotationValue = "Formatting.AxisMaxMin.SecondaryMax"; +Function secondaryMaxFunction = Fx.CreateFunction( + model: Model, + name: secondaryMaxName, + expression: secondaryMaxExpression, + out secondaryMaxFunctionCreated, + annotationLabel: secondaryMaxAnnotationLabel, + annotationValue: secondaryMaxAnnotationValue); +string secondaryMinName = "Formatting.AxisMaxMin.SecondaryMin"; +string secondaryMinExpression = + @"( + lineMinExpression: ANYREF EXPR, + xAxisColumn: ANYREF EXPR, + paddingScalar: ANYVAL DECIMAL, + secondaryAxisMaxValue: ANYVAL DECIMAL, + lineChartWeight: ANYVAL DECIMAL + ) => + VAR _lineMinVal = + EXPAND( + MINX( ROWS, lineMinExpression ), + xAxisColumn + ) + * ( 1 - paddingScalar ) + VAR _lineHeight = secondaryAxisMaxValue - _lineMinVal + VAR _secondaryAxisHeight = _lineHeight / lineChartWeight + VAR _result = secondaryAxisMaxValue - _secondaryAxisHeight + RETURN + _result"; +string secondaryMinAnnotationLabel = "@AgulloBernat"; +string secondaryMinAnnotationValue = "Formatting.AxisMaxMin.SecondaryMin"; +Function secondaryMinFunction = Fx.CreateFunction( + model: Model, + name: secondaryMinName, + expression: secondaryMinExpression, + out secondaryMinFunctionCreated, + annotationLabel: secondaryMinAnnotationLabel, + annotationValue: secondaryMinAnnotationValue); +string primaryMaxName = "Formatting.AxisMaxMin.PrimaryMax"; +string primaryMaxExpression = + @"( + columnMaxExpression: ANYREF EXPR, + xAxisColumn: ANYREF EXPR, + paddingScalar: ANYVAL DECIMAL, + lineChartWeight: ANYVAL DECIMAL + ) => + VAR _maxColumnValue = + EXPAND( + MAXX( ROWS, columnMaxExpression ), + xAxisColumn + ) + * ( 1 + paddingScalar ) + VAR _result = _maxColumnValue / ( 1 - lineChartWeight ) + RETURN + _result"; +string primaryMaxAnnotationLabel = "@AgulloBernat"; +string primaryMaxAnnotationValue = "Formatting.AxisMaxMin.PrimaryMax"; +Function primaryMaxFunction = Fx.CreateFunction( + model: Model, + name: primaryMaxName, + expression: primaryMaxExpression, + out primaryMaxFunctionCreated, + annotationLabel: primaryMaxAnnotationLabel, + annotationValue: primaryMaxAnnotationValue); +if (globalPaddingCreated + || lineChartHeightCreated + || secondaryMaxFunctionCreated + || secondaryMinFunctionCreated + || primaryMaxFunctionCreated) +{ + Info("Some elements were added to the semantic model. Commit changes to the model, save your progress and run this script again"); + return; +} +//all elements were already in place, time to proceed with the report layer +var paddingProjection = new VisualDto.Projection +{ + Field = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef + { + Entity = globalPaddingMeasure.Table.Name + } + }, + Property = globalPaddingMeasure.Name + } + }, + QueryRef = globalPaddingMeasure.Table.Name + "." + globalPaddingMeasure.Name, + NativeQueryRef = globalPaddingMeasure.Name, + Hidden = true +}; +var lineChartHeightProjection = new VisualDto.Projection +{ + Field = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef + { + Entity = lineChartHeight.Table.Name + } + }, + Property = lineChartHeight.Name + } + }, + QueryRef = lineChartHeight.Table.Name + "." + lineChartHeight.Name, + NativeQueryRef = lineChartHeight.Name, + Hidden = true +}; +if(categories.Count() > 1) +{ + Error("Multiple fields found in x-axis. Not implemented yet"); + return; +} +//variables used in different visual calculations +string xAxisColumn = "[" + categories[0].NativeQueryRef + "]"; +string paddingScalar = "[" + paddingProjection.NativeQueryRef + "]"; +string lineChartWeight = "[" + lineChartHeightProjection.NativeQueryRef + "]"; +string secondaryMaxLineMaxExpression = String.Format( + "MAXX({{{0}}},[Value])", + "[" + string.Join( + "],[", + lines.Select(l=> l.NativeQueryRef)) + "]" + ); +string secondaryMaxVisualCalcExpression = + String.Format( + @"{0}({1},{2},{3})", + secondaryMaxFunction.Name, + secondaryMaxLineMaxExpression, + xAxisColumn, + paddingScalar + ); +var secondaryMaxVisualCalcProjection = new VisualDto.Projection +{ + Field = new VisualDto.Field + { + NativeVisualCalculation = new VisualDto.NativeVisualCalculation + { + Language = "dax", + Expression= secondaryMaxVisualCalcExpression, + Name = "secondaryMax" + } + }, + QueryRef = "secondaryMax", + NativeQueryRef = "secondaryMax", + Hidden = true +}; +string secondaryAxisMaxValue = "[" + secondaryMaxVisualCalcProjection.NativeQueryRef + "]"; +string lineMinExpression = String.Format( + "MINX({{{0}}},[Value])", + "[" + string.Join( + "],[", + lines.Select(l => l.NativeQueryRef)) + "]" + ); +string secondaryMinVisualCalcExpression = + String.Format( + @"{0}({1},{2},{3},{4},{5})", + secondaryMinFunction.Name, + lineMinExpression, + xAxisColumn, + paddingScalar, + secondaryAxisMaxValue, + lineChartWeight + ); +var secondaryMinVisualCalcProjection = new VisualDto.Projection +{ + Field = new VisualDto.Field + { + NativeVisualCalculation = new VisualDto.NativeVisualCalculation + { + Language = "dax", + Expression = secondaryMinVisualCalcExpression, + Name = "secondaryMin" + } + }, + QueryRef = "secondaryMin", + NativeQueryRef = "secondaryMin", + Hidden = true +}; +string primaryMaxColumnMaxExpression = String.Format( + "MAXX({{{0}}},[Value])", + "[" + string.Join( + "],[", + columns.Select(l => l.NativeQueryRef)) + "]" + ); +string primaryMaxVisualCalcExpression = + String.Format( + @"{0}({1},{2},{3},{4})", + primaryMaxFunction.Name, + primaryMaxColumnMaxExpression, + xAxisColumn, + paddingScalar, + lineChartWeight + ); +var primaryMaxVisualCalcProjection = new VisualDto.Projection +{ + Field = new VisualDto.Field + { + NativeVisualCalculation = new VisualDto.NativeVisualCalculation + { + Language = "dax", + Expression = primaryMaxVisualCalcExpression, + Name = "primaryMax" + } + }, + QueryRef = "primaryMax", + NativeQueryRef = "primaryMax", + Hidden = true +}; +columns.Add(paddingProjection); +columns.Add(lineChartHeightProjection); +columns.Add(secondaryMaxVisualCalcProjection); +columns.Add(secondaryMinVisualCalcProjection); +columns.Add(primaryMaxVisualCalcProjection); +if (selectedVisual.Content.Visual.Objects == null) + selectedVisual.Content.Visual.Objects = new VisualDto.Objects(); +if (selectedVisual.Content.Visual.Objects.ValueAxis == null) + selectedVisual.Content.Visual.Objects.ValueAxis = new List(); +// Ensure there's at least one ObjectProperties entry +if (selectedVisual.Content.Visual.Objects.ValueAxis.Count == 0) +{ + selectedVisual.Content.Visual.Objects.ValueAxis.Add(new VisualDto.ObjectProperties + { + Properties = new Dictionary() + }); +} +var valueAxisProperties = selectedVisual.Content.Visual.Objects.ValueAxis[0].Properties; +// secondary axis min +valueAxisProperties["secStart"] = new VisualDto.VisualObjectProperty +{ + Expr = new VisualDto.VisualPropertyExpr + { + SelectRef = new VisualDto.SelectRefExpression + { + ExpressionName = "secondaryMin" + } + } +}; +// secondary axis max +valueAxisProperties["secEnd"] = new VisualDto.VisualObjectProperty +{ + Expr = new VisualDto.VisualPropertyExpr + { + SelectRef = new VisualDto.SelectRefExpression + { + ExpressionName = "secondaryMax" + } + } +}; +//main axis min +valueAxisProperties["start"] = new VisualDto.VisualObjectProperty +{ + Expr = new VisualDto.VisualPropertyExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = "0D" + } + } +}; +//main axis max +valueAxisProperties["end"] = new VisualDto.VisualObjectProperty +{ + Expr = new VisualDto.VisualPropertyExpr + { + SelectRef = new VisualDto.SelectRefExpression + { + ExpressionName = "primaryMax" + } + } +}; +Rx.SaveVisual(selectedVisual); +Info("Visual on page '" + + selectedVisual.ParentPage.Page.Name + + "' has been modified. Close and reopen the report to see the changes"); + +public static class Fx +{ + public static void CheckCompatibilityVersion(Model model, int requiredVersion, string customMessage = "Compatibility level must be raised to {0} to run this script. Do you want raise the compatibility level?") + { + if (model.Database.CompatibilityLevel < requiredVersion) + { + if (Fx.IsAnswerYes(String.Format("The model compatibility level is below {0}. " + customMessage, requiredVersion))) + { + model.Database.CompatibilityLevel = requiredVersion; + } + else + { + Info("Operation cancelled."); + return; + } + } + } + public static Function CreateFunction( + Model model, + string name, + string expression, + out bool functionCreated, + string description = null, + string annotationLabel = null, + string annotationValue = null, + string outputType = null, + string nameTemplate = null, + string formatString = null, + string displayFolder = null, + string outputDestination = null) + { + Function function = null as Function; + functionCreated = false; + var matchingFunctions = model.Functions.Where(f => f.GetAnnotation(annotationLabel) == annotationValue); + if (matchingFunctions.Count() == 1) + { + return matchingFunctions.First(); + } + else if (matchingFunctions.Count() == 0) + { + function = model.AddFunction(name); + function.Expression = expression; + function.Description = description; + functionCreated = true; + } + else + { + Error("More than one function found with annoation " + annotationLabel + " value " + annotationValue); + return null as Function; + } + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + function.SetAnnotation(annotationLabel, annotationValue); + } + if (!string.IsNullOrEmpty(outputType)) + { + function.SetAnnotation("outputType", outputType); + } + if (!string.IsNullOrEmpty(nameTemplate)) + { + function.SetAnnotation("nameTemplate", nameTemplate); + } + if (!string.IsNullOrEmpty(formatString)) + { + function.SetAnnotation("formatString", formatString); + } + if (!string.IsNullOrEmpty(displayFolder)) + { + function.SetAnnotation("displayFolder", displayFolder); + } + if (!string.IsNullOrEmpty(outputDestination)) + { + function.SetAnnotation("outputDestination", outputDestination); + } + return function; + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression = "FILTER({0},FALSE)") + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static Measure CreateMeasure( + Table table, + string measureName, + string measureExpression, + out bool measureCreated, + string formatString = null, + string displayFolder = null, + string description = null, + string annotationLabel = null, + string annotationValue = null, + bool isHidden = false) + { + measureCreated = false; + IEnumerable matchingMeasures = null as IEnumerable; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + matchingMeasures = table.Measures.Where(m => m.GetAnnotation(annotationLabel) == annotationValue); + } + else + { + matchingMeasures = table.Measures.Where(m => m.Name == measureName); + } + if (matchingMeasures.Count() == 1) + { + return matchingMeasures.First(); + } + else if (matchingMeasures.Count() == 0) + { + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = description; + measure.DisplayFolder = displayFolder; + measure.FormatString = formatString; + measureCreated = true; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + measure.SetAnnotation(annotationLabel, annotationValue); + } + measure.IsHidden = isHidden; + return measure; + } + else + { + Error("More than one measure found with annoation " + annotationLabel + " value " + annotationValue); + Output(matchingMeasures); + return null as Measure; + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + + + public static VisualExtended SelectTableVisual(ReportExtended report) + + { + + List visualTypes = new List + + { + + "tableEx","pivotTable" + + }; + + return SelectVisual(report: report, visualTypes); + + } + + + + + + + + public static VisualExtended SelectVisual(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: false, visualTypeList:visualTypeList) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: true, visualTypeList:visualTypeList) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect, List visualTypeList = null) + + { + + // Step 1: Build selection list + + var visualSelectionList = + + report.Pages + + .SelectMany(p => p.Visuals + + .Where(v => + + v?.Content != null && + + ( + + // If visualTypeList is null, do not filter at all + + (visualTypeList == null) || + + // If visualTypeList is provided and not empty, filter by it + + (visualTypeList.Count > 0 && v.Content.Visual != null && visualTypeList.Contains(v.Content?.Visual?.VisualType)) + + // Otherwise, include all visuals and visual groups + + || (visualTypeList.Count == 0) + + ) + + ) + + .Select(v => new + + { + + // Use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)(v.Content.Position?.X ?? 0), + + (int)(v.Content.Position?.Y ?? 0) + + ), + + Page = p, + + Visual = v + + } + + ) + + ) + + .ToList(); + + + + if (visualSelectionList.Count == 0) + + { + + if (visualTypeList != null) + + { + + string types = string.Join(", ", visualTypeList); + + Error(string.Format("No visual of type {0} were found", types)); + + + + }else + + { + + Error("No visuals found in the report."); + + } + + + + + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public FilterConfig FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + + [JsonProperty("tabOrder", NullValueHandling = NullValueHandling.Ignore)] + public int? TabOrder { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonProperty("fieldParameters")] public List FieldParameters { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FieldParameter + { + [JsonProperty("parameterExpr")] + public Field ParameterExpr { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("length")] + public int Length { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + [JsonProperty("columnFormatting")] public List ColumnFormatting { get; set; } + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public VisualPropertyExpr Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class VisualPropertyExpr + { + // Existing Field properties + [JsonProperty("Measure")] public MeasureObject Measure { get; set; } + [JsonProperty("Column")] public ColumnField Column { get; set; } + [JsonProperty("Aggregation")] public Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + + // New properties from JSON + [JsonProperty("SelectRef")] public SelectRefExpression SelectRef { get; set; } + [JsonProperty("Literal")] public VisualLiteral Literal { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SelectRefExpression + { + [JsonProperty("ExpressionName")] + public string ExpressionName { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ThemeDataColor + { + [JsonProperty("ColorId")] public int ColorId { get; set; } + [JsonProperty("Percent")] public double Percent { get; set; } + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + + public VisualLiteral Literal { get; set; } + + [JsonProperty("ThemeDataColor")] + public ThemeDataColor ThemeDataColor { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class FilterConfig + { + [JsonProperty("filters")] + public List Filters { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualFilter + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("filter")] public FilterDefinition Filter { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterDefinition + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Where")] public List Where { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterFrom + { + [JsonProperty("Name")] public string Name { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Type")] public int Type { get; set; } + [JsonProperty("Expression")] public FilterExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterExpression + { + [JsonProperty("Subquery")] public SubqueryExpression Subquery { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryExpression + { + [JsonProperty("Query")] public SubqueryQuery Query { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryQuery + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Select")] public List Select { get; set; } + [JsonProperty("OrderBy")] public List OrderBy { get; set; } + [JsonProperty("Top")] public int? Top { get; set; } + + [JsonProperty("Where")] public List Where { get; set; } // 🔹 Added + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + + public class SelectExpression + { + [JsonProperty("Column")] public ColumnSelect Column { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnSelect + { + [JsonProperty("Expression")] + public VisualDto.Expression Expression { get; set; } // NOTE: wrapper that contains "SourceRef" + + [JsonProperty("Property")] + public string Property { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByExpression + { + [JsonProperty("Direction")] public int Direction { get; set; } + [JsonProperty("Expression")] public OrderByInnerExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByInnerExpression + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterWhere + { + [JsonProperty("Condition")] public Condition Condition { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Condition + { + [JsonProperty("In")] public InExpression In { get; set; } + [JsonProperty("Not")] public NotExpression Not { get; set; } + [JsonProperty("Comparison")] public ComparisonExpression Comparison { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InExpression + { + [JsonProperty("Expressions")] public List Expressions { get; set; } + [JsonProperty("Table")] public InTable Table { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InTable + { + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NotExpression + { + [JsonProperty("Expression")] public Condition Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ComparisonExpression + { + [JsonProperty("ComparisonKind")] public int ComparisonKind { get; set; } + [JsonProperty("Left")] public FilterOperand Left { get; set; } + [JsonProperty("Right")] public FilterOperand Right { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterOperand + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + [JsonProperty("Literal")] public LiteralOperand Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class LiteralOperand + { + [JsonProperty("Value")] public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + public bool isVisualGroup => Content?.VisualGroup != null; + public bool isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if (Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig as VisualDto.FilterConfig)); + + return fields.Where(f => f != null); + } + + public IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color?.Expr?.FillRule?.Input != null) + yield return prop.Color.Expr.FillRule.Input; + + if (prop.Solid?.Color?.Expr?.FillRule?.Input != null) + yield return prop.Solid.Color.Expr.FillRule.Input; + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(VisualDto.FilterConfig filterConfig) + { + var fields = new List(); + + if (filterConfig?.Filters == null) + return fields; + + foreach (var filter in filterConfig.Filters ?? Enumerable.Empty()) + { + if (filter.Field != null) + fields.Add(filter.Field); + + if (filter.Filter != null) + { + var aliasMap = BuildAliasMap(filter.Filter.From); + + foreach (var from in filter.Filter.From ?? Enumerable.Empty()) + { + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + } + + foreach (var where in filter.Filter.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + } + + return fields; + } + + private void ExtractFieldsFromSubquery(VisualDto.SubqueryQuery query, List fields) + { + var aliasMap = BuildAliasMap(query.From); + + // SELECT columns + foreach (var sel in query.Select ?? Enumerable.Empty()) + { + var srcRef = sel.Column?.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + var columnExpr = sel.Column ?? new VisualDto.ColumnSelect(); + columnExpr.Expression ??= new VisualDto.Expression(); + columnExpr.Expression.SourceRef ??= new VisualDto.SourceRef(); + columnExpr.Expression.SourceRef.Source = srcRef.Source; + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = sel.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = columnExpr.Expression.SourceRef + } + } + }); + } + + // ORDER BY measures + foreach (var ob in query.OrderBy ?? Enumerable.Empty()) + { + var measureExpr = ob.Expression?.Measure?.Expression ?? new VisualDto.Expression(); + measureExpr.SourceRef ??= new VisualDto.SourceRef(); + measureExpr.SourceRef.Source = ResolveSource(measureExpr.SourceRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = ob.Expression.Measure.Property, + Expression = measureExpr + } + }); + } + + // Nested subqueries + foreach (var from in query.From ?? Enumerable.Empty()) + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + + // WHERE conditions + foreach (var where in query.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + private Dictionary BuildAliasMap(List fromList) + { + var map = new Dictionary(); + foreach (var from in fromList ?? Enumerable.Empty()) + { + if (!string.IsNullOrEmpty(from.Name) && !string.IsNullOrEmpty(from.Entity)) + map[from.Name] = from.Entity; + } + return map; + } + + private string ResolveSource(string source, Dictionary aliasMap) + { + if (string.IsNullOrEmpty(source)) + return source; + return aliasMap.TryGetValue(source, out var entity) ? entity : source; + } + + private void ExtractFieldsFromCondition(VisualDto.Condition condition, List fields, Dictionary aliasMap) + { + if (condition == null) return; + + // IN Expression + if (condition.In != null) + { + foreach (var expr in condition.In.Expressions ?? Enumerable.Empty()) + { + var srcRef = expr.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = expr.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + + // NOT Expression + if (condition.Not != null) + ExtractFieldsFromCondition(condition.Not.Expression, fields, aliasMap); + + // COMPARISON Expression + if (condition.Comparison != null) + { + AddOperandField(condition.Comparison.Left, fields, aliasMap); + AddOperandField(condition.Comparison.Right, fields, aliasMap); + } + } + private void AddOperandField(VisualDto.FilterOperand operand, List fields, Dictionary aliasMap) + { + if (operand == null) return; + + // MEASURE + if (operand.Measure != null) + { + var srcRef = operand.Measure.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = operand.Measure.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + + // COLUMN + if (operand.Column != null) + { + var srcRef = operand.Column.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = operand.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure && newField.Measure != null) + { + // Preserve Expression with SourceRef + f.Measure ??= new VisualDto.MeasureObject(); + f.Measure.Property = newField.Measure.Property; + f.Measure.Expression ??= new VisualDto.Expression(); + f.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Measure.Expression.SourceRef.Entity, + Source = newField.Measure.Expression.SourceRef.Source + } + : f.Measure.Expression.SourceRef; + f.Column = null; + wasModified = true; + } + else if (!isMeasure && newField.Column != null) + { + // Preserve Expression with SourceRef + f.Column ??= new VisualDto.ColumnField(); + f.Column.Property = newField.Column.Property; + f.Column.Expression ??= new VisualDto.Expression(); + f.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Column.Expression.SourceRef.Entity, + Source = newField.Column.Expression.SourceRef.Source + } + : f.Column.Expression.SourceRef; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? proj.Field.Measure.Expression?.SourceRef?.Entity + : proj.Field.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? proj.Field.Measure.Property + : proj.Field.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure ??= new VisualDto.MeasureObject(); + prop.Expr.Measure.Property = newField.Measure.Property; + prop.Expr.Measure.Expression ??= new VisualDto.Expression(); + prop.Expr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column ??= new VisualDto.ColumnField(); + prop.Expr.Column.Property = newField.Column.Property; + prop.Expr.Column.Expression ??= new VisualDto.Expression(); + prop.Expr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure ??= new VisualDto.MeasureObject(); + fillInput.Measure.Property = newField.Measure.Property; + fillInput.Measure.Expression ??= new VisualDto.Expression(); + fillInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column ??= new VisualDto.ColumnField(); + fillInput.Column.Property = newField.Column.Property; + fillInput.Column.Expression ??= new VisualDto.Expression(); + fillInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure ??= new VisualDto.MeasureObject(); + solidInput.Measure.Property = newField.Measure.Property; + solidInput.Measure.Expression ??= new VisualDto.Expression(); + solidInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column ??= new VisualDto.ColumnField(); + solidInput.Column.Property = newField.Column.Property; + solidInput.Column.Expression ??= new VisualDto.Expression(); + solidInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidInput.Measure = null; + wasModified = true; + } + } + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure ??= new VisualDto.MeasureObject(); + solidExpr.Measure.Property = newField.Measure.Property; + solidExpr.Measure.Expression ??= new VisualDto.Expression(); + solidExpr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column ??= new VisualDto.ColumnField(); + solidExpr.Column.Property = newField.Column.Property; + solidExpr.Column.Expression ??= new VisualDto.Expression(); + solidExpr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + //if (Content.FilterConfig != null) + //{ + // var filterConfigString = Content.FilterConfig.ToString(); + // string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + // string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + // string oldPattern = oldFieldKey; + // string newPattern = $"'{table}'[{prop}]"; + + // if (filterConfigString.Contains(oldPattern)) + // { + // Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + // wasModified = true; + // } + //} + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + } + + } + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Copy Conditional Formatting Between headers.csx b/Advanced/Report Layer Macros/Copy Conditional Formatting Between headers.csx new file mode 100644 index 0000000..27a2008 --- /dev/null +++ b/Advanced/Report Layer Macros/Copy Conditional Formatting Between headers.csx @@ -0,0 +1,3022 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +// 2025-10-22 / B.Agullo +// Copies conditional formatting from one header to multiple other headers in a table visual. + +#if TE3 +ScriptHelper.WaitFormVisible = false; +#endif +// Step 1: Initialize report +ReportExtended report = Rx.InitReport(); +if (report == null) return; +VisualExtended selectedVisual = Rx.SelectTableVisual(report); +if (selectedVisual == null) return; +// Step 2: Extract all headers from projections (not just those with formatting) +var projectionHeaders = selectedVisual.Content?.Visual?.Query?.QueryState?.Values?.Projections + .Select(p => p.QueryRef) + .Where(h => !string.IsNullOrEmpty(h)) + .Distinct() + .ToList(); +if (projectionHeaders == null || projectionHeaders.Count == 0) +{ + Error("No headers found in the visual projections."); + return; +} +// Step 3: Extract all displayed headers (with formatting objects) +var formattedHeaders = selectedVisual.Content?.Visual?.Objects?.Values? + .Select(cf => cf.Selector?.Metadata) + .Where(h => !string.IsNullOrEmpty(h)) + .Distinct() + .ToList(); +// Step 4: Let user choose the source header for formatting (from all projection headers) +string sourceHeader = Fx.ChooseString( + OptionList: projectionHeaders, + label: "Select the header to copy formatting from" +); +if (string.IsNullOrEmpty(sourceHeader)) return; +// Step 5: Let user choose target headers (multi-select, exclude source) +List targetHeaders = Fx.ChooseStringMultiple( + OptionList: projectionHeaders.Where(h => h != sourceHeader).ToList(), + label: "Select headers to apply the formatting to" +); +if (targetHeaders == null || targetHeaders.Count == 0) +{ + Info("No target headers selected."); + return; +} +// Step 6: Get source formatting (excluding selector) +var sourceFormatting = selectedVisual.Content.Visual.Objects.Values + .FirstOrDefault(cf => cf.Selector?.Metadata == sourceHeader); +if (sourceFormatting == null) +{ + Error("Source header formatting not found."); + return; +} +// Step 7: Apply formatting to target headers +int updatedCount = 0; +foreach (var targetHeader in targetHeaders) +{ + var targetFormatting = selectedVisual.Content.Visual.Objects.Values + .FirstOrDefault(cf => cf.Selector != null && cf.Selector.Metadata == targetHeader); + if (targetFormatting != null) + { + // Copy all properties except Selector + var sourceProps = typeof(VisualDto.ObjectProperties).GetProperties(); + foreach (var prop in sourceProps) + { + if (prop.Name == "Selector") continue; + prop.SetValue(targetFormatting, prop.GetValue(sourceFormatting, null), null); + } + updatedCount++; + } + else + { + // Create new ObjectProperties and copy all except Selector + var newFormatting = new VisualDto.ObjectProperties(); + var sourceProps = typeof(VisualDto.ObjectProperties).GetProperties(); + foreach (var prop in sourceProps) + { + if (prop.Name == "Selector") + { + // Create new Selector and set Metadata to targetHeader + newFormatting.Selector = new VisualDto.Selector { + Data = new List() + { + new VisualDto.DataObject { + DataViewWildcard = new VisualDto.DataViewWildcard() + { + MatchingOption = 1 + } + } + }, + Metadata = targetHeader + }; + } + else + { + prop.SetValue(newFormatting, prop.GetValue(sourceFormatting, null), null); + } + } + if (selectedVisual.Content.Visual.Objects.Values == null) + selectedVisual.Content.Visual.Objects.Values = new List(); + selectedVisual.Content.Visual.Objects.Values.Add(newFormatting); + updatedCount++; + } +} +Rx.SaveVisual(selectedVisual); +Output(String.Format(@"{0} headers updated with formatting from '{1}'.", updatedCount, sourceHeader)); + +public static class Fx +{ + public static void CheckCompatibilityVersion(Model model, int requiredVersion, string customMessage = "Compatibility level must be raised to {0} to run this script. Do you want raise the compatibility level?") + { + if (model.Database.CompatibilityLevel < requiredVersion) + { + if (Fx.IsAnswerYes(String.Format("The model compatibility level is below {0}. " + customMessage, requiredVersion))) + { + model.Database.CompatibilityLevel = requiredVersion; + } + else + { + Info("Operation cancelled."); + return; + } + } + } + public static Function CreateFunction( + Model model, + string name, + string expression, + out bool functionCreated, + string description = null, + string annotationLabel = null, + string annotationValue = null, + string outputType = null, + string nameTemplate = null, + string formatString = null, + string displayFolder = null, + string outputDestination = null) + { + Function function = null as Function; + functionCreated = false; + var matchingFunctions = model.Functions.Where(f => f.GetAnnotation(annotationLabel) == annotationValue); + if (matchingFunctions.Count() == 1) + { + return matchingFunctions.First(); + } + else if (matchingFunctions.Count() == 0) + { + function = model.AddFunction(name); + function.Expression = expression; + function.Description = description; + functionCreated = true; + } + else + { + Error("More than one function found with annoation " + annotationLabel + " value " + annotationValue); + return null as Function; + } + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + function.SetAnnotation(annotationLabel, annotationValue); + } + if (!string.IsNullOrEmpty(outputType)) + { + function.SetAnnotation("outputType", outputType); + } + if (!string.IsNullOrEmpty(nameTemplate)) + { + function.SetAnnotation("nameTemplate", nameTemplate); + } + if (!string.IsNullOrEmpty(formatString)) + { + function.SetAnnotation("formatString", formatString); + } + if (!string.IsNullOrEmpty(displayFolder)) + { + function.SetAnnotation("displayFolder", displayFolder); + } + if (!string.IsNullOrEmpty(outputDestination)) + { + function.SetAnnotation("outputDestination", outputDestination); + } + return function; + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression = "FILTER({0},FALSE)") + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static Measure CreateMeasure( + Table table, + string measureName, + string measureExpression, + out bool measureCreated, + string formatString = null, + string displayFolder = null, + string description = null, + string annotationLabel = null, + string annotationValue = null, + bool isHidden = false) + { + measureCreated = false; + IEnumerable matchingMeasures = null as IEnumerable; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + matchingMeasures = table.Measures.Where(m => m.GetAnnotation(annotationLabel) == annotationValue); + } + else + { + matchingMeasures = table.Measures.Where(m => m.Name == measureName); + } + if (matchingMeasures.Count() == 1) + { + return matchingMeasures.First(); + } + else if (matchingMeasures.Count() == 0) + { + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = description; + measure.DisplayFolder = displayFolder; + measure.FormatString = formatString; + measureCreated = true; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + measure.SetAnnotation(annotationLabel, annotationValue); + } + measure.IsHidden = isHidden; + return measure; + } + else + { + Error("More than one measure found with annoation " + annotationLabel + " value " + annotationValue); + Output(matchingMeasures); + return null as Measure; + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + + + public static VisualExtended SelectTableVisual(ReportExtended report) + + { + + List visualTypes = new List + + { + + "tableEx","pivotTable" + + }; + + return SelectVisual(report: report, visualTypes); + + } + + + + + + + + public static VisualExtended SelectVisual(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: false, visualTypeList:visualTypeList) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: true, visualTypeList:visualTypeList) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect, List visualTypeList = null) + + { + + // Step 1: Build selection list + + var visualSelectionList = + + report.Pages + + .SelectMany(p => p.Visuals + + .Where(v => + + v?.Content != null && + + ( + + // If visualTypeList is null, do not filter at all + + (visualTypeList == null) || + + // If visualTypeList is provided and not empty, filter by it + + (visualTypeList.Count > 0 && v.Content.Visual != null && visualTypeList.Contains(v.Content?.Visual?.VisualType)) + + // Otherwise, include all visuals and visual groups + + || (visualTypeList.Count == 0) + + ) + + ) + + .Select(v => new + + { + + // Use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)(v.Content.Position?.X ?? 0), + + (int)(v.Content.Position?.Y ?? 0) + + ), + + Page = p, + + Visual = v + + } + + ) + + ) + + .ToList(); + + + + if (visualSelectionList.Count == 0) + + { + + if (visualTypeList != null) + + { + + string types = string.Join(", ", visualTypeList); + + Error(string.Format("No visual of type {0} were found", types)); + + + + }else + + { + + Error("No visuals found in the report."); + + } + + + + + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public FilterConfig FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + + [JsonProperty("tabOrder", NullValueHandling = NullValueHandling.Ignore)] + public int? TabOrder { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonProperty("fieldParameters")] public List FieldParameters { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FieldParameter + { + [JsonProperty("parameterExpr")] + public Field ParameterExpr { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("length")] + public int Length { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + [JsonProperty("columnFormatting")] public List ColumnFormatting { get; set; } + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public VisualPropertyExpr Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class VisualPropertyExpr + { + // Existing Field properties + [JsonProperty("Measure")] public MeasureObject Measure { get; set; } + [JsonProperty("Column")] public ColumnField Column { get; set; } + [JsonProperty("Aggregation")] public Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + + // New properties from JSON + [JsonProperty("SelectRef")] public SelectRefExpression SelectRef { get; set; } + [JsonProperty("Literal")] public VisualLiteral Literal { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SelectRefExpression + { + [JsonProperty("ExpressionName")] + public string ExpressionName { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ThemeDataColor + { + [JsonProperty("ColorId")] public int ColorId { get; set; } + [JsonProperty("Percent")] public double Percent { get; set; } + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + + public VisualLiteral Literal { get; set; } + + [JsonProperty("ThemeDataColor")] + public ThemeDataColor ThemeDataColor { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataObject + { + [JsonProperty("dataViewWildcard")] + public DataViewWildcard DataViewWildcard { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataViewWildcard + { + [JsonProperty("matchingOption")] + public int MatchingOption { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterConfig + { + [JsonProperty("filters")] + public List Filters { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualFilter + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("filter")] public FilterDefinition Filter { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterDefinition + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Where")] public List Where { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterFrom + { + [JsonProperty("Name")] public string Name { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Type")] public int Type { get; set; } + [JsonProperty("Expression")] public FilterExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterExpression + { + [JsonProperty("Subquery")] public SubqueryExpression Subquery { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryExpression + { + [JsonProperty("Query")] public SubqueryQuery Query { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryQuery + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Select")] public List Select { get; set; } + [JsonProperty("OrderBy")] public List OrderBy { get; set; } + [JsonProperty("Top")] public int? Top { get; set; } + + [JsonProperty("Where")] public List Where { get; set; } // 🔹 Added + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + + public class SelectExpression + { + [JsonProperty("Column")] public ColumnSelect Column { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnSelect + { + [JsonProperty("Expression")] + public VisualDto.Expression Expression { get; set; } // NOTE: wrapper that contains "SourceRef" + + [JsonProperty("Property")] + public string Property { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByExpression + { + [JsonProperty("Direction")] public int Direction { get; set; } + [JsonProperty("Expression")] public OrderByInnerExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByInnerExpression + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterWhere + { + [JsonProperty("Condition")] public Condition Condition { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Condition + { + [JsonProperty("In")] public InExpression In { get; set; } + [JsonProperty("Not")] public NotExpression Not { get; set; } + [JsonProperty("Comparison")] public ComparisonExpression Comparison { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InExpression + { + [JsonProperty("Expressions")] public List Expressions { get; set; } + [JsonProperty("Table")] public InTable Table { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InTable + { + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NotExpression + { + [JsonProperty("Expression")] public Condition Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ComparisonExpression + { + [JsonProperty("ComparisonKind")] public int ComparisonKind { get; set; } + [JsonProperty("Left")] public FilterOperand Left { get; set; } + [JsonProperty("Right")] public FilterOperand Right { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterOperand + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + [JsonProperty("Literal")] public LiteralOperand Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class LiteralOperand + { + [JsonProperty("Value")] public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + public bool isVisualGroup => Content?.VisualGroup != null; + public bool isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if (Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig as VisualDto.FilterConfig)); + + return fields.Where(f => f != null); + } + + public IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color?.Expr?.FillRule?.Input != null) + yield return prop.Color.Expr.FillRule.Input; + + if (prop.Solid?.Color?.Expr?.FillRule?.Input != null) + yield return prop.Solid.Color.Expr.FillRule.Input; + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(VisualDto.FilterConfig filterConfig) + { + var fields = new List(); + + if (filterConfig?.Filters == null) + return fields; + + foreach (var filter in filterConfig.Filters ?? Enumerable.Empty()) + { + if (filter.Field != null) + fields.Add(filter.Field); + + if (filter.Filter != null) + { + var aliasMap = BuildAliasMap(filter.Filter.From); + + foreach (var from in filter.Filter.From ?? Enumerable.Empty()) + { + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + } + + foreach (var where in filter.Filter.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + } + + return fields; + } + + private void ExtractFieldsFromSubquery(VisualDto.SubqueryQuery query, List fields) + { + var aliasMap = BuildAliasMap(query.From); + + // SELECT columns + foreach (var sel in query.Select ?? Enumerable.Empty()) + { + var srcRef = sel.Column?.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + var columnExpr = sel.Column ?? new VisualDto.ColumnSelect(); + columnExpr.Expression ??= new VisualDto.Expression(); + columnExpr.Expression.SourceRef ??= new VisualDto.SourceRef(); + columnExpr.Expression.SourceRef.Source = srcRef.Source; + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = sel.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = columnExpr.Expression.SourceRef + } + } + }); + } + + // ORDER BY measures + foreach (var ob in query.OrderBy ?? Enumerable.Empty()) + { + var measureExpr = ob.Expression?.Measure?.Expression ?? new VisualDto.Expression(); + measureExpr.SourceRef ??= new VisualDto.SourceRef(); + measureExpr.SourceRef.Source = ResolveSource(measureExpr.SourceRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = ob.Expression.Measure.Property, + Expression = measureExpr + } + }); + } + + // Nested subqueries + foreach (var from in query.From ?? Enumerable.Empty()) + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + + // WHERE conditions + foreach (var where in query.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + private Dictionary BuildAliasMap(List fromList) + { + var map = new Dictionary(); + foreach (var from in fromList ?? Enumerable.Empty()) + { + if (!string.IsNullOrEmpty(from.Name) && !string.IsNullOrEmpty(from.Entity)) + map[from.Name] = from.Entity; + } + return map; + } + + private string ResolveSource(string source, Dictionary aliasMap) + { + if (string.IsNullOrEmpty(source)) + return source; + return aliasMap.TryGetValue(source, out var entity) ? entity : source; + } + + private void ExtractFieldsFromCondition(VisualDto.Condition condition, List fields, Dictionary aliasMap) + { + if (condition == null) return; + + // IN Expression + if (condition.In != null) + { + foreach (var expr in condition.In.Expressions ?? Enumerable.Empty()) + { + var srcRef = expr.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = expr.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + + // NOT Expression + if (condition.Not != null) + ExtractFieldsFromCondition(condition.Not.Expression, fields, aliasMap); + + // COMPARISON Expression + if (condition.Comparison != null) + { + AddOperandField(condition.Comparison.Left, fields, aliasMap); + AddOperandField(condition.Comparison.Right, fields, aliasMap); + } + } + private void AddOperandField(VisualDto.FilterOperand operand, List fields, Dictionary aliasMap) + { + if (operand == null) return; + + // MEASURE + if (operand.Measure != null) + { + var srcRef = operand.Measure.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = operand.Measure.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + + // COLUMN + if (operand.Column != null) + { + var srcRef = operand.Column.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = operand.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure && newField.Measure != null) + { + // Preserve Expression with SourceRef + f.Measure ??= new VisualDto.MeasureObject(); + f.Measure.Property = newField.Measure.Property; + f.Measure.Expression ??= new VisualDto.Expression(); + f.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Measure.Expression.SourceRef.Entity, + Source = newField.Measure.Expression.SourceRef.Source + } + : f.Measure.Expression.SourceRef; + f.Column = null; + wasModified = true; + } + else if (!isMeasure && newField.Column != null) + { + // Preserve Expression with SourceRef + f.Column ??= new VisualDto.ColumnField(); + f.Column.Property = newField.Column.Property; + f.Column.Expression ??= new VisualDto.Expression(); + f.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Column.Expression.SourceRef.Entity, + Source = newField.Column.Expression.SourceRef.Source + } + : f.Column.Expression.SourceRef; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? proj.Field.Measure.Expression?.SourceRef?.Entity + : proj.Field.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? proj.Field.Measure.Property + : proj.Field.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure ??= new VisualDto.MeasureObject(); + prop.Expr.Measure.Property = newField.Measure.Property; + prop.Expr.Measure.Expression ??= new VisualDto.Expression(); + prop.Expr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column ??= new VisualDto.ColumnField(); + prop.Expr.Column.Property = newField.Column.Property; + prop.Expr.Column.Expression ??= new VisualDto.Expression(); + prop.Expr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure ??= new VisualDto.MeasureObject(); + fillInput.Measure.Property = newField.Measure.Property; + fillInput.Measure.Expression ??= new VisualDto.Expression(); + fillInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column ??= new VisualDto.ColumnField(); + fillInput.Column.Property = newField.Column.Property; + fillInput.Column.Expression ??= new VisualDto.Expression(); + fillInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure ??= new VisualDto.MeasureObject(); + solidInput.Measure.Property = newField.Measure.Property; + solidInput.Measure.Expression ??= new VisualDto.Expression(); + solidInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column ??= new VisualDto.ColumnField(); + solidInput.Column.Property = newField.Column.Property; + solidInput.Column.Expression ??= new VisualDto.Expression(); + solidInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidInput.Measure = null; + wasModified = true; + } + } + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure ??= new VisualDto.MeasureObject(); + solidExpr.Measure.Property = newField.Measure.Property; + solidExpr.Measure.Expression ??= new VisualDto.Expression(); + solidExpr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column ??= new VisualDto.ColumnField(); + solidExpr.Column.Property = newField.Column.Property; + solidExpr.Column.Expression ??= new VisualDto.Expression(); + solidExpr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + //if (Content.FilterConfig != null) + //{ + // var filterConfigString = Content.FilterConfig.ToString(); + // string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + // string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + // string oldPattern = oldFieldKey; + // string newPattern = $"'{table}'[{prop}]"; + + // if (filterConfigString.Contains(oldPattern)) + // { + // Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + // wasModified = true; + // } + //} + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + } + + } + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Copy Header Formatting between headers.csx b/Advanced/Report Layer Macros/Copy Header Formatting between headers.csx new file mode 100644 index 0000000..229a5c3 --- /dev/null +++ b/Advanced/Report Layer Macros/Copy Header Formatting between headers.csx @@ -0,0 +1,3010 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +// 2025-10-22/B.Agullo +// This script copies column header formatting from one header to multiple other headers in a table visual. +#if TE3 +ScriptHelper.WaitFormVisible = false; +#endif +// Step 1: Initialize report +ReportExtended report = Rx.InitReport(); +if (report == null) return; +VisualExtended selectedVisual = Rx.SelectTableVisual(report); +if(selectedVisual == null) return; +// Step 2: Extract all headers from projections (not just those with formatting) +var projectionHeaders = selectedVisual.Content?.Visual?.Query?.QueryState?.Values?.Projections + .Select(p => p.QueryRef) + .Where(h => !string.IsNullOrEmpty(h)) + .Distinct() + .ToList(); +if (projectionHeaders == null || projectionHeaders.Count == 0) +{ + Error("No headers found in the visual projections."); + return; +} +// Step 3: Extract all displayed headers (with formatting objects) +var formattedHeaders = selectedVisual.Content?.Visual?.Objects?.ColumnFormatting? + .Select(cf => cf.Selector?.Metadata) + .Where(h => !string.IsNullOrEmpty(h)) + .Distinct() + .ToList(); +// Step 4: Let user choose the source header for formatting (from all projection headers) +string sourceHeader = Fx.ChooseString( + OptionList: projectionHeaders, + label: "Select the header to copy formatting from" +); +if (string.IsNullOrEmpty(sourceHeader)) return; +// Step 5: Let user choose target headers (multi-select, exclude source) +List targetHeaders = Fx.ChooseStringMultiple( + OptionList: projectionHeaders.Where(h => h != sourceHeader).ToList(), + label: "Select headers to apply the formatting to" +); +if (targetHeaders == null || targetHeaders.Count == 0) +{ + Info("No target headers selected."); + return; +} +// Step 6: Get source formatting (excluding selector) +var sourceFormatting = selectedVisual.Content.Visual.Objects.ColumnFormatting + .FirstOrDefault(cf => cf.Selector?.Metadata == sourceHeader); +if (sourceFormatting == null) +{ + Error("Source header formatting not found."); + return; +} +// Step 7: Apply formatting to target headers +int updatedCount = 0; +foreach (var targetHeader in targetHeaders) +{ + var targetFormatting = selectedVisual.Content.Visual.Objects.ColumnFormatting + .FirstOrDefault(cf => cf.Selector != null && cf.Selector.Metadata == targetHeader); + if (targetFormatting != null) + { + // Copy all properties except Selector + var sourceProps = typeof(VisualDto.ObjectProperties).GetProperties(); + foreach (var prop in sourceProps) + { + if (prop.Name == "Selector") continue; + prop.SetValue(targetFormatting, prop.GetValue(sourceFormatting, null), null); + } + updatedCount++; + } + else + { + // Create new ObjectProperties and copy all except Selector + var newFormatting = new VisualDto.ObjectProperties(); + var sourceProps = typeof(VisualDto.ObjectProperties).GetProperties(); + foreach (var prop in sourceProps) + { + if (prop.Name == "Selector") + { + // Create new Selector and set Metadata to targetHeader + newFormatting.Selector = new VisualDto.Selector { Metadata = targetHeader }; + } + else + { + prop.SetValue(newFormatting, prop.GetValue(sourceFormatting, null), null); + } + } + if (selectedVisual.Content.Visual.Objects.ColumnFormatting == null) + selectedVisual.Content.Visual.Objects.ColumnFormatting = new List(); + selectedVisual.Content.Visual.Objects.ColumnFormatting.Add(newFormatting); + updatedCount++; + } +} +Rx.SaveVisual(selectedVisual); +Output(String.Format(@"{0} headers updated with formatting from '{1}'.", updatedCount, sourceHeader)); + +public static class Fx +{ + public static void CheckCompatibilityVersion(Model model, int requiredVersion, string customMessage = "Compatibility level must be raised to {0} to run this script. Do you want raise the compatibility level?") + { + if (model.Database.CompatibilityLevel < requiredVersion) + { + if (Fx.IsAnswerYes(String.Format("The model compatibility level is below {0}. " + customMessage, requiredVersion))) + { + model.Database.CompatibilityLevel = requiredVersion; + } + else + { + Info("Operation cancelled."); + return; + } + } + } + public static Function CreateFunction( + Model model, + string name, + string expression, + out bool functionCreated, + string description = null, + string annotationLabel = null, + string annotationValue = null, + string outputType = null, + string nameTemplate = null, + string formatString = null, + string displayFolder = null, + string outputDestination = null) + { + Function function = null as Function; + functionCreated = false; + var matchingFunctions = model.Functions.Where(f => f.GetAnnotation(annotationLabel) == annotationValue); + if (matchingFunctions.Count() == 1) + { + return matchingFunctions.First(); + } + else if (matchingFunctions.Count() == 0) + { + function = model.AddFunction(name); + function.Expression = expression; + function.Description = description; + functionCreated = true; + } + else + { + Error("More than one function found with annoation " + annotationLabel + " value " + annotationValue); + return null as Function; + } + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + function.SetAnnotation(annotationLabel, annotationValue); + } + if (!string.IsNullOrEmpty(outputType)) + { + function.SetAnnotation("outputType", outputType); + } + if (!string.IsNullOrEmpty(nameTemplate)) + { + function.SetAnnotation("nameTemplate", nameTemplate); + } + if (!string.IsNullOrEmpty(formatString)) + { + function.SetAnnotation("formatString", formatString); + } + if (!string.IsNullOrEmpty(displayFolder)) + { + function.SetAnnotation("displayFolder", displayFolder); + } + if (!string.IsNullOrEmpty(outputDestination)) + { + function.SetAnnotation("outputDestination", outputDestination); + } + return function; + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression = "FILTER({0},FALSE)") + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static Measure CreateMeasure( + Table table, + string measureName, + string measureExpression, + out bool measureCreated, + string formatString = null, + string displayFolder = null, + string description = null, + string annotationLabel = null, + string annotationValue = null, + bool isHidden = false) + { + measureCreated = false; + IEnumerable matchingMeasures = null as IEnumerable; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + matchingMeasures = table.Measures.Where(m => m.GetAnnotation(annotationLabel) == annotationValue); + } + else + { + matchingMeasures = table.Measures.Where(m => m.Name == measureName); + } + if (matchingMeasures.Count() == 1) + { + return matchingMeasures.First(); + } + else if (matchingMeasures.Count() == 0) + { + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = description; + measure.DisplayFolder = displayFolder; + measure.FormatString = formatString; + measureCreated = true; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + measure.SetAnnotation(annotationLabel, annotationValue); + } + measure.IsHidden = isHidden; + return measure; + } + else + { + Error("More than one measure found with annoation " + annotationLabel + " value " + annotationValue); + Output(matchingMeasures); + return null as Measure; + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + + + public static VisualExtended SelectTableVisual(ReportExtended report) + + { + + List visualTypes = new List + + { + + "tableEx","pivotTable" + + }; + + return SelectVisual(report: report, visualTypes); + + } + + + + + + + + public static VisualExtended SelectVisual(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: false, visualTypeList:visualTypeList) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: true, visualTypeList:visualTypeList) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect, List visualTypeList = null) + + { + + // Step 1: Build selection list + + var visualSelectionList = + + report.Pages + + .SelectMany(p => p.Visuals + + .Where(v => + + v?.Content != null && + + ( + + // If visualTypeList is null, do not filter at all + + (visualTypeList == null) || + + // If visualTypeList is provided and not empty, filter by it + + (visualTypeList.Count > 0 && v.Content.Visual != null && visualTypeList.Contains(v.Content?.Visual?.VisualType)) + + // Otherwise, include all visuals and visual groups + + || (visualTypeList.Count == 0) + + ) + + ) + + .Select(v => new + + { + + // Use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)(v.Content.Position?.X ?? 0), + + (int)(v.Content.Position?.Y ?? 0) + + ), + + Page = p, + + Visual = v + + } + + ) + + ) + + .ToList(); + + + + if (visualSelectionList.Count == 0) + + { + + if (visualTypeList != null) + + { + + string types = string.Join(", ", visualTypeList); + + Error(string.Format("No visual of type {0} were found", types)); + + + + }else + + { + + Error("No visuals found in the report."); + + } + + + + + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public FilterConfig FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + + [JsonProperty("tabOrder", NullValueHandling = NullValueHandling.Ignore)] + public int? TabOrder { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonProperty("fieldParameters")] public List FieldParameters { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FieldParameter + { + [JsonProperty("parameterExpr")] + public Field ParameterExpr { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("length")] + public int Length { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + [JsonProperty("columnFormatting")] public List ColumnFormatting { get; set; } + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public VisualPropertyExpr Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class VisualPropertyExpr + { + // Existing Field properties + [JsonProperty("Measure")] public MeasureObject Measure { get; set; } + [JsonProperty("Column")] public ColumnField Column { get; set; } + [JsonProperty("Aggregation")] public Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + + // New properties from JSON + [JsonProperty("SelectRef")] public SelectRefExpression SelectRef { get; set; } + [JsonProperty("Literal")] public VisualLiteral Literal { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SelectRefExpression + { + [JsonProperty("ExpressionName")] + public string ExpressionName { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ThemeDataColor + { + [JsonProperty("ColorId")] public int ColorId { get; set; } + [JsonProperty("Percent")] public double Percent { get; set; } + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + + public VisualLiteral Literal { get; set; } + + [JsonProperty("ThemeDataColor")] + public ThemeDataColor ThemeDataColor { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataObject + { + [JsonProperty("dataViewWildcard")] + public DataViewWildcard DataViewWildcard { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataViewWildcard + { + [JsonProperty("matchingOption")] + public int MatchingOption { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterConfig + { + [JsonProperty("filters")] + public List Filters { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualFilter + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("filter")] public FilterDefinition Filter { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterDefinition + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Where")] public List Where { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterFrom + { + [JsonProperty("Name")] public string Name { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Type")] public int Type { get; set; } + [JsonProperty("Expression")] public FilterExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterExpression + { + [JsonProperty("Subquery")] public SubqueryExpression Subquery { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryExpression + { + [JsonProperty("Query")] public SubqueryQuery Query { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryQuery + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Select")] public List Select { get; set; } + [JsonProperty("OrderBy")] public List OrderBy { get; set; } + [JsonProperty("Top")] public int? Top { get; set; } + + [JsonProperty("Where")] public List Where { get; set; } // 🔹 Added + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + + public class SelectExpression + { + [JsonProperty("Column")] public ColumnSelect Column { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnSelect + { + [JsonProperty("Expression")] + public VisualDto.Expression Expression { get; set; } // NOTE: wrapper that contains "SourceRef" + + [JsonProperty("Property")] + public string Property { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByExpression + { + [JsonProperty("Direction")] public int Direction { get; set; } + [JsonProperty("Expression")] public OrderByInnerExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByInnerExpression + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterWhere + { + [JsonProperty("Condition")] public Condition Condition { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Condition + { + [JsonProperty("In")] public InExpression In { get; set; } + [JsonProperty("Not")] public NotExpression Not { get; set; } + [JsonProperty("Comparison")] public ComparisonExpression Comparison { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InExpression + { + [JsonProperty("Expressions")] public List Expressions { get; set; } + [JsonProperty("Table")] public InTable Table { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InTable + { + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NotExpression + { + [JsonProperty("Expression")] public Condition Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ComparisonExpression + { + [JsonProperty("ComparisonKind")] public int ComparisonKind { get; set; } + [JsonProperty("Left")] public FilterOperand Left { get; set; } + [JsonProperty("Right")] public FilterOperand Right { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterOperand + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + [JsonProperty("Literal")] public LiteralOperand Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class LiteralOperand + { + [JsonProperty("Value")] public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + public bool isVisualGroup => Content?.VisualGroup != null; + public bool isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if (Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig as VisualDto.FilterConfig)); + + return fields.Where(f => f != null); + } + + public IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color?.Expr?.FillRule?.Input != null) + yield return prop.Color.Expr.FillRule.Input; + + if (prop.Solid?.Color?.Expr?.FillRule?.Input != null) + yield return prop.Solid.Color.Expr.FillRule.Input; + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(VisualDto.FilterConfig filterConfig) + { + var fields = new List(); + + if (filterConfig?.Filters == null) + return fields; + + foreach (var filter in filterConfig.Filters ?? Enumerable.Empty()) + { + if (filter.Field != null) + fields.Add(filter.Field); + + if (filter.Filter != null) + { + var aliasMap = BuildAliasMap(filter.Filter.From); + + foreach (var from in filter.Filter.From ?? Enumerable.Empty()) + { + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + } + + foreach (var where in filter.Filter.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + } + + return fields; + } + + private void ExtractFieldsFromSubquery(VisualDto.SubqueryQuery query, List fields) + { + var aliasMap = BuildAliasMap(query.From); + + // SELECT columns + foreach (var sel in query.Select ?? Enumerable.Empty()) + { + var srcRef = sel.Column?.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + var columnExpr = sel.Column ?? new VisualDto.ColumnSelect(); + columnExpr.Expression ??= new VisualDto.Expression(); + columnExpr.Expression.SourceRef ??= new VisualDto.SourceRef(); + columnExpr.Expression.SourceRef.Source = srcRef.Source; + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = sel.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = columnExpr.Expression.SourceRef + } + } + }); + } + + // ORDER BY measures + foreach (var ob in query.OrderBy ?? Enumerable.Empty()) + { + var measureExpr = ob.Expression?.Measure?.Expression ?? new VisualDto.Expression(); + measureExpr.SourceRef ??= new VisualDto.SourceRef(); + measureExpr.SourceRef.Source = ResolveSource(measureExpr.SourceRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = ob.Expression.Measure.Property, + Expression = measureExpr + } + }); + } + + // Nested subqueries + foreach (var from in query.From ?? Enumerable.Empty()) + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + + // WHERE conditions + foreach (var where in query.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + private Dictionary BuildAliasMap(List fromList) + { + var map = new Dictionary(); + foreach (var from in fromList ?? Enumerable.Empty()) + { + if (!string.IsNullOrEmpty(from.Name) && !string.IsNullOrEmpty(from.Entity)) + map[from.Name] = from.Entity; + } + return map; + } + + private string ResolveSource(string source, Dictionary aliasMap) + { + if (string.IsNullOrEmpty(source)) + return source; + return aliasMap.TryGetValue(source, out var entity) ? entity : source; + } + + private void ExtractFieldsFromCondition(VisualDto.Condition condition, List fields, Dictionary aliasMap) + { + if (condition == null) return; + + // IN Expression + if (condition.In != null) + { + foreach (var expr in condition.In.Expressions ?? Enumerable.Empty()) + { + var srcRef = expr.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = expr.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + + // NOT Expression + if (condition.Not != null) + ExtractFieldsFromCondition(condition.Not.Expression, fields, aliasMap); + + // COMPARISON Expression + if (condition.Comparison != null) + { + AddOperandField(condition.Comparison.Left, fields, aliasMap); + AddOperandField(condition.Comparison.Right, fields, aliasMap); + } + } + private void AddOperandField(VisualDto.FilterOperand operand, List fields, Dictionary aliasMap) + { + if (operand == null) return; + + // MEASURE + if (operand.Measure != null) + { + var srcRef = operand.Measure.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = operand.Measure.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + + // COLUMN + if (operand.Column != null) + { + var srcRef = operand.Column.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = operand.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure && newField.Measure != null) + { + // Preserve Expression with SourceRef + f.Measure ??= new VisualDto.MeasureObject(); + f.Measure.Property = newField.Measure.Property; + f.Measure.Expression ??= new VisualDto.Expression(); + f.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Measure.Expression.SourceRef.Entity, + Source = newField.Measure.Expression.SourceRef.Source + } + : f.Measure.Expression.SourceRef; + f.Column = null; + wasModified = true; + } + else if (!isMeasure && newField.Column != null) + { + // Preserve Expression with SourceRef + f.Column ??= new VisualDto.ColumnField(); + f.Column.Property = newField.Column.Property; + f.Column.Expression ??= new VisualDto.Expression(); + f.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Column.Expression.SourceRef.Entity, + Source = newField.Column.Expression.SourceRef.Source + } + : f.Column.Expression.SourceRef; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? proj.Field.Measure.Expression?.SourceRef?.Entity + : proj.Field.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? proj.Field.Measure.Property + : proj.Field.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure ??= new VisualDto.MeasureObject(); + prop.Expr.Measure.Property = newField.Measure.Property; + prop.Expr.Measure.Expression ??= new VisualDto.Expression(); + prop.Expr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column ??= new VisualDto.ColumnField(); + prop.Expr.Column.Property = newField.Column.Property; + prop.Expr.Column.Expression ??= new VisualDto.Expression(); + prop.Expr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure ??= new VisualDto.MeasureObject(); + fillInput.Measure.Property = newField.Measure.Property; + fillInput.Measure.Expression ??= new VisualDto.Expression(); + fillInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column ??= new VisualDto.ColumnField(); + fillInput.Column.Property = newField.Column.Property; + fillInput.Column.Expression ??= new VisualDto.Expression(); + fillInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure ??= new VisualDto.MeasureObject(); + solidInput.Measure.Property = newField.Measure.Property; + solidInput.Measure.Expression ??= new VisualDto.Expression(); + solidInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column ??= new VisualDto.ColumnField(); + solidInput.Column.Property = newField.Column.Property; + solidInput.Column.Expression ??= new VisualDto.Expression(); + solidInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidInput.Measure = null; + wasModified = true; + } + } + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure ??= new VisualDto.MeasureObject(); + solidExpr.Measure.Property = newField.Measure.Property; + solidExpr.Measure.Expression ??= new VisualDto.Expression(); + solidExpr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column ??= new VisualDto.ColumnField(); + solidExpr.Column.Property = newField.Column.Property; + solidExpr.Column.Expression ??= new VisualDto.Expression(); + solidExpr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + //if (Content.FilterConfig != null) + //{ + // var filterConfigString = Content.FilterConfig.ToString(); + // string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + // string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + // string oldPattern = oldFieldKey; + // string newPattern = $"'{table}'[{prop}]"; + + // if (filterConfigString.Contains(oldPattern)) + // { + // Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + // wasModified = true; + // } + //} + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + } + + } + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Copy Visual.csx b/Advanced/Report Layer Macros/Copy Visual.csx new file mode 100644 index 0000000..7623e88 --- /dev/null +++ b/Advanced/Report Layer Macros/Copy Visual.csx @@ -0,0 +1,2180 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +// 2025-07-05/B.Agullo +// This script will copy a visual from a template report to the target report. +// Target report must be connected with the model that this instance of tabular editor is connected to. +// Both target report and template report must use PBIR format +// If you are executing this in Tabular Editor 2 you need to +// configure Roslyn compiler as explained here: +// https://docs.tabulareditor.com/te2/Advanced-Scripting.html#compiling-with-roslyn +// Step 1: Initialize source and target reports +ReportExtended sourceReport = Rx.InitReport(label: @"Select the SOURCE report"); +if (sourceReport == null) return; +ReportExtended targetReport = Rx.InitReport(label: @"Select the TARGET report"); +if (targetReport == null) return; +// Step 2: Let user select a single visual from the source report +VisualExtended sourceVisual = Rx.SelectVisual(sourceReport); +if (sourceVisual == null) return; +// Step 3: For each measure and column used, find equivalent in connected model and replace +var referencedMeasures = sourceVisual.GetAllReferencedMeasures().ToList(); +var referencedColumns = sourceVisual.GetAllReferencedColumns().ToList(); +// Prepare replacement maps +var measureReplacementMap = new Dictionary(); +var columnReplacementMap = new Dictionary(); +foreach (string measureRef in referencedMeasures) +{ + Measure preselect = Model.AllMeasures.FirstOrDefault(m => + String.Format(@"{0}[{1}]", m.Table.DaxObjectFullName, m.Name) == measureRef + ); + Measure replacement = SelectMeasure(preselect: preselect, label: String.Format(@"Select replacement for measure {0}", measureRef)); + if (replacement == null) + { + Error(String.Format(@"No replacement selected for measure {0}.", measureRef)); + return; + } + measureReplacementMap[measureRef] = replacement; +} +foreach (string columnRef in referencedColumns) +{ + Column preselect = Model.AllColumns.FirstOrDefault(c => + c.DaxObjectFullName == columnRef + ); + Column replacement = SelectColumn(Model.AllColumns, preselect: preselect, label: String.Format(@"Select replacement for column {0}", columnRef)); + if (replacement == null) + { + Error(String.Format(@"No replacement selected for column {0}.", columnRef)); + return; + } + columnReplacementMap[columnRef] = replacement; +} +// Step 4: Replace fields in the visual object +foreach (var kv in measureReplacementMap) +{ + sourceVisual.ReplaceMeasure(kv.Key, kv.Value); +} +foreach (var kv in columnReplacementMap) +{ + sourceVisual.ReplaceColumn(kv.Key, kv.Value); +} +// Step 5: Ask in which page of the target report the new visual should be created +var targetPages = targetReport.Pages.ToList(); +var pageDisplayList = targetPages.Select(p => p.Page.DisplayName).ToList(); +string newPageOption = @""; +pageDisplayList.Add(newPageOption); +string selectedPageDisplay = Fx.ChooseString(OptionList: pageDisplayList, label: @"Select target page for the new visual"); +if (String.IsNullOrEmpty(selectedPageDisplay)) +{ + Info(@"No target page selected."); + return; +} +object targetPage = null; +// Step 5.1: If the user selected the option to create a new page, replicate the first page as blank +if (selectedPageDisplay == newPageOption) +{ + targetPage = Rx.ReplicateFirstPageAsBlank(targetReport); +} +else +{ + targetPage = targetPages.First(p => p.Page.DisplayName == selectedPageDisplay); +} +// Step 5.2: Assign a new GUID as the visual name to avoid conflicts +string newVisualName = Guid.NewGuid().ToString().Replace("-", "").Substring(0, 20); +sourceVisual.Content.Name = newVisualName; +// Step 6: Build new visual file path +string targetPageFolder = Path.GetDirectoryName(((PageExtended)targetPage).PageFilePath); +string visualsFolder = Path.Combine(targetPageFolder, "visuals"); +string newVisualJsonPath = Path.Combine(visualsFolder, sourceVisual.Content.Name, "visual.json"); +// Update visual's file path and parent page +sourceVisual.VisualFilePath = newVisualJsonPath; +// Step 7: Save the visual generating the visual.json file in the target report +Rx.SaveVisual(sourceVisual); +Output(String.Format(@"Visual copied to page '{0}' in target report.", ((PageExtended)targetPage).Page.DisplayName)); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList, string label = "Choose item") + { + return ChooseStringInternal(OptionList, MultiSelect: false, label:label) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)") + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)") + { + Form form = new Form + { + Text =label, + Width = 400, + Height = 500, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect }; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "YourAppName", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label); + + + + string chosenPath = null; + + if (selected == "Browse for new file..." || string.IsNullOrEmpty(selected)) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) + + { + + Error("Operation canceled by the user."); + + return null; + + } + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + public static VisualExtended SelectVisual(ReportExtended report) + + { + + // Step 1: Build selection list + + var visualSelectionList = report.Pages + + .SelectMany(p => p.Visuals.Select(v => new + + { + + Display = string.Format("{0} - {1} ({2}, {3})", p.Page.DisplayName, v.Content.Visual.VisualType, (int)v.Content.Position.X, (int)v.Content.Position.Y), + + Page = p, + + Visual = v + + })) + + .ToList(); + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public object FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + [JsonProperty("tabOrder")] public int TabOrder { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + + + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public Field Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + + public Boolean isVisualGroup => Content?.VisualGroup != null; + public Boolean isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if(Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + // Ensure the structure exists + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig)); + + return fields.Where(f => f != null); + } + + private IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color != null && + prop.Color.Expr != null && + prop.Color.Expr.FillRule != null && + prop.Color.Expr.FillRule.Input != null) + { + yield return prop.Color.Expr.FillRule.Input; + } + + if (prop.Solid != null && + prop.Solid.Color != null && + prop.Solid.Color.Expr != null && + prop.Solid.Color.Expr.FillRule != null && + prop.Solid.Color.Expr.FillRule.Input != null) + { + yield return prop.Solid.Color.Expr.FillRule.Input; + } + + var solidExpr = prop.Solid != null && + prop.Solid.Color != null + ? prop.Solid.Color.Expr + : null; + + if (solidExpr != null) + { + if (solidExpr.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + + if (solidExpr.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(object filterConfig) + { + var fields = new List(); + + if (filterConfig is JObject jObj) + { + foreach (var token in jObj.DescendantsAndSelf().OfType()) + { + var table = token["table"]?.ToString(); + var property = token["column"]?.ToString() ?? token["measure"]?.ToString(); + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(property)) + { + var field = new VisualDto.Field(); + + if (token["measure"] != null) + { + field.Measure = new VisualDto.MeasureObject + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + else if (token["column"] != null) + { + field.Column = new VisualDto.ColumnField + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + + fields.Add(field); + } + } + } + + return fields; + } + + + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure) + { + f.Measure = newField.Measure; + f.Column = null; + wasModified = true; + } + else + { + f.Column = newField.Column; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? newField.Measure.Expression?.SourceRef?.Entity + : newField.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? newField.Measure.Property + : newField.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + //proj.NativeQueryRef = prop; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure = newField.Measure; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column = newField.Column; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure = newField.Measure; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column = newField.Column; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure = newField.Measure; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column = newField.Column; + solidInput.Measure = null; + wasModified = true; + } + } + + // ✅ NEW: handle direct measure/column under solid.color.expr + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure = newField.Measure; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column = newField.Column; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (Content.FilterConfig != null) + { + var filterConfigString = Content.FilterConfig.ToString(); + string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + string oldPattern = oldFieldKey; + string newPattern = $"'{table}'[{prop}]"; + + if (filterConfigString.Contains(oldPattern)) + { + Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + wasModified = true; + } + } + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + + } + + public void ReplaceInFilterConfigRaw( + Dictionary tableMap, + Dictionary fieldMap, + HashSet modifiedVisuals = null) + { + if (Content.FilterConfig == null) return; + + string originalJson = JsonConvert.SerializeObject(Content.FilterConfig); + string updatedJson = originalJson; + + foreach (var kv in tableMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + foreach (var kv in fieldMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + // Only update and track if something actually changed + if (updatedJson != originalJson) + { + Content.FilterConfig = JsonConvert.DeserializeObject(updatedJson); + modifiedVisuals?.Add(this); + } + } + + } + + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Create Data Problems Button.csx b/Advanced/Report Layer Macros/Create Data Problems Button.csx new file mode 100644 index 0000000..57cc40a --- /dev/null +++ b/Advanced/Report Layer Macros/Create Data Problems Button.csx @@ -0,0 +1,373 @@ +using System.Windows.Forms; +using System.IO; +// '2024-07-10 / B.Agullo / +// Instructions: +// execute after running latest version of dataProblemsButtomMeasureCreation macro +// See https://www.esbrina-ba.com/c-scripting-the-report-layer-with-tabular-editor/ for detail + + +/*uncomment in TE3 to avoid wating cursor infront of dialogs*/ + +//ScriptHelper.WaitFormVisible = false; +// +//bool waitCursor = Application.UseWaitCursor; +//Application.UseWaitCursor = false; + +DialogResult dialogResult = MessageBox.Show(text:"Did you save changes in PBI Desktop before running this macro?", caption:"Saved changes?", buttons:MessageBoxButtons.YesNo); + +if(dialogResult != DialogResult.Yes){ + Info("Please save your changes first and then run this macro"); + return; +}; + +string annotationLabel = "DataProblemsMeasures"; +string annotationValueNavigation = "ButtonNavigationMeasure"; +string annotationValueText = "ButtonTextMeasure"; +string annotationValueBackground = "ButtonBackgroundMeasure"; +string[] annotationArray = new string[3] { annotationValueNavigation, annotationValueText, annotationValueBackground }; +foreach(string annotation in annotationArray) +{ + if(!Model.AllMeasures.Any(m => m.GetAnnotation(annotationLabel) == annotation)) + { + Error(String.Format("No measure found with annotation {0} = {1} ", annotationLabel, annotationValueNavigation)); + return; + } +} + + +// Create an instance of the OpenFileDialog +OpenFileDialog openFileDialog = new OpenFileDialog(); + +openFileDialog.Title = "Please select definition.pbir file of the target report"; +// Set filter options and filter index. +openFileDialog.Filter = "PBIR Files (*.pbir)|*.pbir"; +openFileDialog.FilterIndex = 1; + +// Call the ShowDialog method to show the dialog box. +DialogResult result = openFileDialog.ShowDialog(); + +// Process input if the user clicked OK. +if (result != DialogResult.OK) +{ + Error("You cancelled"); + return; +} + +// Get the file name. +string pbirFilePath = openFileDialog.FileName; + + + +string newPageId = Guid.NewGuid().ToString(); + +string newVisualId = Guid.NewGuid().ToString(); + + + + +Measure navigationMeasure = + Model.AllMeasures + .Where(m => m.GetAnnotation(annotationLabel) == annotationValueNavigation) + .FirstOrDefault(); +Measure textMeasure = + Model.AllMeasures + .Where(m => m.GetAnnotation(annotationLabel) == annotationValueText) + .FirstOrDefault(); +Measure backgroundMeasure = + Model.AllMeasures + .Where(m => m.GetAnnotation(annotationLabel) == annotationValueBackground) + .FirstOrDefault(); + + + +string newPageContent =@" +{ + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/1.0.0/schema.json"", + ""name"": ""{{newPageId}}"", + ""displayName"": ""Problems Button"", + ""displayOption"": ""FitToPage"", + ""height"": 720, + ""width"": 1280 +}"; + + + +string newVisualContent = @"{ + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/1.0.0/schema.json"", + ""name"": ""{{newVisualId}}"", + ""position"": { + ""x"": 510.44776119402987, + ""y"": 256.1194029850746, + ""z"": 0, + ""width"": 188.0597014925373, + ""height"": 50.14925373134328 + }, + ""visual"": { + ""visualType"": ""actionButton"", + ""objects"": { + ""icon"": [ + { + ""properties"": { + ""shapeType"": { + ""expr"": { + ""Literal"": { + ""Value"": ""'blank'"" + } + } + } + }, + ""selector"": { + ""id"": ""default"" + } + }, + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""false"" + } + } + } + } + } + ], + ""outline"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""false"" + } + } + } + } + } + ], + ""text"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + } + } + }, + { + ""properties"": { + ""text"": { + ""expr"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{textMeasureTable}}"" + } + }, + ""Property"": ""{{textMeasureName}}"" + } + } + }, + ""bold"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + }, + ""fontColor"": { + ""solid"": { + ""color"": { + ""expr"": { + ""ThemeDataColor"": { + ""ColorId"": 0, + ""Percent"": 0 + } + } + } + } + } + }, + ""selector"": { + ""id"": ""default"" + } + } + ], + ""fill"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + } + } + }, + { + ""properties"": { + ""fillColor"": { + ""solid"": { + ""color"": { + ""expr"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{backgroundMeasureTable}}"" + } + }, + ""Property"": ""{{backgroundMeasureName}}"" + } + } + } + } + }, + ""transparency"": { + ""expr"": { + ""Literal"": { + ""Value"": ""0D"" + } + } + } + }, + ""selector"": { + ""id"": ""default"" + } + } + ] + }, + ""visualContainerObjects"": { + ""visualLink"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + }, + ""type"": { + ""expr"": { + ""Literal"": { + ""Value"": ""'PageNavigation'"" + } + } + }, + ""navigationSection"": { + ""expr"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{navigationMeasureTable}}"" + } + }, + ""Property"": ""{{navigationMeasureName}}"" + } + } + } + } + } + ] + }, + ""drillFilterOtherVisuals"": true + }, + ""howCreated"": ""InsertVisualButton"" +}"; + + +Dictionary placeholders = new Dictionary(); +placeholders.Add("{{newPageId}}", newPageId); +placeholders.Add("{{newVisualId}}", newVisualId); +placeholders.Add("{{textMeasureTable}}",textMeasure.Table.Name); +placeholders.Add("{{textMeasureName}}",textMeasure.Name); +placeholders.Add("{{backgroundMeasureTable}}",backgroundMeasure.Table.Name); +placeholders.Add("{{backgroundMeasureName}}",backgroundMeasure.Name); +placeholders.Add("{{navigationMeasureTable}}",navigationMeasure.Table.Name); +placeholders.Add("{{navigationMeasureName}}",navigationMeasure.Name); + + +newPageContent = ReportManager.ReplacePlaceholders(newPageContent,placeholders); +newVisualContent = ReportManager.ReplacePlaceholders(newVisualContent, placeholders); +ReportManager.AddNewPage(newPageContent, newVisualContent,pbirFilePath,newPageId,newVisualId); + + + +Info("New page added successfully. Close your PBIP project on Power BI desktop *without saving changes* and open again to see the new page with the button."); +//Application.UseWaitCursor = waitCursor; + +public static class ReportManager +{ + + public static string ReplacePlaceholders(string jsonContents, Dictionary placeholders) + { + foreach(string placeholder in placeholders.Keys) + { + string valueToReplace = placeholders[placeholder]; + + jsonContents = jsonContents.Replace(placeholder, valueToReplace); + + } + + return jsonContents; + } + + + public static void AddNewPage(string pageContents, string visualContents, string pbirFilePath, string newPageId, string newVisualId) + { + + FileInfo pbirFileInfo = new FileInfo(pbirFilePath); + + string pbirFolder = pbirFileInfo.Directory.FullName; + string pagesFolder = Path.Combine(pbirFolder, "definition", "pages"); + string pagesFilePath = Path.Combine(pagesFolder, "pages.json"); + + string newPageFolder = Path.Combine(pagesFolder, newPageId); + + Directory.CreateDirectory(newPageFolder); + + string newPageFilePath = Path.Combine(newPageFolder, "page.json"); + File.WriteAllText(newPageFilePath, pageContents); + + string visualsFolder = Path.Combine(newPageFolder,"visuals"); + Directory.CreateDirectory(visualsFolder); + + string newVisualFolder = Path.Combine(visualsFolder,newVisualId); + Directory.CreateDirectory(newVisualFolder); + + string newVisualFilePath = Path.Combine(newVisualFolder,"visual.json"); + File.WriteAllText(newVisualFilePath,visualContents); + + AddPageIdToPages(pagesFilePath, newPageId); + } + + private static void AddPageIdToPages(string pagesFilePath, string pageId) + { + string pagesFileContents = File.ReadAllText(pagesFilePath); + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesFileContents); + if(pagesDto.pageOrder == null) + { + pagesDto.pageOrder = new List(); + } + + + if (!pagesDto.pageOrder.Contains(pageId)) { + + pagesDto.pageOrder.Add(pageId); + string resultFile = JsonConvert.SerializeObject(pagesDto, Formatting.Indented); + File.WriteAllText(pagesFilePath, resultFile); + } + } +} + +public class PagesDto +{ + [JsonProperty("$schema")] + public string schema { get; set; } + public List pageOrder { get; set; } + public string activePageName { get; set; } +} \ No newline at end of file diff --git a/Advanced/Report Layer Macros/Create Referential Integrity Page.csx b/Advanced/Report Layer Macros/Create Referential Integrity Page.csx new file mode 100644 index 0000000..1fd8264 --- /dev/null +++ b/Advanced/Report Layer Macros/Create Referential Integrity Page.csx @@ -0,0 +1,718 @@ +using Microsoft.VisualBasic; +using System.Windows.Forms; +using System.IO; + +// '2024-07-14 / B.Agullo / +// +// Requirements: +// execute after running the Referential integrity check measures script (version 2024-07-13 or later) +// https://github.com/bernatagulloesbrina/TabularEditor-Scripts/blob/main/Advanced/One-Click%20Macros/Referential%20Integrity%20Check%20Measures.csx +// +// See blog posts here: +// https://www.esbrina-ba.com/easy-management-of-referential-integrity/ +// https://www.esbrina-ba.com/building-a-referential-integrity-report-page-with-a-c-script/ + + +string pageName = "Referential Integrity"; +int interObjectGap = 15; +int totalCardX = interObjectGap; +int totalCardY = interObjectGap; +int totalCardWidth = 300; +int totalCardHeight = 150; +int detailCardFontSize = 14; +int detailCardX = interObjectGap; +int detailCardY = totalCardY + totalCardHeight + interObjectGap; +int detailCardWidth = 300; +int detailCardHeight = 200; +int tableHorizontalGap = 10; +int tablesPerRow = 3; +int tableHeight = 250; +int tableWidth = 300; +int backgroundTransparency = 0; +string backgroundColor = "#F0ECEC"; + +// do not modify below this line -- +string annLabel = "ReferencialIntegrityMeasures"; +string annValueTotal = "TotalUnmappedItems"; +string annValueDetail = "DataProblems"; +string annValueDataQualityMeasures = "DataQualityMeasure"; +string annValueDataQualityTitles = "DataQualityTitle"; +string annValueDataQualitySubtitles = "DataQualitySubitle"; +string annValueFactColumn = "FactColumn"; + +/*uncomment in TE3 to avoid wating cursor infront of dialogs*/ + +ScriptHelper.WaitFormVisible = false; + +bool waitCursor = Application.UseWaitCursor; +Application.UseWaitCursor = false; + +DialogResult dialogResult = MessageBox.Show(text:"Did you save changes in PBI Desktop before running this macro?", caption:"Saved changes?", buttons:MessageBoxButtons.YesNo); + +if(dialogResult != DialogResult.Yes){ + Info("Please save your changes first and then run this macro"); + return; +}; + + + +string[] annotationArray = new string[4] { annValueTotal, annValueDetail, annValueDataQualityMeasures, annValueDataQualityTitles }; +foreach (string annotation in annotationArray) +{ + if (!Model.AllMeasures.Any(m => m.GetAnnotation(annLabel).StartsWith(annotation))) + { + Error(String.Format("No measure found with annotation {0} starting with {1} ", annLabel, annotation)); + return; + } +} +Measure totalMeasure = + Model.AllMeasures + .Where(m => m.GetAnnotation(annLabel) == annValueTotal) + .FirstOrDefault(); +Measure detailMeasure = + Model.AllMeasures + .Where(m => m.GetAnnotation(annLabel) == annValueDetail) + .FirstOrDefault(); +IList dataQualityMeasures = + Model.AllMeasures + .Where(m => m.GetAnnotation(annLabel) != null + && m.GetAnnotation(annLabel).StartsWith(annValueDataQualityMeasures)) + .OrderBy(m => m.GetAnnotation(annLabel)) + .ToList(); + +IList dataQualityTitles = + Model.AllMeasures + .Where(m => m.GetAnnotation(annLabel) != null + && m.GetAnnotation(annLabel).StartsWith(annValueDataQualityTitles)) + .OrderBy(m => m.GetAnnotation(annLabel)) + .ToList(); + + +IList dataQualitySubtitles = + Model.AllMeasures + .Where(m => m.GetAnnotation(annLabel) != null + && m.GetAnnotation(annLabel).StartsWith(annValueDataQualitySubtitles)) + .OrderBy(m => m.GetAnnotation(annLabel)) + .ToList(); + +IList factTableColumns = + Model.AllColumns + .Where(c => c.GetAnnotation(annLabel) != null + && c.GetAnnotation(annLabel).StartsWith(annValueFactColumn)) + .OrderBy(c => c.GetAnnotation(annLabel)) + .ToList(); +//now that we now number of tables we'll need, let's set up the page size. +int tableCount = dataQualityMeasures.Count(); +decimal rowsRaw = (decimal) tableCount / (decimal) tablesPerRow; +int rowsOfTables = (int)Math.Ceiling(rowsRaw); +int pageWidth = totalCardX + totalCardWidth + interObjectGap + (tableWidth + interObjectGap) * tablesPerRow ; +int totalTablesHeight = interObjectGap + (tableHeight + interObjectGap) * rowsOfTables; +int totalCardsHeight = detailCardY + detailCardHeight + interObjectGap; +int pageHeight = Math.Max(totalTablesHeight,totalCardsHeight); + +//adjust detail card height to fill the height if tables are taller +detailCardHeight = pageHeight - 3 * interObjectGap - totalCardHeight; + +// Create an instance of the OpenFileDialog +OpenFileDialog openFileDialog = new OpenFileDialog(); +openFileDialog.Title = "Please select definition.pbir file of the target report"; +// Set filter options and filter index. +openFileDialog.Filter = "PBIR Files (*.pbir)|*.pbir"; +openFileDialog.FilterIndex = 1; +// Call the ShowDialog method to show the dialog box. +DialogResult result = openFileDialog.ShowDialog(); +// Process input if the user clicked OK. +if (result != DialogResult.OK) +{ + Error("You cancelled"); + return; +} +// Get the file name. +string pbirFilePath = openFileDialog.FileName; +string pageContentsTemplate = @" +{ + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/1.0.0/schema.json"", + ""name"": ""{{newPageId}}"", + ""displayName"": ""Referential Integrity"", + ""displayOption"": ""FitToPage"", + ""height"": {{pageHeight}}, + ""width"": {{pageWidth}}, + ""objects"": { + ""background"": [ + { + ""properties"": { + ""transparency"": { + ""expr"": { + ""Literal"": { + ""Value"": ""{{backgroundTransparency}}D"" + } + } + }, + ""color"": { + ""solid"": { + ""color"": { + ""expr"": { + ""Literal"": { + ""Value"": ""'{{backgroundColor}}'"" + } + } + } + } + } + } + } + ] + } +}"; +string totalCardContentsTemplate = @" + { + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/1.0.0/schema.json"", + ""name"": ""{{newVisualId}}"", + ""position"": { + ""x"": {{totalCardX}}, + ""y"": {{totalCardY}}, + ""z"": {{zTabOrder}}, + ""width"": {{totalCardWidth}}, + ""height"": {{totalCardHeight}}, + ""tabOrder"": {{zTabOrder}} + }, + ""visual"": { + ""visualType"": ""card"", + ""query"": { + ""queryState"": { + ""Values"": { + ""projections"": [ + { + ""field"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{totalMeasureTable}}"" + } + }, + ""Property"": ""{{totalMeasureName}}"" + } + }, + ""queryRef"": ""{{totalMeasureTable}}.{{totalMeasureName}}"", + ""nativeQueryRef"": ""{{totalMeasureName}}"" + } + ] + } + }, + ""sortDefinition"": { + ""sort"": [ + { + ""field"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{totalMeasureTable}}"" + } + }, + ""Property"": ""{{totalMeasureName}}"" + } + }, + ""direction"": ""Descending"" + } + ], + ""isDefaultSort"": true + } + }, + ""drillFilterOtherVisuals"": true + } + }"; +string detailCardContentsTemplate = @" + { + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/1.0.0/schema.json"", + ""name"": ""{{newVisualId}}"", + ""position"": { + ""x"": {{detailCardX}}, + ""y"": {{detailCardY}}, + ""z"": {{zTabOrder}}, + ""width"": {{detailCardWidth}}, + ""height"": {{detailCardHeight}}, + ""tabOrder"": {{zTabOrder}} + }, + ""visual"": { + ""visualType"": ""card"", + ""query"": { + ""queryState"": { + ""Values"": { + ""projections"": [ + { + ""field"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{dataProblemsMeasureTable}}"" + } + }, + ""Property"": ""{{dataProblemsMeasureName}}"" + } + }, + ""queryRef"": ""{{dataProblemsMeasureTable}}.{{dataProblemsMeasureName}}"", + ""nativeQueryRef"": ""{{dataProblemsMeasureName}}"" + } + ] + } + } + }, + ""objects"": { + ""labels"": [ + { + ""properties"": { + ""fontSize"": { + ""expr"": { + ""Literal"": { + ""Value"": ""{{detailCardFontSize}}D"" + } + } + } + } + } + ] + }, + ""drillFilterOtherVisuals"": true + } + }"; +string tableContentsTemplate = @" + { + ""$schema"": ""https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/1.0.0/schema.json"", + ""name"": ""{{newVisualId}}"", + ""position"": { + ""x"": {{tableX}}, + ""y"": {{tableY}}, + ""z"": {{zTabOrder}}, + ""width"": {{tableWidth}}, + ""height"": {{tableHeight}}, + ""tabOrder"": {{zTabOrder}} + }, + ""visual"": { + ""visualType"": ""tableEx"", + ""query"": { + ""queryState"": { + ""Values"": { + ""projections"": [ + { + ""field"": { + ""Column"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{factTableName}}"" + } + }, + ""Property"": ""{{factColumnName}}"" + } + }, + ""queryRef"": ""{{factTableName}}.{{factColumnName}}"", + ""nativeQueryRef"": ""{{factColumnName}}"" + }, + { + ""field"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{dataQualityMeasureTable}}"" + } + }, + ""Property"": ""{{dataQualityMeasureName}}"" + } + }, + ""queryRef"": ""{{dataQualityMeasureTable}}.{{dataQualityMeasureName}}"", + ""nativeQueryRef"": ""{{dataQualityMeasureName}}"" + } + ] + } + } + }, + ""visualContainerObjects"": { + ""title"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + }, + ""text"": { + ""expr"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{dataQualityTitleMeasureTable}}"" + } + }, + ""Property"": ""{{dataQualityTitleMeasureName}}"" + } + } + } + } + } + ], + ""subTitle"": [ + { + ""properties"": { + ""show"": { + ""expr"": { + ""Literal"": { + ""Value"": ""true"" + } + } + }, + ""text"": { + ""expr"": { + ""Measure"": { + ""Expression"": { + ""SourceRef"": { + ""Entity"": ""{{dataQualitySubtitleMeasureTable}}"" + } + }, + ""Property"": ""{{dataQualitySubtitleMeasureName}}"" + } + } + } + } + } + ] + }, + ""drillFilterOtherVisuals"": true + } + }"; +Dictionary placeholders = new Dictionary(); +placeholders.Add("{{newPageId}}", ""); +placeholders.Add("{{newVisualId}}", ""); +placeholders.Add("{{pageName}}", pageName); +placeholders.Add("{{totalMeasureTable}}", totalMeasure.Table.Name); +placeholders.Add("{{totalMeasureName}}", totalMeasure.Name); +placeholders.Add("{{dataProblemsMeasureTable}}", detailMeasure.Table.Name); +placeholders.Add("{{dataProblemsMeasureName}}", detailMeasure.Name); +placeholders.Add("{{factTableName}}", ""); //factColumn.Table.Name); +placeholders.Add("{{factColumnName}}", ""); // factColumn.Name); +placeholders.Add("{{dataQualityMeasureTable}}", ""); // dataQualityMeasure.Table.Name); +placeholders.Add("{{dataQualityMeasureName}}", ""); //dataQualityMeasure.Name); +placeholders.Add("{{dataQualityTitleMeasureTable}}", ""); // dataQualityTitleMeasure.Table.Name); +placeholders.Add("{{dataQualityTitleMeasureName}}", ""); //dataQualityTitleMeasure.Name); +placeholders.Add("{{dataQualitySubtitleMeasureTable}}", ""); //dataQualitySubtitleMeasure.Table.Name); +placeholders.Add("{{dataQualitySubtitleMeasureName}}", ""); //dataQualitySubtitleMeasure.Name); +placeholders.Add("{{pageHeight}}", pageHeight.ToString()); +placeholders.Add("{{pageWidth}}", pageWidth.ToString()); +placeholders.Add("{{detailCardX}}", detailCardX.ToString()); +placeholders.Add("{{detailCardY}}", detailCardY.ToString()); +placeholders.Add("{{detailCardWidth}}", detailCardWidth.ToString()); +placeholders.Add("{{detailCardHeight}}", detailCardHeight.ToString()); +placeholders.Add("{{detailCardFontSize}}", detailCardFontSize.ToString()); +placeholders.Add("{{totalCardX}}", totalCardX.ToString()); +placeholders.Add("{{totalCardY}}", totalCardY.ToString()); +placeholders.Add("{{totalCardWidth}}", totalCardWidth.ToString()); +placeholders.Add("{{totalCardHeight}}", totalCardHeight.ToString()); +placeholders.Add("{{zTabOrder}}", 0.ToString()); +placeholders.Add("{{tableX}}", 0.ToString()); +placeholders.Add("{{tableY}}", 0.ToString()); +placeholders.Add("{{tableWidth}}", tableWidth.ToString()); +placeholders.Add("{{tableHeight}}", tableHeight.ToString()); +placeholders.Add("{{backgroundColor}}", backgroundColor); +placeholders.Add("{{backgroundTransparency}}", backgroundTransparency.ToString()); + +string pagesFolder = Fx.GetPagesFolder(pbirFilePath); +string newVisualId = ""; +string tableContents = ""; +int zTabOrder = -1000; + +//create new page +string newPageId = Guid.NewGuid().ToString(); +placeholders["{{newPageId}}"] = newPageId; +zTabOrder = zTabOrder + 1000; +placeholders["{{zTabOrder}}"] = zTabOrder.ToString(); +string pageContents = Fx.ReplacePlaceholders(pageContentsTemplate,placeholders); +string newPageFolder = Fx.AddNewPage(pageContents, pagesFolder, newPageId); + +//create total card +newVisualId = Guid.NewGuid().ToString(); +placeholders["{{newVisualId}}"] = newVisualId; +zTabOrder = zTabOrder + 1000; +placeholders["{{zTabOrder}}"] = zTabOrder.ToString(); +string totalCardContents = Fx.ReplacePlaceholders(totalCardContentsTemplate,placeholders); +Fx.AddNewVisual(visualContents: totalCardContents, pageFolder: newPageFolder, newVisualId: newVisualId); + +//create detail card +newVisualId = Guid.NewGuid().ToString(); +placeholders["{{newVisualId}}"] = newVisualId; +zTabOrder = zTabOrder + 1000; +placeholders["{{zTabOrder}}"] = zTabOrder.ToString(); +string detailCardContents = Fx.ReplacePlaceholders(detailCardContentsTemplate, placeholders); +Fx.AddNewVisual(visualContents: detailCardContents, pageFolder: newPageFolder, newVisualId: newVisualId); + +int currentRow = 1; +int currentColumn = 1; +int startX = totalCardX + totalCardWidth + interObjectGap; +int startY = interObjectGap; + +for(int i = 0; i < dataQualityMeasures.Count(); i++) +{ + //get references and calculate values + Column factColumn = factTableColumns[i]; + Measure dataQualityMeasure = dataQualityMeasures[i]; + Measure dataQualityTitleMeasure = dataQualityTitles[i]; + Measure dataQualitySubtitleMeasure = dataQualitySubtitles[i]; + zTabOrder = zTabOrder + 1000; + newVisualId = Guid.NewGuid().ToString(); + int tableX = startX + (currentColumn - 1) * (tableWidth + interObjectGap) ; + int tableY = startY + (currentRow - 1) * (tableHeight + interObjectGap) ; + //update the dictionary + placeholders["{{newVisualId}}"] = newVisualId; + placeholders["{{zTabOrder}}"] = zTabOrder.ToString(); + placeholders["{{factTableName}}"] =factColumn.Table.Name; + placeholders["{{factColumnName}}"] = factColumn.Name; + placeholders["{{dataQualityMeasureTable}}"] = dataQualityMeasure.Table.Name; + placeholders["{{dataQualityMeasureName}}"] =dataQualityMeasure.Name; + placeholders["{{dataQualityTitleMeasureTable}}"] = dataQualityTitleMeasure.Table.Name; + placeholders["{{dataQualityTitleMeasureName}}"] =dataQualityTitleMeasure.Name; + placeholders["{{dataQualitySubtitleMeasureTable}}"] =dataQualitySubtitleMeasure.Table.Name; + placeholders["{{dataQualitySubtitleMeasureName}}"] =dataQualitySubtitleMeasure.Name; + placeholders["{{tableX}}"] = tableX.ToString(); + placeholders["{{tableY}}"] = tableY.ToString(); + //fill the template + tableContents = Fx.ReplacePlaceholders(tableContentsTemplate, placeholders); + //create the folder & Json file + Fx.AddNewVisual(visualContents: tableContents, pageFolder: newPageFolder, newVisualId: newVisualId); + //update variables for the next table + currentColumn = currentColumn + 1; + if (currentColumn > tablesPerRow) + { + currentRow = currentRow + 1; + currentColumn = 1; + } + +} + +Info("New page added successfully. Close your PBIP project on Power BI desktop *without saving changes* and open again to see the new page with the button."); +//Uncomment in TE3 +Application.UseWaitCursor = waitCursor; + + +public static class Fx +{ + public static string ReplacePlaceholders(string pageContentsTemplate, Dictionary placeholders) + { + string pageContents = pageContentsTemplate; + if (placeholders != null) + { + foreach (string placeholder in placeholders.Keys) + { + string valueToReplace = placeholders[placeholder]; + pageContents = pageContents.Replace(placeholder, valueToReplace); + } + } + return pageContents; + } + public static string GetPagesFolder(string pbirFilePath) + { + FileInfo pbirFileInfo = new FileInfo(pbirFilePath); + string pbirFolder = pbirFileInfo.Directory.FullName; + string pagesFolder = Path.Combine(pbirFolder, "definition", "pages"); + return pagesFolder; + } + public static string AddNewPage(string pageContents, string pagesFolder, string newPageId) + { + + string newPageFolder = Path.Combine(pagesFolder, newPageId); + + Directory.CreateDirectory(newPageFolder); + + string newPageFilePath = Path.Combine(newPageFolder, "page.json"); + File.WriteAllText(newPageFilePath, pageContents); + + string pagesFilePath = Path.Combine(pagesFolder, "pages.json"); + AddPageIdToPages(pagesFilePath, newPageId); + + return newPageFolder; + } + public static void AddNewVisual(string visualContents, string pageFolder, string newVisualId) + { + string visualsFolder = Path.Combine(pageFolder, "visuals"); + + //maybe created earlier + if (!Directory.Exists(visualsFolder)) + { + Directory.CreateDirectory(visualsFolder); + } + + string newVisualFolder = Path.Combine(visualsFolder, newVisualId); + + Directory.CreateDirectory(newVisualFolder); + + string newVisualFilePath = Path.Combine(newVisualFolder, "visual.json"); + File.WriteAllText(newVisualFilePath, visualContents); + + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + if (!model.Tables.Any(t => t.Name == tableName)) + { + return model.AddCalculatedTable(tableName, tableExpression); + } + else + { + return model.Tables.Where(t => t.Name == tableName).First(); + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList) + { + Func, string, string> SelectString = (IList options, string title) => + { + var form = new Form(); + form.Text = title; + var buttonPanel = new Panel(); + buttonPanel.Dock = DockStyle.Bottom; + buttonPanel.Height = 30; + var okButton = new Button() { DialogResult = DialogResult.OK, Text = "OK" }; + var cancelButton = new Button() { DialogResult = DialogResult.Cancel, Text = "Cancel", Left = 80 }; + var listbox = new ListBox(); + listbox.Dock = DockStyle.Fill; + listbox.Items.AddRange(options.ToArray()); + listbox.SelectedItem = options[0]; + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + var result = form.ShowDialog(); + if (result == DialogResult.Cancel) return null; + return listbox.SelectedItem.ToString(); + }; + //let the user select the name of the macro to copy + String select = SelectString(OptionList, "Choose a macro"); + //check that indeed one macro was selected + if (select == null) + { + Info("You cancelled!"); + } + return select; + } + public static IEnumerable
GetDateTables(Model model) + { + IEnumerable
dateTables = null as IEnumerable
; + if (model.Tables.Any(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true))) + { + dateTables = model.Tables.Where(t => t.DataCategory == "Time" && t.Columns.Any(c => c.IsKey == true && c.DataType == DataType.DateTime)); + } + else + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + if (matchTables == null) + { + return null; + } + else + { + return matchTables.First(); + } + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + if (tables.Any(t => lambda(t))) + { + return tables.Where(t => lambda(t)); + } + else + { + return null as IEnumerable
; + } + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + if (columns.Any(c => lambda(c))) + { + return columns.Where(c => lambda(c)); + } + else + { + if (returnAllIfNoneFound) + { + return columns; + } + else + { + return null as IEnumerable; + } + } + } + public static Table SelectTableExt(Model model, string possibleName = null, string annotationName = null, string annotationValue = null, + Func lambdaExpression = null, string label = "Select Table", bool skipDialogIfSingleMatch = true, bool showOnlyMatchingTables = true) + { + if (lambdaExpression == null) + { + if (possibleName != null) { + lambdaExpression = (t) => t.Name == possibleName; + } else if(annotationName!= null && annotationValue != null) + { + lambdaExpression = (t) => t.GetAnnotation(annotationName) == annotationValue; + } + } + IEnumerable
tables = model.Tables.Where(lambdaExpression); + //none found, let the user choose from all tables + if (tables.Count() == 0) + { + return SelectTable(tables: model.Tables, label: label); + } + else if (tables.Count() == 1 && !skipDialogIfSingleMatch) + { + return SelectTable(tables: model.Tables, preselect: tables.First(), label: label); + } + else if (tables.Count() == 1 && skipDialogIfSingleMatch) + { + return tables.First(); + } + else if (tables.Count() > 1 && showOnlyMatchingTables) + { + return SelectTable(tables: tables, preselect: tables.First(), label: label); + } + else if (tables.Count() > 1 && !showOnlyMatchingTables) + { + return SelectTable(tables: model.Tables, preselect: tables.First(), label: label); + } else + { + Error(@"Unexpected logic in ""SelectTableExt"""); + return null; + } + } + //add other methods always as "public static" followed by the data type they will return or void if they do not return anything. + + private static void AddPageIdToPages(string pagesFilePath, string pageId) + { + string pagesFileContents = File.ReadAllText(pagesFilePath); + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesFileContents); + if(pagesDto.pageOrder == null) + { + pagesDto.pageOrder = new List(); + } + + if (!pagesDto.pageOrder.Contains(pageId)) { + + pagesDto.pageOrder.Add(pageId); + string resultFile = JsonConvert.SerializeObject(pagesDto, Formatting.Indented); + File.WriteAllText(pagesFilePath, resultFile); + } + } +} + +public class PagesDto +{ + [JsonProperty("$schema")] + public string schema { get; set; } + public List pageOrder { get; set; } + public string activePageName { get; set; } +} \ No newline at end of file diff --git a/Advanced/Report Layer Macros/Fix Broken Fields.cs b/Advanced/Report Layer Macros/Fix Broken Fields.cs new file mode 100644 index 0000000..b8ead67 --- /dev/null +++ b/Advanced/Report Layer Macros/Fix Broken Fields.cs @@ -0,0 +1,1264 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; + + +// 2025-05-10/B.Agullo +// Being connected to a semantic model, the macro will ask for a definition.pbir file +// it will check all the fields used in the report (measures and columns) and compare them with the fields of the model +// for each field in the report not present in the model it will ask for a substitute +// it will proceed to update all visuals that use any of the broken fields and save the changes back to the visual.json files +// It works only with PBIR reports! +// I have tested but there are no guarantees. Also PBIR is still in preview so updates can break this macro +// Be sure to use GIT on your folder and check all modifications before doing the commit +// Full explanation in https://www.esbrina-ba.com/c-scripting-nirvana-v2-0-now-with-reporting-layer/ + + +ReportExtended report = Rx.InitReport(); +if (report == null) return; +var modifiedVisuals = new HashSet(); +// Gather all visuals and all fields used in them +IList allVisuals = (report.Pages ?? new List()) + .SelectMany(p => p.Visuals ?? Enumerable.Empty()) + .ToList(); +IList allReportMeasures = allVisuals + .SelectMany(v => v.GetAllReferencedMeasures()) + .Distinct() + .ToList(); +IList allReportColumns = allVisuals + .SelectMany(v => v.GetAllReferencedColumns()) + .Distinct() + .ToList(); +IList allModelMeasures = Model.AllMeasures + .Select(m => $"{m.Table.DaxObjectFullName}[{m.Name}]") + .ToList(); +IList allModelColumns = Model.AllColumns + .Select(c => c.DaxObjectFullName) + .ToList(); +IList brokenMeasures = allReportMeasures + .Where(m => !allModelMeasures.Contains(m)) + .ToList(); +IList brokenColumns = allReportColumns + .Where(c => !allModelColumns.Contains(c)) + .ToList(); +if(!brokenMeasures.Any() && !brokenColumns.Any()) +{ + Info("No broken measures or columns found."); + return; +} +// Replacement maps for filterConfig patch +var tableReplacementMap = new Dictionary(); +var fieldReplacementMap = new Dictionary(); +foreach (string brokenMeasure in brokenMeasures) +{ + Measure replacement = + SelectMeasure(label: $"{brokenMeasure} was not found in the model. What's the new measure?"); + if (replacement == null) { Error("You Cancelled"); return; } + string oldTable = brokenMeasure.Split('[')[0].Trim('\''); + string oldField = brokenMeasure.Split('[', ']')[1]; + tableReplacementMap[oldTable] = replacement.Table.Name; + fieldReplacementMap[oldField] = replacement.Name; + foreach (var visual in allVisuals) + { + if (visual.GetAllReferencedMeasures().Contains(brokenMeasure)) + { + visual.ReplaceMeasure(brokenMeasure, replacement, modifiedVisuals); + } + } +} +foreach (string brokenColumn in brokenColumns) +{ + Column replacement = SelectColumn(Model.AllColumns, label: $"{brokenColumn} was not found in the model. What's the new column?"); + if (replacement == null) { Error("You Cancelled"); return; } + string oldTable = brokenColumn.Split('[')[0].Trim('\''); + string oldField = brokenColumn.Split('[', ']')[1]; + tableReplacementMap[oldTable] = replacement.Table.Name; + fieldReplacementMap[oldField] = replacement.Name; + foreach (var visual in allVisuals) + { + if (visual.GetAllReferencedColumns().Contains(brokenColumn)) + { + visual.ReplaceColumn(brokenColumn, replacement, modifiedVisuals); + } + } +} +// Apply raw text-based replacement to filterConfig JSON strings +foreach (var visual in allVisuals) +{ + visual.ReplaceInFilterConfigRaw(tableReplacementMap, fieldReplacementMap, modifiedVisuals); +} +// Save modified visuals +foreach (var visual in modifiedVisuals) +{ + Rx.SaveVisual(visual); +} +Output($"{modifiedVisuals.Count} visuals were modified."); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList) + { + Func, string, string> SelectString = (IList options, string title) => + { + var form = new Form(); + form.Text = title; + var buttonPanel = new Panel(); + buttonPanel.Dock = DockStyle.Bottom; + buttonPanel.Height = 30; + var okButton = new Button() { DialogResult = DialogResult.OK, Text = "OK" }; + var cancelButton = new Button() { DialogResult = DialogResult.Cancel, Text = "Cancel", Left = 80 }; + var listbox = new ListBox(); + listbox.Dock = DockStyle.Fill; + listbox.Items.AddRange(options.ToArray()); + listbox.SelectedItem = options[0]; + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + var result = form.ShowDialog(); + if (result == DialogResult.Cancel) return null; + return listbox.SelectedItem.ToString(); + }; + //let the user select the name of the macro to copy + String select = SelectString(OptionList, "Choose a macro"); + //check that indeed one macro was selected + if (select == null) + { + Info("You Cancelled!"); + } + return select; + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} + +public static class Rx + +{ + + + + + + + + public static ReportExtended InitReport() + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePath(); + + if (basePath == null) + + { + + Error("Operation canceled by the user."); + + return null; + + } + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = Path.Combine(targetPath, "pages.json"); + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + //VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + public static VisualExtended SelectVisual(ReportExtended report) + + { + + // Step 1: Build selection list + + var visualSelectionList = report.Pages + + .SelectMany(p => p.Visuals.Select(v => new + + { + + Display = string.Format("{0} - {1} ({2}, {3})", p.Page.DisplayName, v.Content.Visual.VisualType, (int)v.Content.Position.X, (int)v.Content.Position.Y), + + Page = p, + + Visual = v + + })) + + .ToList(); + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath() + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = "Please select definition.pbir file of the target report", + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + [JsonProperty("filterConfig")] public object FilterConfig { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + [JsonProperty("tabOrder")] public int TabOrder { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + } + + public class QueryState + { + [JsonProperty("Y")] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Values")] public VisualDto.ProjectionsSet Values { get; set; } + [JsonProperty("Category")] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Series")] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data")] public VisualDto.ProjectionsSet Data { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + + + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + public class VisualObjectProperty + { + [JsonProperty("expr")] public Field Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + } + + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig)); + + return fields.Where(f => f != null); + } + + private IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var prop in obj.Properties.Values) + { + if (prop?.Expr != null) + { + if (prop.Expr.Measure != null) yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + if (prop.Expr.Column != null) yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop?.Color?.Expr?.FillRule?.Input != null) + { + yield return prop.Color.Expr.FillRule.Input; + } + + if (prop?.Solid?.Color?.Expr?.FillRule?.Input != null) + { + yield return prop.Solid.Color.Expr.FillRule.Input; + } + // Color measure (outside FillRule) + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(object filterConfig) + { + var fields = new List(); + + if (filterConfig is JObject jObj) + { + foreach (var token in jObj.DescendantsAndSelf().OfType()) + { + var table = token["table"]?.ToString(); + var property = token["column"]?.ToString() ?? token["measure"]?.ToString(); + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(property)) + { + var field = new VisualDto.Field(); + + if (token["measure"] != null) + { + field.Measure = new VisualDto.MeasureObject + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + else if (token["column"] != null) + { + field.Column = new VisualDto.ColumnField + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + + fields.Add(field); + } + } + } + + return fields; + } + + + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure) + { + f.Measure = newField.Measure; + f.Column = null; + wasModified = true; + } + else + { + f.Column = newField.Column; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? newField.Measure.Expression?.SourceRef?.Entity + : newField.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? newField.Measure.Property + : newField.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + proj.NativeQueryRef = prop; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties?.Values ?? Enumerable.Empty()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure = newField.Measure; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column = newField.Column; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure = newField.Measure; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column = newField.Column; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure = newField.Measure; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column = newField.Column; + solidInput.Measure = null; + wasModified = true; + } + } + + // ✅ NEW: handle direct measure/column under solid.color.expr + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure = newField.Measure; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column = newField.Column; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (Content.FilterConfig != null) + { + var filterConfigString = Content.FilterConfig.ToString(); + string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + string oldPattern = oldFieldKey; + string newPattern = $"'{table}'[{prop}]"; + + if (filterConfigString.Contains(oldPattern)) + { + Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + wasModified = true; + } + } + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + + } + + public void ReplaceInFilterConfigRaw( + Dictionary tableMap, + Dictionary fieldMap, + HashSet modifiedVisuals = null) + { + if (Content.FilterConfig == null) return; + + string originalJson = JsonConvert.SerializeObject(Content.FilterConfig); + string updatedJson = originalJson; + + foreach (var kv in tableMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + foreach (var kv in fieldMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + // Only update and track if something actually changed + if (updatedJson != originalJson) + { + Content.FilterConfig = JsonConvert.DeserializeObject(updatedJson); + modifiedVisuals?.Add(this); + } + } + + } + + + + public class PageExtended + { + public PageDto Page { get; set; } + public IList Visuals { get; set; } + public string PageFilePath { get; set; } + + public PageExtended() + { + Visuals = new List(); + } + } + + + public class ReportExtended + { + public IList Pages { get; set; } + public string PagesFilePath { get; set; } + + public PagesDto PagesConfig { get; set; } + + public ReportExtended() + { + Pages = new List(); + + } + } diff --git a/Advanced/Report Layer Macros/Open Visual Json file.csx b/Advanced/Report Layer Macros/Open Visual Json file.csx new file mode 100644 index 0000000..bccdc72 --- /dev/null +++ b/Advanced/Report Layer Macros/Open Visual Json file.csx @@ -0,0 +1,2214 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +//2025-05-25/B.Agullo +//this script allows the user to open the JSON file of one or more visuals in the report. +//see https://www.esbrina-ba.com/pbir-scripts-to-replace-field-and-open-visual-json-files/ for reference on how to use it +// Step 1: Initialize the report object +ReportExtended report = Rx.InitReport(); +if (report == null) return; +// Step 2: Gather all visuals with page info +var allVisuals = report.Pages + .SelectMany(p => p.Visuals.Select(v => new { Page = p.Page, Visual = v })) + .ToList(); +if (allVisuals.Count == 0) +{ + Info("No visuals found in the report."); + return; +} +// Step 3: Prepare display names for selection +var visualDisplayList = allVisuals.Select(x => + String.Format( + @"{0} - {1} ({2}, {3})", + x.Page.DisplayName, + x.Visual?.Content?.Visual?.VisualType + ?? x.Visual?.Content?.VisualGroup?.DisplayName, + (int)x.Visual.Content.Position.X, + (int)x.Visual.Content.Position.Y) +).ToList(); +// Step 4: Let the user select one or more visuals +List selected = Fx.ChooseStringMultiple(OptionList: visualDisplayList, label: "Select visuals to open JSON files"); +if (selected == null || selected.Count == 0) +{ + Info("No visuals selected."); + return; +} +// Step 5: For each selected visual, open its JSON file +foreach (var visualEntry in allVisuals) +{ + string display = String.Format + (@"{0} - {1} ({2}, {3})", + visualEntry.Page.DisplayName, + visualEntry?.Visual?.Content?.Visual?.VisualType + ?? visualEntry.Visual?.Content?.VisualGroup?.DisplayName, + (int)visualEntry.Visual.Content.Position.X, + (int)visualEntry.Visual.Content.Position.Y); + if (selected.Contains(display)) + { + string jsonPath = visualEntry.Visual.VisualFilePath; + if (!File.Exists(jsonPath)) + { + Error(String.Format(@"JSON file not found: {0}", jsonPath)); + continue; + } + System.Diagnostics.Process.Start(jsonPath); + } +} + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList, string label = "Choose item") + { + return ChooseStringInternal(OptionList, MultiSelect: false, label:label) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)") + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)") + { + Form form = new Form + { + Text =label, + Width = 400, + Height = 500, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect }; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "YourAppName", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label); + + + + string chosenPath = null; + + if (selected == "Browse for new file..." || string.IsNullOrEmpty(selected)) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) + + { + + Error("Operation canceled by the user."); + + return null; + + } + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + public static VisualExtended SelectVisual(ReportExtended report) + + { + + return SelectVisualInternal(report, Multiselect: false) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report) + + { + + return SelectVisualInternal(report, Multiselect: true) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect) + + { + + // Step 1: Build selection list + + var visualSelectionList = report.Pages + + .SelectMany(p => p.Visuals.Select(v => new + + { + + Display = string.Format("{0} - {1} ({2}, {3})", p.Page.DisplayName, v.Content.Visual.VisualType, (int)v.Content.Position.X, (int)v.Content.Position.Y), + + Page = p, + + Visual = v + + })) + + .ToList(); + + + + if(visualSelectionList.Count == 0) + + { + + Error("No visuals found in the report."); + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public object FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + [JsonProperty("tabOrder")] public int TabOrder { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + + + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public Field Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + + public Boolean isVisualGroup => Content?.VisualGroup != null; + public Boolean isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if(Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + // Ensure the structure exists + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig)); + + return fields.Where(f => f != null); + } + + private IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color != null && + prop.Color.Expr != null && + prop.Color.Expr.FillRule != null && + prop.Color.Expr.FillRule.Input != null) + { + yield return prop.Color.Expr.FillRule.Input; + } + + if (prop.Solid != null && + prop.Solid.Color != null && + prop.Solid.Color.Expr != null && + prop.Solid.Color.Expr.FillRule != null && + prop.Solid.Color.Expr.FillRule.Input != null) + { + yield return prop.Solid.Color.Expr.FillRule.Input; + } + + var solidExpr = prop.Solid != null && + prop.Solid.Color != null + ? prop.Solid.Color.Expr + : null; + + if (solidExpr != null) + { + if (solidExpr.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + + if (solidExpr.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(object filterConfig) + { + var fields = new List(); + + if (filterConfig is JObject jObj) + { + foreach (var token in jObj.DescendantsAndSelf().OfType()) + { + var table = token["table"]?.ToString(); + var property = token["column"]?.ToString() ?? token["measure"]?.ToString(); + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(property)) + { + var field = new VisualDto.Field(); + + if (token["measure"] != null) + { + field.Measure = new VisualDto.MeasureObject + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + else if (token["column"] != null) + { + field.Column = new VisualDto.ColumnField + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + + fields.Add(field); + } + } + } + + return fields; + } + + + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure) + { + f.Measure = newField.Measure; + f.Column = null; + wasModified = true; + } + else + { + f.Column = newField.Column; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? newField.Measure.Expression?.SourceRef?.Entity + : newField.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? newField.Measure.Property + : newField.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + //proj.NativeQueryRef = prop; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure = newField.Measure; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column = newField.Column; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure = newField.Measure; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column = newField.Column; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure = newField.Measure; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column = newField.Column; + solidInput.Measure = null; + wasModified = true; + } + } + + // ✅ NEW: handle direct measure/column under solid.color.expr + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure = newField.Measure; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column = newField.Column; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (Content.FilterConfig != null) + { + var filterConfigString = Content.FilterConfig.ToString(); + string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + string oldPattern = oldFieldKey; + string newPattern = $"'{table}'[{prop}]"; + + if (filterConfigString.Contains(oldPattern)) + { + Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + wasModified = true; + } + } + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + + } + + public void ReplaceInFilterConfigRaw( + Dictionary tableMap, + Dictionary fieldMap, + HashSet modifiedVisuals = null) + { + if (Content.FilterConfig == null) return; + + string originalJson = JsonConvert.SerializeObject(Content.FilterConfig); + string updatedJson = originalJson; + + foreach (var kv in tableMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + foreach (var kv in fieldMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + // Only update and track if something actually changed + if (updatedJson != originalJson) + { + Content.FilterConfig = JsonConvert.DeserializeObject(updatedJson); + modifiedVisuals?.Add(this); + } + } + + } + + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/Repleace Field.csx b/Advanced/Report Layer Macros/Repleace Field.csx new file mode 100644 index 0000000..7cbf4a3 --- /dev/null +++ b/Advanced/Report Layer Macros/Repleace Field.csx @@ -0,0 +1,1432 @@ +#r "Microsoft.VisualBasic" +//2025-05-25/B.Agullo +//provided a definition.pbir file, this script allows the user to replace a measure in all visuals that use it with another measure. +//when executing the script you must be connected to the semantic model to which the report is connected to or one that is identical. +//see https://www.esbrina-ba.com/pbir-scripts-to-replace-field-and-open-visual-json-files/ for reference on how to use it +using System.Windows.Forms; + + + +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +ReportExtended report = Rx.InitReport(); +if (report == null) return; +var modifiedVisuals = new HashSet(); +var allVisuals = report.Pages + .SelectMany(p => p.Visuals.Select(v => new { Page = p.Page, Visual = v })) + .ToList(); +IList allReportMeasures = allVisuals + .SelectMany(x => x.Visual.GetAllReferencedMeasures()) + .Distinct() + .ToList(); +string measureToReplace = Fx.ChooseString( + OptionList: allReportMeasures, + "Select a measure to replace" +); +if (string.IsNullOrEmpty(measureToReplace)) +{ + Error("No measure selected."); + return; +} +Measure replacementMeasure = SelectMeasure( + label: $"Select a replacement for '{measureToReplace}'" +); +if (replacementMeasure == null) +{ + Error("No replacement measure selected."); + return; +} +var visualsUsingMeasure = allVisuals + .Where(x => x.Visual.GetAllReferencedMeasures().Contains(measureToReplace)) + .Select(x => new + { + Display = $"{x.Page.DisplayName} - {x.Visual.Content.Visual.VisualType} ({(int)x.Visual.Content.Position.X}, {(int)x.Visual.Content.Position.Y})", + Visual = x.Visual + }) + .ToList(); +if (visualsUsingMeasure.Count == 0) +{ + Info($"No visuals use the measure '{measureToReplace}'."); + return; +} +// Step 2: Let the user choose one or more visuals +var options = visualsUsingMeasure.Select(v => v.Display).ToList(); +List selected = Fx.ChooseStringMultiple(options, "Select visuals to update"); +if (selected == null || selected.Count == 0) +{ + Info("No visuals selected."); + return; +} +// Step 3: Apply replacement only to selected visuals +foreach (var visualEntry in visualsUsingMeasure) +{ + if (selected.Contains(visualEntry.Display)) + { + visualEntry.Visual.ReplaceMeasure(measureToReplace, replacementMeasure); + modifiedVisuals.Add(visualEntry.Visual); + } +} +// Save modified visuals +foreach (var visual in modifiedVisuals) +{ + Rx.SaveVisual(visual); +} +Output($"{modifiedVisuals.Count} visuals were modified."); + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList, string Label = "Choose item") + { + return ChooseStringInternal(OptionList, MultiSelect: false, Label:Label) as string; + } + public static List ChooseStringMultiple(IList OptionList, string Label = "Choose item(s)") + { + return ChooseStringInternal(OptionList, MultiSelect:true, Label:Label) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string Label = "Choose item(s)") + { + Form form = new Form + { + Text =Label, + Width = 400, + Height = 500, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect }; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} + +public static class Rx + +{ + + + + + + + + public static ReportExtended InitReport() + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePath(); + + if (basePath == null) + + { + + Error("Operation canceled by the user."); + + return null; + + } + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = Path.Combine(targetPath, "pages.json"); + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + public static VisualExtended SelectVisual(ReportExtended report) + + { + + // Step 1: Build selection list + + var visualSelectionList = report.Pages + + .SelectMany(p => p.Visuals.Select(v => new + + { + + Display = string.Format("{0} - {1} ({2}, {3})", p.Page.DisplayName, v.Content.Visual.VisualType, (int)v.Content.Position.X, (int)v.Content.Position.Y), + + Page = p, + + Visual = v + + })) + + .ToList(); + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath() + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = "Please select definition.pbir file of the target report", + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + [JsonProperty("visualContainerObjects")] public object VisualContainerObjects { get; set; } + [JsonProperty("filterConfig")] public object FilterConfig { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + [JsonProperty("tabOrder")] public int TabOrder { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType", Order = 1)] public string VisualType { get; set; } + [JsonProperty("query", Order = 2)] public Query Query { get; set; } + [JsonProperty("objects", Order = 3)] public Objects Objects { get; set; } + [JsonProperty("drillFilterOtherVisuals", Order = 4)] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + + + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public Field Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig)); + + return fields.Where(f => f != null); + } + + private IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color != null && + prop.Color.Expr != null && + prop.Color.Expr.FillRule != null && + prop.Color.Expr.FillRule.Input != null) + { + yield return prop.Color.Expr.FillRule.Input; + } + + if (prop.Solid != null && + prop.Solid.Color != null && + prop.Solid.Color.Expr != null && + prop.Solid.Color.Expr.FillRule != null && + prop.Solid.Color.Expr.FillRule.Input != null) + { + yield return prop.Solid.Color.Expr.FillRule.Input; + } + + var solidExpr = prop.Solid != null && + prop.Solid.Color != null + ? prop.Solid.Color.Expr + : null; + + if (solidExpr != null) + { + if (solidExpr.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + + if (solidExpr.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(object filterConfig) + { + var fields = new List(); + + if (filterConfig is JObject jObj) + { + foreach (var token in jObj.DescendantsAndSelf().OfType()) + { + var table = token["table"]?.ToString(); + var property = token["column"]?.ToString() ?? token["measure"]?.ToString(); + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(property)) + { + var field = new VisualDto.Field(); + + if (token["measure"] != null) + { + field.Measure = new VisualDto.MeasureObject + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + else if (token["column"] != null) + { + field.Column = new VisualDto.ColumnField + { + Property = property, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = table } + } + }; + } + + fields.Add(field); + } + } + } + + return fields; + } + + + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure) + { + f.Measure = newField.Measure; + f.Column = null; + wasModified = true; + } + else + { + f.Column = newField.Column; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? newField.Measure.Expression?.SourceRef?.Entity + : newField.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? newField.Measure.Property + : newField.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + //proj.NativeQueryRef = prop; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure = newField.Measure; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column = newField.Column; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure = newField.Measure; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column = newField.Column; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure = newField.Measure; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column = newField.Column; + solidInput.Measure = null; + wasModified = true; + } + } + + // ✅ NEW: handle direct measure/column under solid.color.expr + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure = newField.Measure; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column = newField.Column; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (Content.FilterConfig != null) + { + var filterConfigString = Content.FilterConfig.ToString(); + string table = isMeasure ? newField.Measure.Expression.SourceRef.Entity : newField.Column.Expression.SourceRef.Entity; + string prop = isMeasure ? newField.Measure.Property : newField.Column.Property; + + string oldPattern = oldFieldKey; + string newPattern = $"'{table}'[{prop}]"; + + if (filterConfigString.Contains(oldPattern)) + { + Content.FilterConfig = filterConfigString.Replace(oldPattern, newPattern); + wasModified = true; + } + } + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + + } + + public void ReplaceInFilterConfigRaw( + Dictionary tableMap, + Dictionary fieldMap, + HashSet modifiedVisuals = null) + { + if (Content.FilterConfig == null) return; + + string originalJson = JsonConvert.SerializeObject(Content.FilterConfig); + string updatedJson = originalJson; + + foreach (var kv in tableMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + foreach (var kv in fieldMap) + updatedJson = updatedJson.Replace($"\"{kv.Key}\"", $"\"{kv.Value}\""); + + // Only update and track if something actually changed + if (updatedJson != originalJson) + { + Content.FilterConfig = JsonConvert.DeserializeObject(updatedJson); + modifiedVisuals?.Add(this); + } + } + + } + + + + public class PageExtended + { + public PageDto Page { get; set; } + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/apply display names.csx b/Advanced/Report Layer Macros/apply display names.csx new file mode 100644 index 0000000..44f333a --- /dev/null +++ b/Advanced/Report Layer Macros/apply display names.csx @@ -0,0 +1,2993 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; +// 2025-10-31 / B.Agullo +// Initializes a report, lets user select visuals, iterates through projections, +// and applies DisplayName annotations from the model to the projections in the report. +#if TE3 + ScriptHelper.WaitFormVisible = false; +#endif +// Step 1: Initialize report +ReportExtended report = Rx.InitReport(); +if (report == null) return; +// Step 2: Let user select visuals +IList selectedVisuals = Rx.SelectVisuals(report); +if (selectedVisuals == null || selectedVisuals.Count == 0) +{ + Info("No visuals selected."); + return; +} +int updatedCount = 0; +int visualsUpdated = 0; +// Step 3: Process each selected visual +foreach (var visual in selectedVisuals) +{ + var queryState = visual.Content?.Visual?.Query?.QueryState; + if (queryState == null) continue; + // Create list of all projection sets to iterate + var projectionSets = new List + { + queryState.Values, + queryState.Y, + queryState.Y2, + queryState.Category, + queryState.Series, + queryState.Data, + queryState.Rows + }; + bool visualModified = false; + // Iterate through each projection set + foreach (var projectionSet in projectionSets) + { + if (projectionSet?.Projections == null) continue; + foreach (var projection in projectionSet.Projections) + { + if (projection?.Field == null) continue; + string displayNameFromModel = null; + // Check if it's a measure + if (projection.Field.Measure != null) + { + var measureExpr = projection.Field.Measure; + if (measureExpr.Expression?.SourceRef?.Entity != null && measureExpr.Property != null) + { + string fullName = String.Format("'{0}'[{1}]", measureExpr.Expression.SourceRef.Entity, measureExpr.Property); + var measure = Model.AllMeasures.FirstOrDefault(m => m.Table.DaxObjectFullName + m.DaxObjectFullName == fullName); + if (measure != null) + { + displayNameFromModel = measure.GetAnnotation("DisplayName"); + } + } + } + // Check if it's a column + else if (projection.Field.Column != null) + { + var columnExpr = projection.Field.Column; + if (columnExpr.Expression?.SourceRef?.Entity != null && columnExpr.Property != null) + { + string fullName = String.Format("'{0}'[{1}]", columnExpr.Expression.SourceRef.Entity, columnExpr.Property); + var column = Model.AllColumns.FirstOrDefault(c => c.DaxObjectFullName == fullName); + if (column != null) + { + displayNameFromModel = column.GetAnnotation("DisplayName"); + } + } + } + // Apply display name if found in model + if (!string.IsNullOrEmpty(displayNameFromModel)) + { + // Check if projection already has a display name + if (string.IsNullOrEmpty(projection.DisplayName) || projection.DisplayName != displayNameFromModel) + { + projection.DisplayName = displayNameFromModel; + updatedCount++; + visualModified = true; + } + } + } + } + // Save visual if it was modified + if (visualModified) + { + Rx.SaveVisual(visual); + visualsUpdated++; + } +} +Output(String.Format("Updated {0} display names in {1} visuals of the report.", updatedCount, visualsUpdated)); + +public static class Fx +{ + public static void CheckCompatibilityVersion(Model model, int requiredVersion, string customMessage = "Compatibility level must be raised to {0} to run this script. Do you want raise the compatibility level?") + { + if (model.Database.CompatibilityLevel < requiredVersion) + { + if (Fx.IsAnswerYes(String.Format("The model compatibility level is below {0}. " + customMessage, requiredVersion))) + { + model.Database.CompatibilityLevel = requiredVersion; + } + else + { + Info("Operation cancelled."); + return; + } + } + } + public static Function CreateFunction( + Model model, + string name, + string expression, + out bool functionCreated, + string description = null, + string annotationLabel = null, + string annotationValue = null, + string outputType = null, + string nameTemplate = null, + string formatString = null, + string displayFolder = null, + string outputDestination = null) + { + Function function = null as Function; + functionCreated = false; + var matchingFunctions = model.Functions.Where(f => f.GetAnnotation(annotationLabel) == annotationValue); + if (matchingFunctions.Count() == 1) + { + return matchingFunctions.First(); + } + else if (matchingFunctions.Count() == 0) + { + function = model.AddFunction(name); + function.Expression = expression; + function.Description = description; + functionCreated = true; + } + else + { + Error("More than one function found with annoation " + annotationLabel + " value " + annotationValue); + return null as Function; + } + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + function.SetAnnotation(annotationLabel, annotationValue); + } + if (!string.IsNullOrEmpty(outputType)) + { + function.SetAnnotation("outputType", outputType); + } + if (!string.IsNullOrEmpty(nameTemplate)) + { + function.SetAnnotation("nameTemplate", nameTemplate); + } + if (!string.IsNullOrEmpty(formatString)) + { + function.SetAnnotation("formatString", formatString); + } + if (!string.IsNullOrEmpty(displayFolder)) + { + function.SetAnnotation("displayFolder", displayFolder); + } + if (!string.IsNullOrEmpty(outputDestination)) + { + function.SetAnnotation("outputDestination", outputDestination); + } + return function; + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression = "FILTER({0},FALSE)") + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static Measure CreateMeasure( + Table table, + string measureName, + string measureExpression, + out bool measureCreated, + string formatString = null, + string displayFolder = null, + string description = null, + string annotationLabel = null, + string annotationValue = null, + bool isHidden = false) + { + measureCreated = false; + IEnumerable matchingMeasures = null as IEnumerable; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + matchingMeasures = table.Measures.Where(m => m.GetAnnotation(annotationLabel) == annotationValue); + } + else + { + matchingMeasures = table.Measures.Where(m => m.Name == measureName); + } + if (matchingMeasures.Count() == 1) + { + return matchingMeasures.First(); + } + else if (matchingMeasures.Count() == 0) + { + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = description; + measure.DisplayFolder = displayFolder; + measure.FormatString = formatString; + measureCreated = true; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + measure.SetAnnotation(annotationLabel, annotationValue); + } + measure.IsHidden = isHidden; + return measure; + } + else + { + Error("More than one measure found with annoation " + annotationLabel + " value " + annotationValue); + Output(matchingMeasures); + return null as Measure; + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + + + public static VisualExtended SelectTableVisual(ReportExtended report) + + { + + List visualTypes = new List + + { + + "tableEx","pivotTable" + + }; + + return SelectVisual(report: report, visualTypes); + + } + + + + + + + + public static VisualExtended SelectVisual(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: false, visualTypeList:visualTypeList) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: true, visualTypeList:visualTypeList) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect, List visualTypeList = null) + + { + + // Step 1: Build selection list + + var visualSelectionList = + + report.Pages + + .SelectMany(p => p.Visuals + + .Where(v => + + v?.Content != null && + + ( + + // If visualTypeList is null, do not filter at all + + (visualTypeList == null) || + + // If visualTypeList is provided and not empty, filter by it + + (visualTypeList.Count > 0 && v.Content.Visual != null && visualTypeList.Contains(v.Content?.Visual?.VisualType)) + + // Otherwise, include all visuals and visual groups + + || (visualTypeList.Count == 0) + + ) + + ) + + .Select(v => new + + { + + // Use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)(v.Content.Position?.X ?? 0), + + (int)(v.Content.Position?.Y ?? 0) + + ), + + Page = p, + + Visual = v + + } + + ) + + ) + + .ToList(); + + + + if (visualSelectionList.Count == 0) + + { + + if (visualTypeList != null) + + { + + string types = string.Join(", ", visualTypeList); + + Error(string.Format("No visual of type {0} were found", types)); + + + + }else + + { + + Error("No visuals found in the report."); + + } + + + + + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public FilterConfig FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + + [JsonProperty("tabOrder", NullValueHandling = NullValueHandling.Ignore)] + public int? TabOrder { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonProperty("fieldParameters")] public List FieldParameters { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FieldParameter + { + [JsonProperty("parameterExpr")] + public Field ParameterExpr { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("length")] + public int Length { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + [JsonProperty("columnFormatting")] public List ColumnFormatting { get; set; } + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public VisualPropertyExpr Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class VisualPropertyExpr + { + // Existing Field properties + [JsonProperty("Measure")] public MeasureObject Measure { get; set; } + [JsonProperty("Column")] public ColumnField Column { get; set; } + [JsonProperty("Aggregation")] public Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + + // New properties from JSON + [JsonProperty("SelectRef")] public SelectRefExpression SelectRef { get; set; } + [JsonProperty("Literal")] public VisualLiteral Literal { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SelectRefExpression + { + [JsonProperty("ExpressionName")] + public string ExpressionName { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ThemeDataColor + { + [JsonProperty("ColorId")] public int ColorId { get; set; } + [JsonProperty("Percent")] public double Percent { get; set; } + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + + public VisualLiteral Literal { get; set; } + + [JsonProperty("ThemeDataColor")] + public ThemeDataColor ThemeDataColor { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataObject + { + [JsonProperty("dataViewWildcard")] + public DataViewWildcard DataViewWildcard { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataViewWildcard + { + [JsonProperty("matchingOption")] + public int MatchingOption { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterConfig + { + [JsonProperty("filters")] + public List Filters { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualFilter + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("filter")] public FilterDefinition Filter { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterDefinition + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Where")] public List Where { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterFrom + { + [JsonProperty("Name")] public string Name { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Type")] public int Type { get; set; } + [JsonProperty("Expression")] public FilterExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterExpression + { + [JsonProperty("Subquery")] public SubqueryExpression Subquery { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryExpression + { + [JsonProperty("Query")] public SubqueryQuery Query { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryQuery + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Select")] public List Select { get; set; } + [JsonProperty("OrderBy")] public List OrderBy { get; set; } + [JsonProperty("Top")] public int? Top { get; set; } + + [JsonProperty("Where")] public List Where { get; set; } // 🔹 Added + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + + public class SelectExpression + { + [JsonProperty("Column")] public ColumnSelect Column { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnSelect + { + [JsonProperty("Expression")] + public VisualDto.Expression Expression { get; set; } // NOTE: wrapper that contains "SourceRef" + + [JsonProperty("Property")] + public string Property { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByExpression + { + [JsonProperty("Direction")] public int Direction { get; set; } + [JsonProperty("Expression")] public OrderByInnerExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByInnerExpression + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterWhere + { + [JsonProperty("Condition")] public Condition Condition { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Condition + { + [JsonProperty("In")] public InExpression In { get; set; } + [JsonProperty("Not")] public NotExpression Not { get; set; } + [JsonProperty("Comparison")] public ComparisonExpression Comparison { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InExpression + { + [JsonProperty("Expressions")] public List Expressions { get; set; } + [JsonProperty("Table")] public InTable Table { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InTable + { + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NotExpression + { + [JsonProperty("Expression")] public Condition Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ComparisonExpression + { + [JsonProperty("ComparisonKind")] public int ComparisonKind { get; set; } + [JsonProperty("Left")] public FilterOperand Left { get; set; } + [JsonProperty("Right")] public FilterOperand Right { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterOperand + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + [JsonProperty("Literal")] public LiteralOperand Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class LiteralOperand + { + [JsonProperty("Value")] public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + public bool isVisualGroup => Content?.VisualGroup != null; + public bool isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if (Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig as VisualDto.FilterConfig)); + + return fields.Where(f => f != null); + } + + public IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color?.Expr?.FillRule?.Input != null) + yield return prop.Color.Expr.FillRule.Input; + + if (prop.Solid?.Color?.Expr?.FillRule?.Input != null) + yield return prop.Solid.Color.Expr.FillRule.Input; + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(VisualDto.FilterConfig filterConfig) + { + var fields = new List(); + + if (filterConfig?.Filters == null) + return fields; + + foreach (var filter in filterConfig.Filters ?? Enumerable.Empty()) + { + if (filter.Field != null) + fields.Add(filter.Field); + + if (filter.Filter != null) + { + var aliasMap = BuildAliasMap(filter.Filter.From); + + foreach (var from in filter.Filter.From ?? Enumerable.Empty()) + { + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + } + + foreach (var where in filter.Filter.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + } + + return fields; + } + + private void ExtractFieldsFromSubquery(VisualDto.SubqueryQuery query, List fields) + { + var aliasMap = BuildAliasMap(query.From); + + // SELECT columns + foreach (var sel in query.Select ?? Enumerable.Empty()) + { + var srcRef = sel.Column?.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + var columnExpr = sel.Column ?? new VisualDto.ColumnSelect(); + columnExpr.Expression ??= new VisualDto.Expression(); + columnExpr.Expression.SourceRef ??= new VisualDto.SourceRef(); + columnExpr.Expression.SourceRef.Source = srcRef.Source; + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = sel.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = columnExpr.Expression.SourceRef + } + } + }); + } + + // ORDER BY measures + foreach (var ob in query.OrderBy ?? Enumerable.Empty()) + { + var measureExpr = ob.Expression?.Measure?.Expression ?? new VisualDto.Expression(); + measureExpr.SourceRef ??= new VisualDto.SourceRef(); + measureExpr.SourceRef.Source = ResolveSource(measureExpr.SourceRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = ob.Expression.Measure.Property, + Expression = measureExpr + } + }); + } + + // Nested subqueries + foreach (var from in query.From ?? Enumerable.Empty()) + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + + // WHERE conditions + foreach (var where in query.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + private Dictionary BuildAliasMap(List fromList) + { + var map = new Dictionary(); + foreach (var from in fromList ?? Enumerable.Empty()) + { + if (!string.IsNullOrEmpty(from.Name) && !string.IsNullOrEmpty(from.Entity)) + map[from.Name] = from.Entity; + } + return map; + } + + private string ResolveSource(string source, Dictionary aliasMap) + { + if (string.IsNullOrEmpty(source)) + return source; + return aliasMap.TryGetValue(source, out var entity) ? entity : source; + } + + private void ExtractFieldsFromCondition(VisualDto.Condition condition, List fields, Dictionary aliasMap) + { + if (condition == null) return; + + // IN Expression + if (condition.In != null) + { + foreach (var expr in condition.In.Expressions ?? Enumerable.Empty()) + { + var srcRef = expr.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = expr.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + + // NOT Expression + if (condition.Not != null) + ExtractFieldsFromCondition(condition.Not.Expression, fields, aliasMap); + + // COMPARISON Expression + if (condition.Comparison != null) + { + AddOperandField(condition.Comparison.Left, fields, aliasMap); + AddOperandField(condition.Comparison.Right, fields, aliasMap); + } + } + private void AddOperandField(VisualDto.FilterOperand operand, List fields, Dictionary aliasMap) + { + if (operand == null) return; + + // MEASURE + if (operand.Measure != null) + { + var srcRef = operand.Measure.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = operand.Measure.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + + // COLUMN + if (operand.Column != null) + { + var srcRef = operand.Column.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = operand.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure && newField.Measure != null) + { + // Preserve Expression with SourceRef + f.Measure ??= new VisualDto.MeasureObject(); + f.Measure.Property = newField.Measure.Property; + f.Measure.Expression ??= new VisualDto.Expression(); + f.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Measure.Expression.SourceRef.Entity, + Source = newField.Measure.Expression.SourceRef.Source + } + : f.Measure.Expression.SourceRef; + f.Column = null; + wasModified = true; + } + else if (!isMeasure && newField.Column != null) + { + // Preserve Expression with SourceRef + f.Column ??= new VisualDto.ColumnField(); + f.Column.Property = newField.Column.Property; + f.Column.Expression ??= new VisualDto.Expression(); + f.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Column.Expression.SourceRef.Entity, + Source = newField.Column.Expression.SourceRef.Source + } + : f.Column.Expression.SourceRef; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? proj.Field.Measure.Expression?.SourceRef?.Entity + : proj.Field.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? proj.Field.Measure.Property + : proj.Field.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure ??= new VisualDto.MeasureObject(); + prop.Expr.Measure.Property = newField.Measure.Property; + prop.Expr.Measure.Expression ??= new VisualDto.Expression(); + prop.Expr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column ??= new VisualDto.ColumnField(); + prop.Expr.Column.Property = newField.Column.Property; + prop.Expr.Column.Expression ??= new VisualDto.Expression(); + prop.Expr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure ??= new VisualDto.MeasureObject(); + fillInput.Measure.Property = newField.Measure.Property; + fillInput.Measure.Expression ??= new VisualDto.Expression(); + fillInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column ??= new VisualDto.ColumnField(); + fillInput.Column.Property = newField.Column.Property; + fillInput.Column.Expression ??= new VisualDto.Expression(); + fillInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure ??= new VisualDto.MeasureObject(); + solidInput.Measure.Property = newField.Measure.Property; + solidInput.Measure.Expression ??= new VisualDto.Expression(); + solidInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column ??= new VisualDto.ColumnField(); + solidInput.Column.Property = newField.Column.Property; + solidInput.Column.Expression ??= new VisualDto.Expression(); + solidInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidInput.Measure = null; + wasModified = true; + } + } + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure ??= new VisualDto.MeasureObject(); + solidExpr.Measure.Property = newField.Measure.Property; + solidExpr.Measure.Expression ??= new VisualDto.Expression(); + solidExpr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column ??= new VisualDto.ColumnField(); + solidExpr.Column.Property = newField.Column.Property; + solidExpr.Column.Expression ??= new VisualDto.Expression(); + solidExpr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + } + + } + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Advanced/Report Layer Macros/store display names.csx b/Advanced/Report Layer Macros/store display names.csx new file mode 100644 index 0000000..5f9d8b2 --- /dev/null +++ b/Advanced/Report Layer Macros/store display names.csx @@ -0,0 +1,3065 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; +using Microsoft.VisualBasic; +using System.IO; +using Newtonsoft.Json.Linq; + +// 2025-10-30 / B.Agullo +// Initializes a report, lets user select visuals, iterates through projections to extract display names, +// and stores them as "DisplayName" annotations in the model. Handles multiple display names by prompting user. +#if TE3 +ScriptHelper.WaitFormVisible = false; +#endif +// Step 1: Initialize report +ReportExtended report = Rx.InitReport(); +if (report == null) return; +// Step 2: Let user select visuals +IList selectedVisuals = Rx.SelectVisuals(report); +if (selectedVisuals == null || selectedVisuals.Count == 0) +{ + Info("No visuals selected."); + return; +} +// Step 3: Collect display names from selected visuals +var measureDisplayNamesDict = new Dictionary>(); +var columnDisplayNamesDict = new Dictionary>(); +foreach (var visual in selectedVisuals) +{ + var queryState = visual.Content?.Visual?.Query?.QueryState; + if (queryState == null) continue; + // Create list of all projection sets to iterate + var projectionSets = new List + { + queryState.Values, + queryState.Y, + queryState.Y2, + queryState.Category, + queryState.Series, + queryState.Data, + queryState.Rows + }; + // Iterate through each projection set + foreach (var projectionSet in projectionSets) + { + if (projectionSet?.Projections == null) continue; + foreach (var projection in projectionSet.Projections) + { + if (projection?.Field == null) continue; + string displayName = projection.DisplayName; + if (string.IsNullOrEmpty(displayName)) continue; + // Check if it's a measure + if (projection.Field.Measure != null) + { + var measureExpr = projection.Field.Measure; + if (measureExpr.Expression?.SourceRef?.Entity != null && measureExpr.Property != null) + { + string fullName = String.Format("'{0}'[{1}]", measureExpr.Expression.SourceRef.Entity, measureExpr.Property); + if (!measureDisplayNamesDict.ContainsKey(fullName)) + { + measureDisplayNamesDict[fullName] = new HashSet(); + } + // Only add if it's different from the default field name + if (displayName != measureExpr.Property) + { + measureDisplayNamesDict[fullName].Add(displayName); + } + else if (measureDisplayNamesDict[fullName].Count == 0) + { + // If no custom display name yet, use the property name + measureDisplayNamesDict[fullName].Add(measureExpr.Property); + } + } + } + // Check if it's a column + else if (projection.Field.Column != null) + { + var columnExpr = projection.Field.Column; + if (columnExpr.Expression?.SourceRef?.Entity != null && columnExpr.Property != null) + { + string fullName = String.Format("'{0}'[{1}]", columnExpr.Expression.SourceRef.Entity, columnExpr.Property); + if (!columnDisplayNamesDict.ContainsKey(fullName)) + { + columnDisplayNamesDict[fullName] = new HashSet(); + } + // Only add if it's different from the default field name + if (displayName != columnExpr.Property && displayName != fullName) + { + columnDisplayNamesDict[fullName].Add(displayName); + } + } + } + } + } +} +// Step 4: Resolve conflicts (multiple display names for same field) +var measureDisplayNames = new Dictionary(); +var columnDisplayNames = new Dictionary(); +foreach (var kvp in measureDisplayNamesDict) +{ + string fieldKey = kvp.Key; + var displayNames = kvp.Value.ToList(); + if (displayNames.Count == 0) continue; + if (displayNames.Count == 1) + { + measureDisplayNames[fieldKey] = displayNames[0]; + } + else + { + // Multiple display names found - ask user + string chosen = Fx.ChooseString( + OptionList: displayNames, + label: String.Format("Multiple display names found for measure '{0}'. Choose one:", fieldKey) + ); + if (string.IsNullOrEmpty(chosen)) + { + Info("Operation cancelled."); + return; + } + measureDisplayNames[fieldKey] = chosen; + } +} +foreach (var kvp in columnDisplayNamesDict) +{ + string fieldKey = kvp.Key; + var displayNames = kvp.Value.ToList(); + if (displayNames.Count == 0) continue; + if (displayNames.Count == 1) + { + columnDisplayNames[fieldKey] = displayNames[0]; + } + else + { + // Multiple display names found - ask user + string chosen = Fx.ChooseString( + OptionList: displayNames, + label: String.Format("Multiple display names found for column '{0}'. Choose one:", fieldKey) + ); + if (string.IsNullOrEmpty(chosen)) + { + Info("Operation cancelled."); + return; + } + columnDisplayNames[fieldKey] = chosen; + } +} +// Step 5: Apply annotations to model +int measuresUpdated = 0; +int columnsUpdated = 0; +foreach (var kvp in measureDisplayNames) +{ + var measure = Model.AllMeasures.FirstOrDefault( + m => m.Table.DaxObjectFullName + m.DaxObjectFullName == kvp.Key); + if (measure != null) + { + if (measure.GetAnnotation("DisplayName") == kvp.Value) continue; + measure.SetAnnotation("DisplayName", kvp.Value); + measuresUpdated++; + } +} +foreach (var kvp in columnDisplayNames) +{ + var column = Model.AllColumns.FirstOrDefault(c => c.DaxObjectFullName == kvp.Key); + if (column != null) + { + if(column.GetAnnotation("DisplayName") == kvp.Value) continue; + column.SetAnnotation("DisplayName", kvp.Value); + columnsUpdated++; + } +} +Output(String.Format("Updated {0} measures and {1} columns with DisplayName annotations.", measuresUpdated, columnsUpdated)); + +public static class Fx +{ + public static void CheckCompatibilityVersion(Model model, int requiredVersion, string customMessage = "Compatibility level must be raised to {0} to run this script. Do you want raise the compatibility level?") + { + if (model.Database.CompatibilityLevel < requiredVersion) + { + if (Fx.IsAnswerYes(String.Format("The model compatibility level is below {0}. " + customMessage, requiredVersion))) + { + model.Database.CompatibilityLevel = requiredVersion; + } + else + { + Info("Operation cancelled."); + return; + } + } + } + public static Function CreateFunction( + Model model, + string name, + string expression, + out bool functionCreated, + string description = null, + string annotationLabel = null, + string annotationValue = null, + string outputType = null, + string nameTemplate = null, + string formatString = null, + string displayFolder = null, + string outputDestination = null) + { + Function function = null as Function; + functionCreated = false; + var matchingFunctions = model.Functions.Where(f => f.GetAnnotation(annotationLabel) == annotationValue); + if (matchingFunctions.Count() == 1) + { + return matchingFunctions.First(); + } + else if (matchingFunctions.Count() == 0) + { + function = model.AddFunction(name); + function.Expression = expression; + function.Description = description; + functionCreated = true; + } + else + { + Error("More than one function found with annoation " + annotationLabel + " value " + annotationValue); + return null as Function; + } + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + function.SetAnnotation(annotationLabel, annotationValue); + } + if (!string.IsNullOrEmpty(outputType)) + { + function.SetAnnotation("outputType", outputType); + } + if (!string.IsNullOrEmpty(nameTemplate)) + { + function.SetAnnotation("nameTemplate", nameTemplate); + } + if (!string.IsNullOrEmpty(formatString)) + { + function.SetAnnotation("formatString", formatString); + } + if (!string.IsNullOrEmpty(displayFolder)) + { + function.SetAnnotation("displayFolder", displayFolder); + } + if (!string.IsNullOrEmpty(outputDestination)) + { + function.SetAnnotation("outputDestination", outputDestination); + } + return function; + } + public static Table CreateCalcTable(Model model, string tableName, string tableExpression = "FILTER({0},FALSE)") + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static Measure CreateMeasure( + Table table, + string measureName, + string measureExpression, + out bool measureCreated, + string formatString = null, + string displayFolder = null, + string description = null, + string annotationLabel = null, + string annotationValue = null, + bool isHidden = false) + { + measureCreated = false; + IEnumerable matchingMeasures = null as IEnumerable; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + matchingMeasures = table.Measures.Where(m => m.GetAnnotation(annotationLabel) == annotationValue); + } + else + { + matchingMeasures = table.Measures.Where(m => m.Name == measureName); + } + if (matchingMeasures.Count() == 1) + { + return matchingMeasures.First(); + } + else if (matchingMeasures.Count() == 0) + { + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = description; + measure.DisplayFolder = displayFolder; + measure.FormatString = formatString; + measureCreated = true; + if (!string.IsNullOrEmpty(annotationLabel) && !string.IsNullOrEmpty(annotationValue)) + { + measure.SetAnnotation(annotationLabel, annotationValue); + } + measure.IsHidden = isHidden; + return measure; + } + else + { + Error("More than one measure found with annoation " + annotationLabel + " value " + annotationValue); + Output(matchingMeasures); + return null as Measure; + } + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static bool IsAnswerYes(string question, string title = "Please confirm") + { + var result = MessageBox.Show(question, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question); + return result == DialogResult.Yes; + } + public static (IList Values, string Type) SelectAnyObjects(Model model, string selectionType = null, string prompt1 = "select item type", string prompt2 = "select item(s)", string placeholderValue = "") + { + var returnEmpty = (Values: new List(), Type: (string)null); + if (prompt1.Contains("{0}")) + prompt1 = string.Format(prompt1, placeholderValue ?? ""); + if(prompt2.Contains("{0}")) + prompt2 = string.Format(prompt2, placeholderValue ?? ""); + if (selectionType == null) + { + IList selectionTypeOptions = new List { "Table", "Column", "Measure", "Scalar" }; + selectionType = ChooseString(selectionTypeOptions, label: prompt1, customWidth: 600); + } + if (selectionType == null) return returnEmpty; + IList selectedValues = new List(); + switch (selectionType) + { + case "Table": + selectedValues = SelectTableMultiple(model, label: prompt2); + break; + case "Column": + selectedValues = SelectColumnMultiple(model, label: prompt2); + break; + case "Measure": + selectedValues = SelectMeasureMultiple(model: model, label: prompt2); + break; + case "Scalar": + IList scalarList = new List(); + scalarList.Add(GetNameFromUser(prompt2, "Scalar value", "0")); + selectedValues = scalarList; + break; + default: + Error("Invalid selection type"); + return returnEmpty; + } + if (selectedValues.Count == 0) return returnEmpty; + return (Values:selectedValues, Type:selectionType); + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 650, int customHeight = 550) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 70, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect , Height = 50, Width = 150}; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect, Height = 50, Width = 150 }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Height = 50, Width = 100 }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Height = 50, Width = 100 }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + form.Width = customWidth; + form.Height = customHeight; + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetDateTable(Model model, string prompt = "Select Date Table") + { + var dateTables = GetDateTables(model); + if (dateTables == null) { + Table t = SelectTable(model.Tables, label: prompt); + if(t == null) + { + Error("No table selected"); + return null; + } + if (IsAnswerYes(String.Format("Mark {0} as date table?",t.DaxObjectFullName))) + { + t.DataCategory = "Time"; + var dateColumns = t.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column",t.Name)); + return null; + } + var keyColumn = SelectColumn(dateColumns, preselect:dateColumns.First(), label: "Select Date Column to be used as key column"); + if(keyColumn == null) + { + Error("No key column selected"); + return null; + } + keyColumn.IsKey = true; + } + return t; + }; + if (dateTables.Count() == 1) + return dateTables.First(); + Table dateTable = SelectTable(dateTables, label: prompt); + if(dateTable == null) + { + Error("No table selected"); + return null; + } + return dateTable; + } + public static Column GetDateColumn(Table dateTable, string prompt = "Select Date Column") + { + var dateColumns = dateTable.Columns + .Where(c => c.DataType == DataType.DateTime) + .ToList(); + if(dateColumns.Count == 0) + { + Error(String.Format(@"No date column detected in the table {0}. Please check that the table contains a date column", dateTable.Name)); + return null; + } + if(dateColumns.Any(c => c.IsKey)) + { + return dateColumns.First(c => c.IsKey); + } + Column dateColumn = null; + if (dateColumns.Count() == 1) + { + dateColumn = dateColumns.First(); + } + else + { + dateColumn = SelectColumn(dateColumns, label: prompt); + if (dateColumn == null) + { + Error("No column selected"); + return null; + } + } + return dateColumn; + } + public static IEnumerable
GetFactTables(Model model) + { + IEnumerable
factTables = model.Tables.Where( + x => model.Relationships.Where(r => r.ToTable == x) + .All(r => r.ToCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.FromTable == x) + .All(r => r.FromCardinality == RelationshipEndCardinality.Many) + && model.Relationships.Where(r => r.ToTable == x || r.FromTable == x).Any()); // at least one relationship + if (!factTables.Any()) + { + Error("No fact table detected in the model. Please check that the model contains relationships"); + return null; + } + return factTables; + } + public static Table GetFactTable(Model model, string prompt = "Select Fact Table") + { + Table factTable = null; + var factTables = GetFactTables(model); + if (factTables == null) + { + factTable = SelectTable(model.Tables, label: "This does not look like a star schema. Choose your fact table manually"); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + }; + if (factTables.Count() == 1) + return factTables.First(); + factTable = SelectTable(factTables, label: prompt); + if (factTable == null) + { + Error("No table selected"); + return null; + } + return factTable; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } + public static IList SelectMeasureMultiple(Model model, IEnumerable measures = null, string label = "Select Measure(s)") + { + measures ??= model.AllMeasures; + IList measureNames = measures.Select(m => m.DaxObjectFullName).ToList(); + IList selectedMeasureNames = ChooseStringMultiple(measureNames, label: label); + return selectedMeasureNames; + } + public static IList SelectColumnMultiple(Model model, IEnumerable columns = null, string label = "Select Columns(s)") + { + columns ??= model.AllColumns; + IList columnNames = columns.Select(m => m.DaxObjectFullName).ToList(); + IList selectedColumnNames = ChooseStringMultiple(columnNames, label: label); + return selectedColumnNames; + } + public static IList SelectTableMultiple(Model model, IEnumerable
Tables = null, string label = "Select Tables(s)", int customWidth = 400) + { + Tables ??= model.Tables; + IList TableNames = Tables.Select(m => m.DaxObjectFullName).ToList(); + IList selectedTableNames = ChooseStringMultiple(TableNames, label: label, customWidth: customWidth); + return selectedTableNames; + } +} + +public static class Rx + +{ + + + + + + + + + + public static VisualExtended DuplicateVisual(VisualExtended visualExtended) + + { + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newVisualName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string sourceFolder = Path.GetDirectoryName(visualExtended.VisualFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newVisualName); + + if (Directory.Exists(targetFolder)) + + { + + Error(string.Format("Folder already exists: {0}", targetFolder)); + + return null; + + } + + Directory.CreateDirectory(targetFolder); + + + + // Deep clone the VisualDto.Root object + + string originalJson = JsonConvert.SerializeObject(visualExtended.Content, Newtonsoft.Json.Formatting.Indented); + + VisualDto.Root clonedContent = + + JsonConvert.DeserializeObject( + + originalJson, + + new JsonSerializerSettings { + + DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + }); + + + + // Update the name property if it exists + + if (clonedContent != null && clonedContent.Name != null) + + { + + clonedContent.Name = newVisualName; + + } + + + + // Set the new file path + + string newVisualFilePath = Path.Combine(targetFolder, "visual.json"); + + + + // Create the new VisualExtended object + + VisualExtended newVisual = new VisualExtended + + { + + Content = clonedContent, + + VisualFilePath = newVisualFilePath + + }; + + + + return newVisual; + + } + + + + public static VisualExtended GroupVisuals(List visualsToGroup, string groupName = null, string groupDisplayName = null) + + { + + if (visualsToGroup == null || visualsToGroup.Count == 0) + + { + + Error("No visuals to group."); + + return null; + + } + + // Generate a clean 16-character name from a GUID (no dashes or slashes) if no group name is provided + + if (string.IsNullOrEmpty(groupName)) + + { + + groupName = Guid.NewGuid().ToString("N").Substring(0, 16); + + } + + if (string.IsNullOrEmpty(groupDisplayName)) + + { + + groupDisplayName = groupName; + + } + + + + // Find minimum X and Y + + double minX = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.X : 0); + + double minY = visualsToGroup.Min(v => v.Content.Position != null ? (double)v.Content.Position.Y : 0); + + + + //Info("minX:" + minX.ToString() + ", minY: " + minY.ToString()); + + + + // Calculate width and height + + double groupWidth = 0; + + double groupHeight = 0; + + foreach (var v in visualsToGroup) + + { + + if (v.Content != null && v.Content.Position != null) + + { + + double visualWidth = v.Content.Position != null ? (double)v.Content.Position.Width : 0; + + double visualHeight = v.Content.Position != null ? (double)v.Content.Position.Height : 0; + + double xOffset = (double)v.Content.Position.X - (double)minX; + + double yOffset = (double)v.Content.Position.Y - (double)minY; + + double totalWidth = xOffset + visualWidth; + + double totalHeight = yOffset + visualHeight; + + if (totalWidth > groupWidth) groupWidth = totalWidth; + + if (totalHeight > groupHeight) groupHeight = totalHeight; + + } + + } + + + + // Create the group visual content + + var groupContent = new VisualDto.Root + + { + + Schema = visualsToGroup.FirstOrDefault().Content.Schema, + + Name = groupName, + + Position = new VisualDto.Position + + { + + X = minX, + + Y = minY, + + Width = groupWidth, + + Height = groupHeight + + }, + + VisualGroup = new VisualDto.VisualGroup + + { + + DisplayName = groupDisplayName, + + GroupMode = "ScaleMode" + + } + + }; + + + + // Set VisualFilePath for the group visual + + // Use the VisualFilePath of the first visual as a template + + string groupVisualFilePath = null; + + var firstVisual = visualsToGroup.FirstOrDefault(v => !string.IsNullOrEmpty(v.VisualFilePath)); + + if (firstVisual != null && !string.IsNullOrEmpty(firstVisual.VisualFilePath)) + + { + + string originalPath = firstVisual.VisualFilePath; + + string parentDir = Path.GetDirectoryName(Path.GetDirectoryName(originalPath)); // up to 'visuals' + + if (!string.IsNullOrEmpty(parentDir)) + + { + + string groupFolder = Path.Combine(parentDir, groupName); + + groupVisualFilePath = Path.Combine(groupFolder, "visual.json"); + + } + + } + + + + // Create the new VisualExtended for the group + + var groupVisual = new VisualExtended + + { + + Content = groupContent, + + VisualFilePath = groupVisualFilePath // Set as described + + }; + + + + // Update grouped visuals: set parentGroupName and adjust X/Y + + foreach (var v in visualsToGroup) + + { + + + + if (v.Content == null) continue; + + v.Content.ParentGroupName = groupName; + + + + if (v.Content.Position != null) + + { + + v.Content.Position.X = v.Content.Position.X - minX + 0; + + v.Content.Position.Y = v.Content.Position.Y - minY + 0; + + } + + } + + + + return groupVisual; + + } + + + + + + + + private static readonly string RecentPathsFile = Path.Combine( + + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + + "Tabular Editor Macro Settings", "recentPbirPaths.json"); + + + + public static string GetPbirFilePathWithHistory(string label = "Select definition.pbir file") + + { + + // Load recent paths + + List recentPaths = LoadRecentPbirPaths(); + + + + // Filter out non-existing files + + recentPaths = recentPaths.Where(File.Exists).ToList(); + + + + // Present options to the user + + var options = new List(recentPaths); + + options.Add("Browse for new file..."); + + + + string selected = Fx.ChooseString(options,label:label, customWidth:600, customHeight:300); + + + + if (selected == null) return null; + + + + string chosenPath = null; + + if (selected == "Browse for new file..." ) + + { + + chosenPath = GetPbirFilePath(label); + + } + + else + + { + + chosenPath = selected; + + } + + + + if (!string.IsNullOrEmpty(chosenPath)) + + { + + // Update recent paths + + UpdateRecentPbirPaths(chosenPath, recentPaths); + + } + + + + return chosenPath; + + } + + + + private static List LoadRecentPbirPaths() + + { + + try + + { + + if (File.Exists(RecentPathsFile)) + + { + + string json = File.ReadAllText(RecentPathsFile); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + + } + + } + + catch { } + + return new List(); + + } + + + + private static void UpdateRecentPbirPaths(string newPath, List recentPaths) + + { + + // Remove if already exists, insert at top + + recentPaths.RemoveAll(p => string.Equals(p, newPath, StringComparison.OrdinalIgnoreCase)); + + recentPaths.Insert(0, newPath); + + + + // Keep only the latest 10 + + while (recentPaths.Count > 10) + + recentPaths.RemoveAt(recentPaths.Count - 1); + + + + // Ensure directory exists + + Directory.CreateDirectory(Path.GetDirectoryName(RecentPathsFile)); + + File.WriteAllText(RecentPathsFile, JsonConvert.SerializeObject(recentPaths, Newtonsoft.Json.Formatting.Indented)); + + } + + + + + + public static ReportExtended InitReport(string label = "Please select definition.pbir file of the target report") + + { + + // Get the base path from the user + + string basePath = Rx.GetPbirFilePathWithHistory(label:label); + + if (basePath == null) return null; + + + + // Define the target path + + string baseDirectory = Path.GetDirectoryName(basePath); + + string targetPath = Path.Combine(baseDirectory, "definition", "pages"); + + + + // Check if the target path exists + + if (!Directory.Exists(targetPath)) + + { + + Error(String.Format("The path '{0}' does not exist.", targetPath)); + + return null; + + } + + + + // Get all subfolders in the target path + + List subfolders = Directory.GetDirectories(targetPath).ToList(); + + + + string pagesFilePath = Path.Combine(targetPath, "pages.json"); + + string pagesJsonContent = File.ReadAllText(pagesFilePath); + + + + if (string.IsNullOrEmpty(pagesJsonContent)) + + { + + Error(String.Format("The file '{0}' is empty or does not exist.", pagesFilePath)); + + return null; + + } + + + + PagesDto pagesDto = JsonConvert.DeserializeObject(pagesJsonContent); + + + + ReportExtended report = new ReportExtended(); + + report.PagesFilePath = pagesFilePath; + + report.PagesConfig = pagesDto; + + + + // Process each folder + + foreach (string folder in subfolders) + + { + + string pageJsonPath = Path.Combine(folder, "page.json"); + + if (File.Exists(pageJsonPath)) + + { + + try + + { + + string jsonContent = File.ReadAllText(pageJsonPath); + + PageDto page = JsonConvert.DeserializeObject(jsonContent); + + + + PageExtended pageExtended = new PageExtended(); + + pageExtended.Page = page; + + pageExtended.PageFilePath = pageJsonPath; + + + + pageExtended.ParentReport = report; + + + + string visualsPath = Path.Combine(folder, "visuals"); + + + + if (!Directory.Exists(visualsPath)) + + { + + report.Pages.Add(pageExtended); // still add the page + + continue; // skip visual loading + + } + + + + List visualSubfolders = Directory.GetDirectories(visualsPath).ToList(); + + + + foreach (string visualFolder in visualSubfolders) + + { + + string visualJsonPath = Path.Combine(visualFolder, "visual.json"); + + if (File.Exists(visualJsonPath)) + + { + + try + + { + + string visualJsonContent = File.ReadAllText(visualJsonPath); + + VisualDto.Root visual = JsonConvert.DeserializeObject(visualJsonContent); + + + + VisualExtended visualExtended = new VisualExtended(); + + visualExtended.Content = visual; + + visualExtended.VisualFilePath = visualJsonPath; + + visualExtended.ParentPage = pageExtended; // Set parent page reference + + pageExtended.Visuals.Add(visualExtended); + + } + + catch (Exception ex2) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", visualJsonPath, ex2.Message)); + + return null; + + } + + + + } + + } + + + + report.Pages.Add(pageExtended); + + + + } + + catch (Exception ex) + + { + + Output(String.Format("Error reading or deserializing '{0}': {1}", pageJsonPath, ex.Message)); + + } + + } + + + + } + + return report; + + } + + + + + + public static VisualExtended SelectTableVisual(ReportExtended report) + + { + + List visualTypes = new List + + { + + "tableEx","pivotTable" + + }; + + return SelectVisual(report: report, visualTypes); + + } + + + + + + + + public static VisualExtended SelectVisual(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: false, visualTypeList:visualTypeList) as VisualExtended; + + } + + + + public static List SelectVisuals(ReportExtended report, List visualTypeList = null) + + { + + return SelectVisualInternal(report, Multiselect: true, visualTypeList:visualTypeList) as List; + + } + + + + private static object SelectVisualInternal(ReportExtended report, bool Multiselect, List visualTypeList = null) + + { + + // Step 1: Build selection list + + var visualSelectionList = + + report.Pages + + .SelectMany(p => p.Visuals + + .Where(v => + + v?.Content != null && + + ( + + // If visualTypeList is null, do not filter at all + + (visualTypeList == null) || + + // If visualTypeList is provided and not empty, filter by it + + (visualTypeList.Count > 0 && v.Content.Visual != null && visualTypeList.Contains(v.Content?.Visual?.VisualType)) + + // Otherwise, include all visuals and visual groups + + || (visualTypeList.Count == 0) + + ) + + ) + + .Select(v => new + + { + + // Use visual type for regular visuals, displayname for groups + + Display = string.Format( + + "{0} - {1} ({2}, {3})", + + p.Page.DisplayName, + + v?.Content?.Visual?.VisualType + + ?? v?.Content?.VisualGroup?.DisplayName, + + (int)(v.Content.Position?.X ?? 0), + + (int)(v.Content.Position?.Y ?? 0) + + ), + + Page = p, + + Visual = v + + } + + ) + + ) + + .ToList(); + + + + if (visualSelectionList.Count == 0) + + { + + if (visualTypeList != null) + + { + + string types = string.Join(", ", visualTypeList); + + Error(string.Format("No visual of type {0} were found", types)); + + + + }else + + { + + Error("No visuals found in the report."); + + } + + + + + + return null; + + } + + + + // Step 2: Let user choose a visual + + var options = visualSelectionList.Select(v => v.Display).ToList(); + + + + if (Multiselect) + + { + + // For multiselect, use ChooseStringMultiple + + var multiSelelected = Fx.ChooseStringMultiple(options); + + if (multiSelelected == null || multiSelelected.Count == 0) + + { + + Info("You cancelled."); + + return null; + + } + + // Find all selected visuals + + var selectedVisuals = visualSelectionList.Where(v => multiSelelected.Contains(v.Display)).Select(v => v.Visual).ToList(); + + + + return selectedVisuals; + + } + + else + + { + + string selected = Fx.ChooseString(options); + + + + if (string.IsNullOrEmpty(selected)) + + { + + Info("You cancelled."); + + return null; + + } + + + + // Step 3: Find the selected visual + + var selectedVisual = visualSelectionList.FirstOrDefault(v => v.Display == selected); + + + + if (selectedVisual == null) + + { + + Error("Selected visual not found."); + + return null; + + } + + + + return selectedVisual.Visual; + + } + + } + + + + public static PageExtended ReplicateFirstPageAsBlank(ReportExtended report, bool showMessages = false) + + { + + if (report.Pages == null || !report.Pages.Any()) + + { + + Error("No pages found in the report."); + + return null; + + } + + + + PageExtended firstPage = report.Pages[0]; + + + + // Generate a clean 16-character name from a GUID (no dashes or slashes) + + string newPageName = Guid.NewGuid().ToString("N").Substring(0, 16); + + string newPageDisplayName = firstPage.Page.DisplayName + " - Copy"; + + + + string sourceFolder = Path.GetDirectoryName(firstPage.PageFilePath); + + string targetFolder = Path.Combine(Path.GetDirectoryName(sourceFolder), newPageName); + + string visualsFolder = Path.Combine(targetFolder, "visuals"); + + + + if (Directory.Exists(targetFolder)) + + { + + Error($"Folder already exists: {targetFolder}"); + + return null; + + } + + + + Directory.CreateDirectory(targetFolder); + + Directory.CreateDirectory(visualsFolder); + + + + var newPageDto = new PageDto + + { + + Name = newPageName, + + DisplayName = newPageDisplayName, + + DisplayOption = firstPage.Page.DisplayOption, + + Height = firstPage.Page.Height, + + Width = firstPage.Page.Width, + + Schema = firstPage.Page.Schema + + }; + + + + var newPage = new PageExtended + + { + + Page = newPageDto, + + PageFilePath = Path.Combine(targetFolder, "page.json"), + + Visuals = new List() // empty visuals + + }; + + + + File.WriteAllText(newPage.PageFilePath, JsonConvert.SerializeObject(newPageDto, Newtonsoft.Json.Formatting.Indented)); + + + + report.Pages.Add(newPage); + + + + if(showMessages) Info($"Created new blank page: {newPageName}"); + + + + return newPage; + + } + + + + + + public static void SaveVisual(VisualExtended visual) + + { + + + + // Save new JSON, ignoring nulls + + string newJson = JsonConvert.SerializeObject( + + visual.Content, + + Newtonsoft.Json.Formatting.Indented, + + new JsonSerializerSettings + + { + + //DefaultValueHandling = DefaultValueHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + + + } + + ); + + // Ensure the directory exists before saving + + string visualFolder = Path.GetDirectoryName(visual.VisualFilePath); + + if (!Directory.Exists(visualFolder)) + + { + + Directory.CreateDirectory(visualFolder); + + } + + File.WriteAllText(visual.VisualFilePath, newJson); + + } + + + + + + public static string ReplacePlaceholders(string pageContents, Dictionary placeholders) + + { + + if (placeholders != null) + + { + + foreach (string placeholder in placeholders.Keys) + + { + + string valueToReplace = placeholders[placeholder]; + + + + pageContents = pageContents.Replace(placeholder, valueToReplace); + + + + } + + } + + + + + + return pageContents; + + } + + + + + + public static String GetPbirFilePath(string label = "Please select definition.pbir file of the target report") + + { + + + + // Create an instance of the OpenFileDialog + + OpenFileDialog openFileDialog = new OpenFileDialog + + { + + Title = label, + + // Set filter options and filter index. + + Filter = "PBIR Files (*.pbir)|*.pbir", + + FilterIndex = 1 + + }; + + // Call the ShowDialog method to show the dialog box. + + DialogResult result = openFileDialog.ShowDialog(); + + // Process input if the user clicked OK. + + if (result != DialogResult.OK) + + { + + Error("You cancelled"); + + return null; + + } + + return openFileDialog.FileName; + + + + } + + + + + +} + + + + + + + + public class PagesDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("pageOrder")] + public List PageOrder { get; set; } + + [Newtonsoft.Json.JsonProperty("activePageName")] + public string ActivePageName { get; set; } + + } + + + public class PageDto + { + [Newtonsoft.Json.JsonProperty("$schema")] + public string Schema { get; set; } + + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("displayName")] + public string DisplayName { get; set; } + + [Newtonsoft.Json.JsonProperty("displayOption")] + public string DisplayOption { get; set; } // Could create enum if you want stricter typing + + [Newtonsoft.Json.JsonProperty("height")] + public double? Height { get; set; } + + [Newtonsoft.Json.JsonProperty("width")] + public double? Width { get; set; } + } + + + + public partial class VisualDto + { + public class Root + { + [JsonProperty("$schema")] public string Schema { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("position")] public Position Position { get; set; } + [JsonProperty("visual")] public Visual Visual { get; set; } + + + [JsonProperty("visualGroup")] public VisualGroup VisualGroup { get; set; } + [JsonProperty("parentGroupName")] public string ParentGroupName { get; set; } + [JsonProperty("filterConfig")] public FilterConfig FilterConfig { get; set; } + [JsonProperty("isHidden")] public bool IsHidden { get; set; } + + [JsonExtensionData] + + public Dictionary ExtensionData { get; set; } + } + + + public class VisualContainerObjects + { + [JsonProperty("general")] + public List General { get; set; } + + // Add other known properties as needed, e.g.: + [JsonProperty("title")] + public List Title { get; set; } + + [JsonProperty("subTitle")] + public List SubTitle { get; set; } + + // This will capture any additional properties not explicitly defined above + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerObject + { + [JsonProperty("properties")] + public Dictionary Properties { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualContainerProperty + { + [JsonProperty("expr")] + public VisualExpr Expr { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualExpr + { + [JsonProperty("Literal")] + public VisualLiteral Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualLiteral + { + [JsonProperty("Value")] + public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualGroup + { + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("groupMode")] public string GroupMode { get; set; } + } + + public class Position + { + [JsonProperty("x")] public double X { get; set; } + [JsonProperty("y")] public double Y { get; set; } + [JsonProperty("z")] public int Z { get; set; } + [JsonProperty("height")] public double Height { get; set; } + [JsonProperty("width")] public double Width { get; set; } + + [JsonProperty("tabOrder", NullValueHandling = NullValueHandling.Ignore)] + public int? TabOrder { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Visual + { + [JsonProperty("visualType")] public string VisualType { get; set; } + [JsonProperty("query")] public Query Query { get; set; } + [JsonProperty("objects")] public Objects Objects { get; set; } + [JsonProperty("visualContainerObjects")] + public VisualContainerObjects VisualContainerObjects { get; set; } + [JsonProperty("drillFilterOtherVisuals")] public bool DrillFilterOtherVisuals { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Query + { + [JsonProperty("queryState")] public QueryState QueryState { get; set; } + [JsonProperty("sortDefinition")] public SortDefinition SortDefinition { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class QueryState + { + [JsonProperty("Rows", Order = 1)] public VisualDto.ProjectionsSet Rows { get; set; } + [JsonProperty("Category", Order = 2)] public VisualDto.ProjectionsSet Category { get; set; } + [JsonProperty("Y", Order = 3)] public VisualDto.ProjectionsSet Y { get; set; } + [JsonProperty("Y2", Order = 4)] public VisualDto.ProjectionsSet Y2 { get; set; } + [JsonProperty("Values", Order = 5)] public VisualDto.ProjectionsSet Values { get; set; } + + [JsonProperty("Series", Order = 6)] public VisualDto.ProjectionsSet Series { get; set; } + [JsonProperty("Data", Order = 7)] public VisualDto.ProjectionsSet Data { get; set; } + + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ProjectionsSet + { + [JsonProperty("projections")] public List Projections { get; set; } + [JsonProperty("fieldParameters")] public List FieldParameters { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FieldParameter + { + [JsonProperty("parameterExpr")] + public Field ParameterExpr { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("length")] + public int Length { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Projection + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("queryRef")] public string QueryRef { get; set; } + [JsonProperty("nativeQueryRef")] public string NativeQueryRef { get; set; } + + [JsonProperty("displayName")] public string DisplayName { get; set; } + [JsonProperty("active")] public bool? Active { get; set; } + [JsonProperty("hidden")] public bool? Hidden { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Field + { + [JsonProperty("Aggregation")] public VisualDto.Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class Aggregation + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Function")] public int Function { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NativeVisualCalculation + { + [JsonProperty("Language")] public string Language { get; set; } + [JsonProperty("Expression")] public string Expression { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonProperty("DataType")] public string DataType { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class MeasureObject + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnField + { + [JsonProperty("Expression")] public VisualDto.Expression Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Expression + { + [JsonProperty("Column")] public ColumnExpression Column { get; set; } + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnExpression + { + [JsonProperty("Expression")] public VisualDto.SourceRef Expression { get; set; } + [JsonProperty("Property")] public string Property { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SourceRef + { + [JsonProperty("Schema")] public string Schema { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Source")] public string Source { get; set; } + + + } + + public class SortDefinition + { + [JsonProperty("sort")] public List Sort { get; set; } + [JsonProperty("isDefaultSort")] public bool IsDefaultSort { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Sort + { + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("direction")] public string Direction { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Objects + { + [JsonProperty("valueAxis")] public List ValueAxis { get; set; } + [JsonProperty("general")] public List General { get; set; } + [JsonProperty("data")] public List Data { get; set; } + [JsonProperty("title")] public List Title { get; set; } + [JsonProperty("legend")] public List Legend { get; set; } + [JsonProperty("labels")] public List Labels { get; set; } + [JsonProperty("dataPoint")] public List DataPoint { get; set; } + [JsonProperty("columnFormatting")] public List ColumnFormatting { get; set; } + [JsonProperty("referenceLabel")] public List ReferenceLabel { get; set; } + [JsonProperty("referenceLabelDetail")] public List ReferenceLabelDetail { get; set; } + [JsonProperty("referenceLabelValue")] public List ReferenceLabelValue { get; set; } + + [JsonProperty("values")] public List Values { get; set; } + + [JsonProperty("y1AxisReferenceLine")] public List Y1AxisReferenceLine { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class ObjectProperties + { + [JsonProperty("properties")] + [JsonConverter(typeof(PropertiesConverter))] + public Dictionary Properties { get; set; } + + [JsonProperty("selector")] + public Selector Selector { get; set; } + + + [JsonExtensionData] public IDictionary ExtensionData { get; set; } + } + + + + + public class VisualObjectProperty + { + [JsonProperty("expr")] public VisualPropertyExpr Expr { get; set; } + [JsonProperty("solid")] public SolidColor Solid { get; set; } + [JsonProperty("color")] public ColorExpression Color { get; set; } + + [JsonProperty("paragraphs")] + public List Paragraphs { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class VisualPropertyExpr + { + // Existing Field properties + [JsonProperty("Measure")] public MeasureObject Measure { get; set; } + [JsonProperty("Column")] public ColumnField Column { get; set; } + [JsonProperty("Aggregation")] public Aggregation Aggregation { get; set; } + [JsonProperty("NativeVisualCalculation")] public NativeVisualCalculation NativeVisualCalculation { get; set; } + + // New properties from JSON + [JsonProperty("SelectRef")] public SelectRefExpression SelectRef { get; set; } + [JsonProperty("Literal")] public VisualLiteral Literal { get; set; } + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + public class SelectRefExpression + { + [JsonProperty("ExpressionName")] + public string ExpressionName { get; set; } + } + + public class Paragraph + { + [JsonProperty("textRuns")] + public List TextRuns { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class TextRun + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("textStyle")] + public Dictionary TextStyle { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SolidColor + { + [JsonProperty("color")] public ColorExpression Color { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColorExpression + { + [JsonProperty("expr")] + public VisualColorExprWrapper Expr { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExprWrapper + { + [JsonProperty("FillRule")] public FillRuleExpression FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FillRuleExpression + { + [JsonProperty("Input")] public VisualDto.Field Input { get; set; } + [JsonProperty("FillRule")] public Dictionary FillRule { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ThemeDataColor + { + [JsonProperty("ColorId")] public int ColorId { get; set; } + [JsonProperty("Percent")] public double Percent { get; set; } + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + public class VisualColorExprWrapper + { + [JsonProperty("Measure")] + public VisualDto.MeasureObject Measure { get; set; } + + [JsonProperty("Column")] + public VisualDto.ColumnField Column { get; set; } + + [JsonProperty("Aggregation")] + public VisualDto.Aggregation Aggregation { get; set; } + + [JsonProperty("NativeVisualCalculation")] + public NativeVisualCalculation NativeVisualCalculation { get; set; } + + [JsonProperty("FillRule")] + public FillRuleExpression FillRule { get; set; } + + public VisualLiteral Literal { get; set; } + + [JsonProperty("ThemeDataColor")] + public ThemeDataColor ThemeDataColor { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + + + public class Selector + { + + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("order")] + public int? Order { get; set; } + + [JsonProperty("data")] + public List Data { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("scopeId")] + public string ScopeId { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataObject + { + [JsonProperty("dataViewWildcard")] + public DataViewWildcard DataViewWildcard { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class DataViewWildcard + { + [JsonProperty("matchingOption")] + public int MatchingOption { get; set; } + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterConfig + { + [JsonProperty("filters")] + public List Filters { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class VisualFilter + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("field")] public VisualDto.Field Field { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("filter")] public FilterDefinition Filter { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterDefinition + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Where")] public List Where { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterFrom + { + [JsonProperty("Name")] public string Name { get; set; } + [JsonProperty("Entity")] public string Entity { get; set; } + [JsonProperty("Type")] public int Type { get; set; } + [JsonProperty("Expression")] public FilterExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterExpression + { + [JsonProperty("Subquery")] public SubqueryExpression Subquery { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryExpression + { + [JsonProperty("Query")] public SubqueryQuery Query { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class SubqueryQuery + { + [JsonProperty("Version")] public int Version { get; set; } + [JsonProperty("From")] public List From { get; set; } + [JsonProperty("Select")] public List Select { get; set; } + [JsonProperty("OrderBy")] public List OrderBy { get; set; } + [JsonProperty("Top")] public int? Top { get; set; } + + [JsonProperty("Where")] public List Where { get; set; } // 🔹 Added + + [JsonExtensionData] public Dictionary ExtensionData { get; set; } + } + + + public class SelectExpression + { + [JsonProperty("Column")] public ColumnSelect Column { get; set; } + [JsonProperty("Name")] public string Name { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ColumnSelect + { + [JsonProperty("Expression")] + public VisualDto.Expression Expression { get; set; } // NOTE: wrapper that contains "SourceRef" + + [JsonProperty("Property")] + public string Property { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByExpression + { + [JsonProperty("Direction")] public int Direction { get; set; } + [JsonProperty("Expression")] public OrderByInnerExpression Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class OrderByInnerExpression + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterWhere + { + [JsonProperty("Condition")] public Condition Condition { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class Condition + { + [JsonProperty("In")] public InExpression In { get; set; } + [JsonProperty("Not")] public NotExpression Not { get; set; } + [JsonProperty("Comparison")] public ComparisonExpression Comparison { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InExpression + { + [JsonProperty("Expressions")] public List Expressions { get; set; } + [JsonProperty("Table")] public InTable Table { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class InTable + { + [JsonProperty("SourceRef")] public VisualDto.SourceRef SourceRef { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class NotExpression + { + [JsonProperty("Expression")] public Condition Expression { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class ComparisonExpression + { + [JsonProperty("ComparisonKind")] public int ComparisonKind { get; set; } + [JsonProperty("Left")] public FilterOperand Left { get; set; } + [JsonProperty("Right")] public FilterOperand Right { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class FilterOperand + { + [JsonProperty("Measure")] public VisualDto.MeasureObject Measure { get; set; } + [JsonProperty("Column")] public VisualDto.ColumnField Column { get; set; } + [JsonProperty("Literal")] public LiteralOperand Literal { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + public class LiteralOperand + { + [JsonProperty("Value")] public string Value { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + + public class PropertiesConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Dictionary); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new Dictionary(); + var jObj = JObject.Load(reader); + + foreach (var prop in jObj.Properties()) + { + if (prop.Name == "paragraphs") + { + var paragraphs = prop.Value.ToObject>(serializer); + result[prop.Name] = paragraphs; + } + else + { + var visualProp = prop.Value.ToObject(serializer); + result[prop.Name] = visualProp; + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var dict = (Dictionary)value; + writer.WriteStartObject(); + + foreach (var kvp in dict) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value is VisualObjectProperty vo) + serializer.Serialize(writer, vo); + else if (kvp.Value is List ps) + serializer.Serialize(writer, ps); + else + serializer.Serialize(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + } + } + + + public class VisualExtended + { + public VisualDto.Root Content { get; set; } + + public string VisualFilePath { get; set; } + + public bool isVisualGroup => Content?.VisualGroup != null; + public bool isGroupedVisual => Content?.ParentGroupName != null; + + public bool IsBilingualVisualGroup() + { + if (!isVisualGroup || string.IsNullOrEmpty(Content.VisualGroup.DisplayName)) + return false; + return System.Text.RegularExpressions.Regex.IsMatch(Content.VisualGroup.DisplayName, @"^P\d{2}-\d{3}$"); + } + + public PageExtended ParentPage { get; set; } + + public bool IsInBilingualVisualGroup() + { + if (ParentPage == null || ParentPage.Visuals == null || Content.ParentGroupName == null) + return false; + return ParentPage.Visuals.Any(v => v.IsBilingualVisualGroup() && v.Content.Name == Content.ParentGroupName); + } + + [JsonIgnore] + public string AltText + { + get + { + var general = Content?.Visual?.VisualContainerObjects?.General; + if (general == null || general.Count == 0) + return null; + if (!general[0].Properties.ContainsKey("altText")) + return null; + return general[0].Properties["altText"]?.Expr?.Literal?.Value?.Trim('\''); + } + set + { + if (Content?.Visual == null) + Content.Visual = new VisualDto.Visual(); + + if (Content?.Visual?.VisualContainerObjects == null) + Content.Visual.VisualContainerObjects = new VisualDto.VisualContainerObjects(); + + if (Content.Visual?.VisualContainerObjects.General == null || Content.Visual?.VisualContainerObjects.General.Count == 0) + Content.Visual.VisualContainerObjects.General = + new List { + new VisualDto.VisualContainerObject { + Properties = new Dictionary() + } + }; + + var general = Content.Visual.VisualContainerObjects.General[0]; + + if (general.Properties == null) + general.Properties = new Dictionary(); + + general.Properties["altText"] = new VisualDto.VisualContainerProperty + { + Expr = new VisualDto.VisualExpr + { + Literal = new VisualDto.VisualLiteral + { + Value = value == null ? null : "'" + value.Replace("'", "\\'") + "'" + } + } + }; + } + } + + private IEnumerable GetAllFields() + { + var fields = new List(); + var queryState = Content?.Visual?.Query?.QueryState; + + if (queryState != null) + { + fields.AddRange(GetFieldsFromProjections(queryState.Values)); + fields.AddRange(GetFieldsFromProjections(queryState.Y)); + fields.AddRange(GetFieldsFromProjections(queryState.Y2)); + fields.AddRange(GetFieldsFromProjections(queryState.Category)); + fields.AddRange(GetFieldsFromProjections(queryState.Series)); + fields.AddRange(GetFieldsFromProjections(queryState.Data)); + fields.AddRange(GetFieldsFromProjections(queryState.Rows)); + } + + var sortList = Content?.Visual?.Query?.SortDefinition?.Sort; + if (sortList != null) + fields.AddRange(sortList.Select(s => s.Field)); + + var objects = Content?.Visual?.Objects; + if (objects != null) + { + fields.AddRange(GetFieldsFromObjectList(objects.DataPoint)); + fields.AddRange(GetFieldsFromObjectList(objects.Data)); + fields.AddRange(GetFieldsFromObjectList(objects.Labels)); + fields.AddRange(GetFieldsFromObjectList(objects.Title)); + fields.AddRange(GetFieldsFromObjectList(objects.Legend)); + fields.AddRange(GetFieldsFromObjectList(objects.General)); + fields.AddRange(GetFieldsFromObjectList(objects.ValueAxis)); + fields.AddRange(GetFieldsFromObjectList(objects.Y1AxisReferenceLine)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabel)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelDetail)); + fields.AddRange(GetFieldsFromObjectList(objects.ReferenceLabelValue)); + } + + fields.AddRange(GetFieldsFromFilterConfig(Content?.FilterConfig as VisualDto.FilterConfig)); + + return fields.Where(f => f != null); + } + + public IEnumerable GetFieldsFromProjections(VisualDto.ProjectionsSet set) + { + return set?.Projections?.Select(p => p.Field) ?? Enumerable.Empty(); + } + + + + private IEnumerable GetFieldsFromObjectList(List objectList) + { + if (objectList == null) yield break; + + foreach (var obj in objectList) + { + if (obj.Properties == null) continue; + + foreach (var val in obj.Properties.Values) + { + var prop = val as VisualDto.VisualObjectProperty; + if (prop == null) continue; + + if (prop.Expr != null) + { + if (prop.Expr.Measure != null) + yield return new VisualDto.Field { Measure = prop.Expr.Measure }; + + if (prop.Expr.Column != null) + yield return new VisualDto.Field { Column = prop.Expr.Column }; + } + + if (prop.Color?.Expr?.FillRule?.Input != null) + yield return prop.Color.Expr.FillRule.Input; + + if (prop.Solid?.Color?.Expr?.FillRule?.Input != null) + yield return prop.Solid.Color.Expr.FillRule.Input; + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr?.Measure != null) + yield return new VisualDto.Field { Measure = solidExpr.Measure }; + if (solidExpr?.Column != null) + yield return new VisualDto.Field { Column = solidExpr.Column }; + } + } + } + + private IEnumerable GetFieldsFromFilterConfig(VisualDto.FilterConfig filterConfig) + { + var fields = new List(); + + if (filterConfig?.Filters == null) + return fields; + + foreach (var filter in filterConfig.Filters ?? Enumerable.Empty()) + { + if (filter.Field != null) + fields.Add(filter.Field); + + if (filter.Filter != null) + { + var aliasMap = BuildAliasMap(filter.Filter.From); + + foreach (var from in filter.Filter.From ?? Enumerable.Empty()) + { + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + } + + foreach (var where in filter.Filter.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + } + + return fields; + } + + private void ExtractFieldsFromSubquery(VisualDto.SubqueryQuery query, List fields) + { + var aliasMap = BuildAliasMap(query.From); + + // SELECT columns + foreach (var sel in query.Select ?? Enumerable.Empty()) + { + var srcRef = sel.Column?.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + var columnExpr = sel.Column ?? new VisualDto.ColumnSelect(); + columnExpr.Expression ??= new VisualDto.Expression(); + columnExpr.Expression.SourceRef ??= new VisualDto.SourceRef(); + columnExpr.Expression.SourceRef.Source = srcRef.Source; + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = sel.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = columnExpr.Expression.SourceRef + } + } + }); + } + + // ORDER BY measures + foreach (var ob in query.OrderBy ?? Enumerable.Empty()) + { + var measureExpr = ob.Expression?.Measure?.Expression ?? new VisualDto.Expression(); + measureExpr.SourceRef ??= new VisualDto.SourceRef(); + measureExpr.SourceRef.Source = ResolveSource(measureExpr.SourceRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = ob.Expression.Measure.Property, + Expression = measureExpr + } + }); + } + + // Nested subqueries + foreach (var from in query.From ?? Enumerable.Empty()) + if (from.Expression?.Subquery?.Query != null) + ExtractFieldsFromSubquery(from.Expression.Subquery.Query, fields); + + // WHERE conditions + foreach (var where in query.Where ?? Enumerable.Empty()) + ExtractFieldsFromCondition(where.Condition, fields, aliasMap); + } + private Dictionary BuildAliasMap(List fromList) + { + var map = new Dictionary(); + foreach (var from in fromList ?? Enumerable.Empty()) + { + if (!string.IsNullOrEmpty(from.Name) && !string.IsNullOrEmpty(from.Entity)) + map[from.Name] = from.Entity; + } + return map; + } + + private string ResolveSource(string source, Dictionary aliasMap) + { + if (string.IsNullOrEmpty(source)) + return source; + return aliasMap.TryGetValue(source, out var entity) ? entity : source; + } + + private void ExtractFieldsFromCondition(VisualDto.Condition condition, List fields, Dictionary aliasMap) + { + if (condition == null) return; + + // IN Expression + if (condition.In != null) + { + foreach (var expr in condition.In.Expressions ?? Enumerable.Empty()) + { + var srcRef = expr.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = expr.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + + // NOT Expression + if (condition.Not != null) + ExtractFieldsFromCondition(condition.Not.Expression, fields, aliasMap); + + // COMPARISON Expression + if (condition.Comparison != null) + { + AddOperandField(condition.Comparison.Left, fields, aliasMap); + AddOperandField(condition.Comparison.Right, fields, aliasMap); + } + } + private void AddOperandField(VisualDto.FilterOperand operand, List fields, Dictionary aliasMap) + { + if (operand == null) return; + + // MEASURE + if (operand.Measure != null) + { + var srcRef = operand.Measure.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = operand.Measure.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + + // COLUMN + if (operand.Column != null) + { + var srcRef = operand.Column.Expression?.SourceRef ?? new VisualDto.SourceRef(); + srcRef.Source = ResolveSource(srcRef.Source, aliasMap); + + fields.Add(new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = operand.Column.Property, + Expression = new VisualDto.Expression + { + SourceRef = srcRef + } + } + }); + } + } + public IEnumerable GetAllReferencedMeasures() + { + return GetAllFields() + .Select(f => f.Measure) + .Where(m => m?.Expression?.SourceRef?.Entity != null && m.Property != null) + .Select(m => $"'{m.Expression.SourceRef.Entity}'[{m.Property}]") + .Distinct(); + } + + public IEnumerable GetAllReferencedColumns() + { + return GetAllFields() + .Select(f => f.Column) + .Where(c => c?.Expression?.SourceRef?.Entity != null && c.Property != null) + .Select(c => $"'{c.Expression.SourceRef.Entity}'[{c.Property}]") + .Distinct(); + } + + public void ReplaceMeasure(string oldFieldKey, Measure newMeasure, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Measure = new VisualDto.MeasureObject + { + Property = newMeasure.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newMeasure.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: true, modifiedSet); + } + + public void ReplaceColumn(string oldFieldKey, Column newColumn, HashSet modifiedSet = null) + { + var newField = new VisualDto.Field + { + Column = new VisualDto.ColumnField + { + Property = newColumn.Name, + Expression = new VisualDto.Expression + { + SourceRef = new VisualDto.SourceRef { Entity = newColumn.Table.Name } + } + } + }; + ReplaceField(oldFieldKey, newField, isMeasure: false, modifiedSet); + } + + private string ToFieldKey(VisualDto.Field f) + { + if (f?.Measure?.Expression?.SourceRef?.Entity is string mEntity && f.Measure.Property is string mProp) + return $"'{mEntity}'[{mProp}]"; + + if (f?.Column?.Expression?.SourceRef?.Entity is string cEntity && f.Column.Property is string cProp) + return $"'{cEntity}'[{cProp}]"; + + return null; + } + + private void ReplaceField(string oldFieldKey, VisualDto.Field newField, bool isMeasure, HashSet modifiedSet = null) + { + var query = Content?.Visual?.Query; + var objects = Content?.Visual?.Objects; + bool wasModified = false; + + void Replace(VisualDto.Field f) + { + if (f == null) return; + + if (isMeasure && newField.Measure != null) + { + // Preserve Expression with SourceRef + f.Measure ??= new VisualDto.MeasureObject(); + f.Measure.Property = newField.Measure.Property; + f.Measure.Expression ??= new VisualDto.Expression(); + f.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Measure.Expression.SourceRef.Entity, + Source = newField.Measure.Expression.SourceRef.Source + } + : f.Measure.Expression.SourceRef; + f.Column = null; + wasModified = true; + } + else if (!isMeasure && newField.Column != null) + { + // Preserve Expression with SourceRef + f.Column ??= new VisualDto.ColumnField(); + f.Column.Property = newField.Column.Property; + f.Column.Expression ??= new VisualDto.Expression(); + f.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef != null + ? new VisualDto.SourceRef + { + Entity = newField.Column.Expression.SourceRef.Entity, + Source = newField.Column.Expression.SourceRef.Source + } + : f.Column.Expression.SourceRef; + f.Measure = null; + wasModified = true; + } + } + + void UpdateProjection(VisualDto.Projection proj) + { + if (proj == null) return; + + if (ToFieldKey(proj.Field) == oldFieldKey) + { + Replace(proj.Field); + + string entity = isMeasure + ? proj.Field.Measure.Expression?.SourceRef?.Entity + : proj.Field.Column.Expression?.SourceRef?.Entity; + + string prop = isMeasure + ? proj.Field.Measure.Property + : proj.Field.Column.Property; + + if (!string.IsNullOrEmpty(entity) && !string.IsNullOrEmpty(prop)) + { + proj.QueryRef = $"{entity}.{prop}"; + } + + wasModified = true; + } + } + + foreach (var proj in query?.QueryState?.Values?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Y2?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Category?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Series?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Data?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var proj in query?.QueryState?.Rows?.Projections ?? Enumerable.Empty()) + UpdateProjection(proj); + + foreach (var sort in query?.SortDefinition?.Sort ?? Enumerable.Empty()) + if (ToFieldKey(sort.Field) == oldFieldKey) Replace(sort.Field); + + string oldMetadata = oldFieldKey.Replace("'", "").Replace("[", ".").Replace("]", ""); + string newMetadata = isMeasure + ? $"{newField.Measure.Expression.SourceRef.Entity}.{newField.Measure.Property}" + : $"{newField.Column.Expression.SourceRef.Entity}.{newField.Column.Property}"; + + IEnumerable AllObjectProperties() => + (objects?.DataPoint ?? Enumerable.Empty()) + .Concat(objects?.Data ?? Enumerable.Empty()) + .Concat(objects?.Labels ?? Enumerable.Empty()) + .Concat(objects?.Title ?? Enumerable.Empty()) + .Concat(objects?.Legend ?? Enumerable.Empty()) + .Concat(objects?.General ?? Enumerable.Empty()) + .Concat(objects?.ValueAxis ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabel ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelDetail ?? Enumerable.Empty()) + .Concat(objects?.ReferenceLabelValue ?? Enumerable.Empty()) + .Concat(objects?.Values ?? Enumerable.Empty()) + .Concat(objects?.Y1AxisReferenceLine ?? Enumerable.Empty()); + + foreach (var obj in AllObjectProperties()) + { + foreach (var prop in obj.Properties.Values.OfType()) + { + var field = isMeasure ? new VisualDto.Field { Measure = prop.Expr?.Measure } : new VisualDto.Field { Column = prop.Expr?.Column }; + if (ToFieldKey(field) == oldFieldKey) + { + if (prop.Expr != null) + { + if (isMeasure) + { + prop.Expr.Measure ??= new VisualDto.MeasureObject(); + prop.Expr.Measure.Property = newField.Measure.Property; + prop.Expr.Measure.Expression ??= new VisualDto.Expression(); + prop.Expr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + prop.Expr.Column = null; + wasModified = true; + } + else + { + prop.Expr.Column ??= new VisualDto.ColumnField(); + prop.Expr.Column.Property = newField.Column.Property; + prop.Expr.Column.Expression ??= new VisualDto.Expression(); + prop.Expr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + prop.Expr.Measure = null; + wasModified = true; + } + } + } + + var fillInput = prop.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(fillInput) == oldFieldKey) + { + if (isMeasure) + { + fillInput.Measure ??= new VisualDto.MeasureObject(); + fillInput.Measure.Property = newField.Measure.Property; + fillInput.Measure.Expression ??= new VisualDto.Expression(); + fillInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + fillInput.Column = null; + wasModified = true; + } + else + { + fillInput.Column ??= new VisualDto.ColumnField(); + fillInput.Column.Property = newField.Column.Property; + fillInput.Column.Expression ??= new VisualDto.Expression(); + fillInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + fillInput.Measure = null; + wasModified = true; + } + } + + var solidInput = prop.Solid?.Color?.Expr?.FillRule?.Input; + if (ToFieldKey(solidInput) == oldFieldKey) + { + if (isMeasure) + { + solidInput.Measure ??= new VisualDto.MeasureObject(); + solidInput.Measure.Property = newField.Measure.Property; + solidInput.Measure.Expression ??= new VisualDto.Expression(); + solidInput.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidInput.Column = null; + wasModified = true; + } + else + { + solidInput.Column ??= new VisualDto.ColumnField(); + solidInput.Column.Property = newField.Column.Property; + solidInput.Column.Expression ??= new VisualDto.Expression(); + solidInput.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidInput.Measure = null; + wasModified = true; + } + } + + var solidExpr = prop.Solid?.Color?.Expr; + if (solidExpr != null) + { + var solidField = isMeasure + ? new VisualDto.Field { Measure = solidExpr.Measure } + : new VisualDto.Field { Column = solidExpr.Column }; + + if (ToFieldKey(solidField) == oldFieldKey) + { + if (isMeasure) + { + solidExpr.Measure ??= new VisualDto.MeasureObject(); + solidExpr.Measure.Property = newField.Measure.Property; + solidExpr.Measure.Expression ??= new VisualDto.Expression(); + solidExpr.Measure.Expression.SourceRef = newField.Measure.Expression?.SourceRef; + solidExpr.Column = null; + wasModified = true; + } + else + { + solidExpr.Column ??= new VisualDto.ColumnField(); + solidExpr.Column.Property = newField.Column.Property; + solidExpr.Column.Expression ??= new VisualDto.Expression(); + solidExpr.Column.Expression.SourceRef = newField.Column.Expression?.SourceRef; + solidExpr.Measure = null; + wasModified = true; + } + } + } + } + + if (obj.Selector?.Metadata == oldMetadata) + { + obj.Selector.Metadata = newMetadata; + wasModified = true; + } + } + + if (wasModified && modifiedSet != null) + modifiedSet.Add(this); + } + + } + + + public class PageExtended + { + public PageDto Page { get; set; } + + public ReportExtended ParentReport { get; set; } + + public int PageIndex + { + get + { + if (ParentReport == null || ParentReport.PagesConfig == null || ParentReport.PagesConfig.PageOrder == null) + return -1; + return ParentReport.PagesConfig.PageOrder.IndexOf(Page.Name); + } + } + + + public IList Visuals { get; set; } = new List(); + public string PageFilePath { get; set; } + } + + + public class ReportExtended + { + public IList Pages { get; set; } = new List(); + public string PagesFilePath { get; set; } + public PagesDto PagesConfig { get; set; } + } diff --git a/Basic/Autogenerate DISTINCTCOUNT Measures.csx b/Basic/Autogenerate DISTINCTCOUNT Measures.csx new file mode 100644 index 0000000..d905792 --- /dev/null +++ b/Basic/Autogenerate DISTINCTCOUNT Measures.csx @@ -0,0 +1,27 @@ +/* + * Title: Auto-generate DISTINCTCOUNT measures from columns + * + * B.Agullo adapting code from Daniel Otykier + * + * This script, when executed, will loop through the currently selected columns, + * creating one DISTINCTCOUNT measure for each column + */ + +// Loop through all currently selected columns: +foreach(var c in Selected.Columns) +{ + var newMeasure = c.Table.AddMeasure( + "Distinct Count of " + c.Name, // Name + "DISTINCTCOUNT(" + c.DaxObjectFullName + ")", // DAX expression + c.DisplayFolder // Display Folder + ); + + // Set the format string on the new measure: + newMeasure.FormatString = "0"; + + // Provide some documentation: + newMeasure.Description = "This measure is the distinct count of column " + c.DaxObjectFullName; + + // Hide the base column: + //c.IsHidden = true; +} diff --git a/Basic/Autogenerate MAX Measures.csx b/Basic/Autogenerate MAX Measures.csx new file mode 100644 index 0000000..86e38f1 --- /dev/null +++ b/Basic/Autogenerate MAX Measures.csx @@ -0,0 +1,27 @@ +/* + * Title: Auto-generate MAX measures from columns + * + * Author: B.Agullo (Adapted from Daniel Otykier, twitter.com/DOtykier) + * + * This script, when executed, will loop through the currently selected columns, + * creating one MAX measure for each column and also hiding the column itself. + */ + +// Loop through all currently selected columns: +foreach(Column c in Selected.Columns) +{ + var newMeasure = c.Table.AddMeasure( + "Max " + c.Name, // Name + "MAX(" + c.DaxObjectFullName + ")", // DAX expression + c.DisplayFolder // Display Folder + ); + + // Set the format string on the new measure: + newMeasure.FormatString = c.FormatString; + + // Provide some documentation: + newMeasure.Description = "This measure is the MAX of column " + c.DaxObjectFullName; + + // Hide the base column: + //c.IsHidden = true; +} diff --git a/Basic/Autogenerate MIN Measures.csx b/Basic/Autogenerate MIN Measures.csx new file mode 100644 index 0000000..5fe7556 --- /dev/null +++ b/Basic/Autogenerate MIN Measures.csx @@ -0,0 +1,27 @@ +/* + * Title: Auto-generate MIN measures from columns + * + * Author: B.Agullo (Adapted from Daniel Otykier, twitter.com/DOtykier) + * + * This script, when executed, will loop through the currently selected columns, + * creating one MIN measure for each column and also hiding the column itself. + */ + +// Loop through all currently selected columns: +foreach(Column c in Selected.Columns) +{ + var newMeasure = c.Table.AddMeasure( + "MIN " + c.Name, // Name + "MIN(" + c.DaxObjectFullName + ")", // DAX expression + c.DisplayFolder // Display Folder + ); + + // Set the format string on the new measure: + newMeasure.FormatString = c.FormatString; + + // Provide some documentation: + newMeasure.Description = "This measure is the MIN of column " + c.DaxObjectFullName; + + // Hide the base column: + //c.IsHidden = true; +} diff --git a/Basic/Autogenerate SELECTEDVALUE Measures.csx b/Basic/Autogenerate SELECTEDVALUE Measures.csx new file mode 100644 index 0000000..c72ce81 --- /dev/null +++ b/Basic/Autogenerate SELECTEDVALUE Measures.csx @@ -0,0 +1,27 @@ +/* + * Title: Auto-generate SELECTEDVALUE measures from columns + * + * Author: B.Agullo (Adapted from Daniel Otykier, twitter.com/DOtykier) + * + * This script, when executed, will loop through the currently selected columns, + * creating one SELECTEDVALUE measure for each column and also hiding the column itself. + */ + +// Loop through all currently selected columns: +foreach(var c in Selected.Columns) +{ + var newMeasure = c.Table.AddMeasure( + "Selected " + c.Name, // Name + "SELECTEDVALUE(" + c.DaxObjectFullName + ")", // DAX expression + c.DisplayFolder // Display Folder + ); + + // Set the format string on the new measure: + newMeasure.FormatString = "0.00"; + + // Provide some documentation: + newMeasure.Description = "This measure is the SELECTEDVALUE of column " + c.DaxObjectFullName; + + // Hide the base column: + //c.IsHidden = true; +} diff --git a/Basic/Configure Table as Field Parameter.csx b/Basic/Configure Table as Field Parameter.csx new file mode 100644 index 0000000..5a3a553 --- /dev/null +++ b/Basic/Configure Table as Field Parameter.csx @@ -0,0 +1,25 @@ +//'2024-08-09 / B.Agullo / +// Configure a field parameter table +if(Selected.Tables.Count() != 1) +{ + Error("Select a single table and try again"); + return; +} +Table fieldParameterTable = Selected.Table; +if (fieldParameterTable.Columns.Count() < 3) +{ + Error("This script expects at least 3 columns in the table"); + return; +} +Column displayNameColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[0], "Select display name column"); +if(displayNameColumn == null) { Error("You cancelled");return; }; +Column fieldColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[1], "Select field table"); +if(fieldColumn == null) { Error("You cancelled"); return; }; +Column orderColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[2], "Select order column"); +if(orderColumn == null) { Error("You cancelled"); return; }; +fieldColumn.SetExtendedProperty(name: "ParameterMetadata", value: @"{""version"":3,""kind"":2}", type: ExtendedPropertyType.Json); +displayNameColumn.GroupByColumns.Add(fieldColumn); +displayNameColumn.SortByColumn = orderColumn; +fieldColumn.SortByColumn = orderColumn; +fieldColumn.IsHidden = true; +orderColumn.IsHidden = true; diff --git a/Basic/Create Has Data Measure.csx b/Basic/Create Has Data Measure.csx new file mode 100644 index 0000000..7b5ac3b --- /dev/null +++ b/Basic/Create Has Data Measure.csx @@ -0,0 +1,16 @@ +// '2023-06-08 / B.Agullo / Creates a measure to show only relevant items in slicers for a fact table. + + +if(Selected.Tables.Count() == 0) +{ + Error("Select at least one table and try again"); + return; +} +foreach(Table table in Selected.Tables) +{ + string measureExpression = String.Format(@"INT(NOT ISEMPTY({0}))",table.DaxObjectFullName); + string measureName = table.Name + " has data"; + string measureDescription = String.Format(@"Returns 1 if {0} has visible rows in filter context, 0 otherwise. Can be used to show only relevant slicer items.", table.DaxObjectFullName); + Measure measure = table.AddMeasure(measureName, measureExpression); + measure.Description = measureDescription; +} diff --git a/Basic/Create Text Versions of Numeric Measures with prefix and sufix.csx b/Basic/Create Text Versions of Numeric Measures with prefix and sufix.csx new file mode 100644 index 0000000..7e519ac --- /dev/null +++ b/Basic/Create Text Versions of Numeric Measures with prefix and sufix.csx @@ -0,0 +1,164 @@ +#r "Microsoft.VisualBasic" +using System.Windows.Forms; + +using Microsoft.VisualBasic; + +//2025-07-28/B.Agullo +//This script creates text measures based on the selected measures in the model. +//It prompts the user for a prefix and suffix to be added to the text measures. +//It also allows the user to specify a suffix for the names of the new text measures. +if (Selected.Measures.Count() == 0) +{ + Error("No measures selected. Please select at least one measure."); + return; +} +// Ask user for prefix +string prefix = Fx.GetNameFromUser( + Prompt: "Enter a prefix for the new text measures (use ### for current measure name):", + Title: "Text Measure Prefix", + DefaultResponse: "" +); +if (prefix == null) return; +// Ask user for suffix +string suffix = Fx.GetNameFromUser( + Prompt: "Enter a suffix for the new text measures (use ### for current measure name):", + Title: "Text Measure Suffix", + DefaultResponse: "" +); +if (suffix == null) return; +// Ask user for measure name suffix +string measureNameSuffix = Fx.GetNameFromUser( + Prompt: "Enter a suffix for the Name of the new text measures:", + Title: "Suffix for names!", + DefaultResponse: " Text" +); +if (measureNameSuffix == null) return; +foreach (Measure m in Selected.Measures) +{ + string newMeasureName = m.Name + measureNameSuffix; + string newMeasureDisplayFolder = (m.DisplayFolder + measureNameSuffix).Trim(); + string newMeasureExpression = + String.Format( + @"""{2}"" & FORMAT([{0}], ""{1}"") & ""{3}""", + m.Name, + m.FormatString, + prefix.Replace("###", m.Name), + suffix.Replace("###",m.Name)); + Measure newMeasure = m.Table.AddMeasure(newMeasureName, newMeasureExpression,newMeasureDisplayFolder); + newMeasure.FormatDax(); +} + +public static class Fx +{ + public static Table CreateCalcTable(Model model, string tableName, string tableExpression) + { + return model.Tables.FirstOrDefault(t => + string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)) //case insensitive search + ?? model.AddCalculatedTable(tableName, tableExpression); + } + public static string GetNameFromUser(string Prompt, string Title, string DefaultResponse) + { + string response = Interaction.InputBox(Prompt, Title, DefaultResponse, 740, 400); + return response; + } + public static string ChooseString(IList OptionList, string label = "Choose item", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect: false, label: label, customWidth: customWidth, customHeight:customHeight) as string; + } + public static List ChooseStringMultiple(IList OptionList, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + return ChooseStringInternal(OptionList, MultiSelect:true, label:label, customWidth: customWidth, customHeight: customHeight) as List; + } + private static object ChooseStringInternal(IList OptionList, bool MultiSelect, string label = "Choose item(s)", int customWidth = 400, int customHeight = 500) + { + Form form = new Form + { + Text =label, + Width = customWidth, + Height = customHeight, + StartPosition = FormStartPosition.CenterScreen, + Padding = new Padding(20) + }; + ListBox listbox = new ListBox + { + Dock = DockStyle.Fill, + SelectionMode = MultiSelect ? SelectionMode.MultiExtended : SelectionMode.One + }; + listbox.Items.AddRange(OptionList.ToArray()); + if (!MultiSelect && OptionList.Count > 0) + listbox.SelectedItem = OptionList[0]; + FlowLayoutPanel buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(10) + }; + Button selectAllButton = new Button { Text = "Select All", Visible = MultiSelect }; + Button selectNoneButton = new Button { Text = "Select None", Visible = MultiSelect }; + Button okButton = new Button { Text = "OK", DialogResult = DialogResult.OK }; + Button cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel }; + selectAllButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, true); + }; + selectNoneButton.Click += delegate + { + for (int i = 0; i < listbox.Items.Count; i++) + listbox.SetSelected(i, false); + }; + buttonPanel.Controls.Add(selectAllButton); + buttonPanel.Controls.Add(selectNoneButton); + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + form.Controls.Add(listbox); + form.Controls.Add(buttonPanel); + DialogResult result = form.ShowDialog(); + if (result == DialogResult.Cancel) + { + Info("You Cancelled!"); + return null; + } + if (MultiSelect) + { + List selectedItems = new List(); + foreach (object item in listbox.SelectedItems) + selectedItems.Add(item.ToString()); + return selectedItems; + } + else + { + return listbox.SelectedItem != null ? listbox.SelectedItem.ToString() : null; + } + } + public static IEnumerable
GetDateTables(Model model) + { + var dateTables = model.Tables + .Where(t => t.DataCategory == "Time" && + t.Columns.Any(c => c.IsKey && c.DataType == DataType.DateTime)) + .ToList(); + if (!dateTables.Any()) + { + Error("No date table detected in the model. Please mark your date table(s) as date table"); + return null; + } + return dateTables; + } + public static Table GetTablesWithAnnotation(IEnumerable
tables, string annotationLabel, string annotationValue) + { + Func lambda = t => t.GetAnnotation(annotationLabel) == annotationValue; + IEnumerable
matchTables = GetFilteredTables(tables, lambda); + return GetFilteredTables(tables, lambda).FirstOrDefault(); + } + public static IEnumerable
GetFilteredTables(IEnumerable
tables, Func lambda) + { + var filteredTables = tables.Where(t => lambda(t)); + return filteredTables.Any() ? filteredTables : null; + } + public static IEnumerable GetFilteredColumns(IEnumerable columns, Func lambda, bool returnAllIfNoneFound = true) + { + var filteredColumns = columns.Where(c => lambda(c)); + return filteredColumns.Any() || returnAllIfNoneFound ? filteredColumns : null; + } +} diff --git a/Intermediate/Configure Table as Field Parameter.cs b/Intermediate/Configure Table as Field Parameter.cs new file mode 100644 index 0000000..0035528 --- /dev/null +++ b/Intermediate/Configure Table as Field Parameter.cs @@ -0,0 +1,26 @@ +//'2024-08-09 / B.Agullo / +// Configure a field parameter table + +if(Selected.Tables.Count() != 1) +{ + Error("Select a single table and try again"); + return; +} +Table fieldParameterTable = Selected.Table; +if (fieldParameterTable.Columns.Count() < 3) +{ + Error("This script expects at least 3 columns in the table"); + return; +} +Column displayNameColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[0], "Select display name column"); +if(displayNameColumn == null) { Error("You cancelled");return; }; +Column fieldColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[1], "Select field table"); +if(fieldColumn == null) { Error("You cancelled"); return; }; +Column orderColumn = SelectColumn(fieldParameterTable, fieldParameterTable.Columns[2], "Select order column"); +if(orderColumn == null) { Error("You cancelled"); return; }; +fieldColumn.SetExtendedProperty(name: "ParameterMetadata", value: @"{""version"":3,""kind"":2}", type: ExtendedPropertyType.Json); +displayNameColumn.GroupByColumns.Add(fieldColumn); +displayNameColumn.SortByColumn = orderColumn; +fieldColumn.SortByColumn = orderColumn; +fieldColumn.IsHidden = true; +orderColumn.IsHidden = true;