Files
tooll3--t3/Editor/Gui/Graph/Dialogs/TourDataMarkdownExport.cs
2026-07-13 13:13:17 +08:00

492 lines
16 KiB
C#

#nullable enable
using System.Text;
using System.Text.RegularExpressions;
using ImGuiNET;
using T3.Core.Utils;
using T3.Editor.UiModel;
using T3.Editor.UiModel.ProjectHandling;
using T3.Editor.UiModel.Selection;
namespace T3.Editor.Gui.Graph.Dialogs;
/// <summary>
/// Write or read tours as Markdown to improve the editing flow.
/// </summary>
/// <remarks>
/// While creating levels and topics of the SkillQuest we noticed that it would be useful to
/// write or export multiple tours into a single Markdown document. With h1 (#) for Levels and
/// h2 (##) for tour points. We could then use external software or LLMs to do things like spell
/// checking and just "paste back" the updated data.
///
/// Things got a little out of hand because the idea of using shorted ids to keep references
/// turned out to be much more complex than expected.
///
/// To test this function, open AppMenu → TiXL → Development → Tour Point Editor.
/// </remarks>
internal static partial class TourDataMarkdownExport
{
#region writing markdown
internal static void WriteSymbolTourMarkdown(SymbolUi symbolUi, StringBuilder? sb = null)
{
sb ??= new StringBuilder();
sb.Append("# ");
sb.Append(string.IsNullOrEmpty(symbolUi.Description)
? symbolUi.Symbol.Name
: symbolUi.Description);
sb.Append(" &");
sb.AppendLine(symbolUi.Symbol.Id.ShortenGuid());
sb.AppendLine();
foreach (var tp in symbolUi.TourPoints)
{
tp.ToMarkdown(sb, symbolUi);
}
}
public static void ToMarkdown(this TourPoint tourPoint, StringBuilder sb, SymbolUi symbolUi)
{
// Type with reference to child and input
sb.Append($"## {tourPoint.Style}");
if (tourPoint.ChildId != Guid.Empty
&& TryGetChildNameIndex(symbolUi, tourPoint.ChildId, out var childName) )
{
TryGetInputName(symbolUi, tourPoint, out var inputName);
sb.Append("(");
sb.Append(childName);
if (!string.IsNullOrEmpty(inputName))
{
sb.Append('.');
sb.Append(inputName);
}
sb.Append(") ");
}
// Write id
sb.Append(" &");
sb.Append(tourPoint.Id.ShortenGuid());
sb.AppendLine();
sb.AppendLine(tourPoint.Description.Replace("\n", "\n\n"));
sb.AppendLine();
}
private static bool TryGetChildNameIndex(SymbolUi symbolUi, Guid id, out string name)
{
name = "FAIL?";
if (!symbolUi.ChildUis.TryGetValue(id, out var child))
{
return false;
}
// find identical children and order them by height / pos x
SectionTree.UpdateCollapsedVisibility(symbolUi);
var childSymbolId = child.SymbolChild.Symbol.Id;
var index = symbolUi.ChildUis.Values
.Where(c => c.SymbolChild.Symbol.Id == childSymbolId && !c.IsHiddenInCollapsedSection)
.OrderBy(c => c.PosOnCanvas.Y + c.PosOnCanvas.X * 0.1f)
.Select((item, idx) => new { Item = item, Index = idx })
.FirstOrDefault(pair => pair.Item.Id == child.Id)?.Index ?? -1;
if (index == -1)
{
return false;
}
if (index == 0)
{
name = child.SymbolChild.Symbol.Name;
}
else
{
name = $"{child.SymbolChild.Symbol.Name}:{index + 1}";
}
return true;
}
private static bool TryGetInputName(SymbolUi symbolUi, TourPoint tourPoint, out string inputName)
{
inputName = string.Empty;
if (tourPoint.InputId == Guid.Empty || tourPoint.ChildId == Guid.Empty)
return false;
if (!symbolUi.Symbol.Children.TryGetValue(tourPoint.ChildId, out var child))
return false;
var input = child.Symbol.InputDefinitions.FirstOrDefault(i => i.Id == tourPoint.InputId);
if (input == null)
return false;
inputName = input.Name;
return true;
}
#endregion
#region pasting from mark down
/// <summary>
/// Parses mark down description for tour data matching symbols and tour points.
/// </summary>
/// <remarks>
/// Copy sample markdown from existing symbols with tours.
///
/// There are two methods how this could be applied:
/// - If some ops are selected, their SymbolUis will be the target
/// - Otherwise the current compositionUi will be the target, if...
/// 1. _one_ of the Tours has a matching SymbolId
/// 2. There is a single Tour _without_ SymbolId
///
/// Sadly, dealing with shorted Ids is annoying.
/// </remarks>
internal static bool TryPasteTourData(SymbolUi compositionUi, ProjectView projectView)
{
var markdown = ImGui.GetClipboardText();
var tours = GetToursFromMarkdown(markdown);
if (tours.Count == 0)
{
Log.Warning("No tour points found in clipboard");
return false;
}
if (!projectView.NodeSelection.IsAnythingSelected())
{
if (tours.Count > 1)
{
var shortCompositionId = compositionUi.Symbol.Id.ShortenGuid();
foreach (var tour in tours)
{
if (tour.IdString == shortCompositionId)
{
ApplyTourDataToSymbolUi(tour, compositionUi);
return true;
}
}
Log.Warning("Can't find tour matching current composition");
return false;
}
var newTour = tours[0];
if (!string.IsNullOrEmpty(newTour.IdString) && compositionUi.Symbol.Id.ShortenGuid() != newTour.IdString)
{
Log.Warning("TourId doesn't match current composition.");
return false;
}
ApplyTourDataToSymbolUi(newTour, compositionUi);
}
else
{
var selectedChildUis = projectView.NodeSelection.GetSelectedChildUis().ToArray();
if (tours.Count == 1 && selectedChildUis.Length == 1)
{
var tour = tours[0];
var targetChildUi = selectedChildUis[0];
if (!string.IsNullOrEmpty(tour.IdString) && tour.IdString != targetChildUi.SymbolChild.Symbol.Id.ShortenGuid())
{
Log.Warning("TourId doesn't match current composition.");
return false;
}
if (!targetChildUi.SymbolChild.Symbol.TryGetSymbolUi(out var targetSymbolUi))
{
Log.Warning($"Can't find target for {tour.IdString} to paste data.");
return false;
}
ApplyTourDataToSymbolUi(tour, targetSymbolUi);
}
else
{
foreach (var tour in tours)
{
var idStringMissing = string.IsNullOrEmpty(tour.IdString);
if (idStringMissing)
{
Log.Warning($"Can't paste data of {tour.Title} to selection with without tourIds.");
continue;
}
var targetChildUi = selectedChildUis
.Where(c => !c.IsHiddenInCollapsedSection)
.OrderByDescending(c => c.PosOnCanvas.Y + c.PosOnCanvas.X * 0.1f)
.FirstOrDefault(c => string.Equals(tour.IdString, c.SymbolChild.Symbol.Id.ShortenGuid(),
StringComparison.InvariantCulture));
if (targetChildUi == null || !targetChildUi.SymbolChild.Symbol.TryGetSymbolUi(out var targetSymbolUi))
{
Log.Warning($"Can't find target for {tour.IdString} to paste data.");
continue;
}
ApplyTourDataToSymbolUi(tour, targetSymbolUi);
}
}
}
return false;
}
private static void ApplyTourDataToSymbolUi(TourWithId tour, SymbolUi targetSymbolUi)
{
RealizeTourIds(tour, targetSymbolUi);
if (!string.IsNullOrEmpty(tour.Title))
targetSymbolUi.Description = tour.Title;
targetSymbolUi.TourPoints.Clear();
targetSymbolUi.TourPoints.AddRange(tour.TourPoints);
targetSymbolUi.FlagAsModified();
Log.Debug($"Pasted {tour.TourPoints.Count} tour points to {targetSymbolUi}");
}
private static void RealizeTourIds(TourWithId tour, SymbolUi targetSymbolUi)
{
foreach (var tourPoint in tour.TourPoints)
{
RealizeIdsTourPointIds(tourPoint, targetSymbolUi);
}
}
private static List<TourWithId> GetToursFromMarkdown(string markdown)
{
_tours.Clear();
var span = markdown.AsSpan();
_lineIndex = 0;
var i = 0;
while (i < span.Length)
{
var lineStart = i;
// find end of line
while (i < span.Length && span[i] != '\n')
i++;
var line = span.Slice(lineStart, i - lineStart);
_lineIndex++;
// handle CRLF
if (line.Length > 0 && line[^1] == '\r')
line = line[..^1];
if (line.StartsWith("# ".AsSpan()))
{
TryStartTour(line[2..]);
}
else if (line.StartsWith("## ".AsSpan()))
{
TryStartTourPoint(line[3..]);
}
else if (line.IsEmpty)
{
_lineBreakPending = true;
}
else
{
if (_activeTourPoint != null)
{
if (_activeTourPointDescriptionSb.Length > 0)
{
_activeTourPointDescriptionSb.Append(!_lineBreakPending ? ' ' : '\n');
}
_lineBreakPending = false;
_activeTourPointDescriptionSb.Append(line);
}
}
i++; // skip '\n'
}
CompleteActiveTourPoint();
return _tours;
}
private static void CompleteActiveTourPoint()
{
if (_activeTourPoint == null)
return;
_lineBreakPending = false;
_activeTourPoint.Description = _activeTourPointDescriptionSb.ToString();
_activeTourPoint = null;
_activeTourPointDescriptionSb.Clear();
}
private static void TryStartTour(ReadOnlySpan<char> line)
{
CompleteActiveTourPoint();
_activeTourPointDescriptionSb.Clear();
TryGetIdString(line, out var idString, out var title);
_activeTour = new TourWithId(idString.ToString(), title.Trim().ToString(), []);
_tours.Add(_activeTour);
}
private static void TryStartTourPoint(ReadOnlySpan<char> line)
{
CompleteActiveTourPoint();
_activeTourPointDescriptionSb.Clear();
if (_activeTour == null)
{
_activeTourPoint = null;
return;
}
TryGetIdString(line, out var idString, out var styleAndTargetSpan);
var end = 0;
while (end < styleAndTargetSpan.Length && styleAndTargetSpan[end].IsAlphaNumericOrUnderscore())
end++;
var title = styleAndTargetSpan[..end];
if (!Enum.TryParse<TourPoint.Styles>(title, out var style))
{
Log.Warning($"Can't parse tour point style {style}");
}
_activeTourPoint = new TourPoint
{
Style = style,
ShortId = idString.ToString(),
StyleAndTargetString = styleAndTargetSpan.ToString(),
};
_activeTour.TourPoints.Add(_activeTourPoint);
}
static bool IsAlphaNumericOrUnderscore(this char c) =>
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '_';
/// <summary>
/// Try to find the referenced items in the target symbolUi.
/// </summary>
private static void RealizeIdsTourPointIds(TourPoint tourPoint, SymbolUi symbolUi)
{
// Copy original ids if possible
var foundId = false;
foreach (var orgTourPoint in symbolUi.TourPoints)
{
if (tourPoint.ShortId != orgTourPoint.Id.ShortenGuid())
continue;
tourPoint.Id = orgTourPoint.Id;
foundId = true;
break;
}
if(!foundId)
tourPoint.Id = Guid.NewGuid();
// e.g. Info(Symbol:1)
var symbolName = string.Empty;
var inputName = string.Empty;
var index = 1;
var m = MatchSymbolNameAndIndex().Match(tourPoint.StyleAndTargetString);
if (m.Success)
{
int titleGroup = 1;
int indexGroup = 2;
int inputNameGroup = 3;
symbolName = m.Groups[titleGroup].Value;
if (m.Groups[indexGroup].Success && int.TryParse(m.Groups[indexGroup].Value, out var i))
index = i;
if (m.Groups[inputNameGroup].Success)
inputName = m.Groups[inputNameGroup].Value;
}
if (!string.IsNullOrEmpty(symbolName))
{
SectionTree.UpdateCollapsedVisibility(symbolUi);
var matchIndex = 0;
foreach (SymbolUi.Child childUi in symbolUi.ChildUis.Values)
{
if (childUi.IsHiddenInCollapsedSection)
continue;
var childSymbolName = childUi.SymbolChild.Symbol.Name;
if (!string.Equals(childSymbolName, symbolName, StringComparison.InvariantCulture))
continue;
matchIndex++;
if (matchIndex < index)
continue;
tourPoint.ChildId = childUi.Id;
if (!string.IsNullOrEmpty(inputName))
{
foreach (var input in childUi.SymbolChild.Symbol.InputDefinitions)
{
if (input.Name != inputName)
continue;
tourPoint.InputId = input.Id;
break;
}
}
break;
}
}
}
// Find symbol id " &abc1234"
private static void TryGetIdString(ReadOnlySpan<char> line, out ReadOnlySpan<char> id, out ReadOnlySpan<char> title)
{
id = ReadOnlySpan<char>.Empty;
title = line;
var idStartIndex = line.IndexOf(" &");
if (idStartIndex == -1)
return;
var maxLength = 7;
if (line.Length < idStartIndex + maxLength + 2)
return;
if (line[idStartIndex + 2] == ' ')
return;
var length = 0;
while (length < maxLength && line[idStartIndex + 2 + length] != ' ')
length++;
id = line.Slice(idStartIndex + 2, length);
title = line[..idStartIndex].Trim();
}
private sealed record TourWithId(string IdString, string Title, List<TourPoint> TourPoints);
private static readonly List<TourWithId> _tours = [];
private static TourWithId? _activeTour;
private static readonly StringBuilder _activeTourPointDescriptionSb = new();
private static bool _lineBreakPending;
private static TourPoint? _activeTourPoint;
private static int _lineIndex;
//[GeneratedRegex(@"\((?<title>[^):]+)(?::(?<index>\d+))?\)", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
[GeneratedRegex(@"\(([^):.]+)(?::(\d+))?(?:\.([^)]+))?\)", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
private static partial Regex MatchSymbolNameAndIndex();
#endregion
}