diff --git a/Content.Client/Guidebook/Controls/GuidebookError.xaml b/Content.Client/Guidebook/Controls/GuidebookError.xaml new file mode 100644 index 000000000000..b84d527ea0a2 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuidebookError.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Guidebook/Controls/GuidebookError.xaml.cs b/Content.Client/Guidebook/Controls/GuidebookError.xaml.cs new file mode 100644 index 000000000000..461f196c8389 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuidebookError.xaml.cs @@ -0,0 +1,23 @@ +using JetBrains.Annotations; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Guidebook.Controls; + +[UsedImplicitly] [GenerateTypedNameReferences] +public sealed partial class GuidebookError : BoxContainer +{ + public GuidebookError() + { + RobustXamlLoader.Load(this); + } + + public GuidebookError(string original, string? error) : this() + { + Original.AddText(original); + + if (error is not null) + Error.AddText(error); + } +} diff --git a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs index c904a9c78986..469b0ed22245 100644 --- a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs +++ b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs @@ -4,12 +4,10 @@ using Content.Client.UserInterface.Controls; using Content.Client.UserInterface.Controls.FancyTree; using Content.Client.UserInterface.Systems.Info; -using Content.Shared.CCVar; using Content.Shared.Guidebook; using Robust.Client.AutoGenerated; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; -using Robust.Shared.Configuration; using Robust.Shared.ContentPack; using Robust.Shared.Prototypes; @@ -18,15 +16,18 @@ namespace Content.Client.Guidebook.Controls; [GenerateTypedNameReferences] public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler { - [Dependency] private readonly IResourceManager _resourceManager = default!; [Dependency] private readonly DocumentParsingManager _parsingMan = default!; + [Dependency] private readonly IResourceManager _resourceManager = default!; private Dictionary, GuideEntry> _entries = new(); + private readonly ISawmill _sawmill; + public GuidebookWindow() { RobustXamlLoader.Load(this); IoCManager.InjectDependencies(this); + _sawmill = Logger.GetSawmill("Guidebook"); Tree.OnSelectedItemChanged += OnSelectionChanged; @@ -36,6 +37,20 @@ public GuidebookWindow() }; } + public void HandleClick(string link) + { + if (!_entries.TryGetValue(link, out var entry)) + return; + + if (Tree.TryGetIndexFromMetadata(entry, out var index)) + { + Tree.ExpandParentEntries(index.Value); + Tree.SetSelectedIndex(index); + } + else + ShowGuide(entry); + } + private void OnSelectionChanged(TreeItem? item) { if (item != null && item.Metadata is GuideEntry entry) @@ -71,8 +86,9 @@ private void ShowGuide(GuideEntry entry) if (!_parsingMan.TryAddMarkup(EntryContainer, file.ReadToEnd())) { - EntryContainer.AddChild(new Label() { Text = "ERROR: Failed to parse document." }); - Logger.Error($"Failed to parse contents of guide document {entry.Id}."); + // The guidebook will automatically display the in-guidebook error if it fails + + _sawmill.Error($"Failed to parse contents of guide document {entry.Id}."); } } @@ -124,8 +140,10 @@ private IEnumerable GetSortedEntries(List GetSortedEntries(List Loc.GetString(rootEntry.Name)); } - private void RepopulateTree(List>? roots = null, ProtoId? forcedRoot = null) + private void RepopulateTree(List>? roots = null, + ProtoId? forcedRoot = null) { Tree.Clear(); HashSet> addedEntries = new(); - TreeItem? parent = forcedRoot == null ? null : AddEntry(forcedRoot.Value, null, addedEntries); + var parent = forcedRoot == null ? null : AddEntry(forcedRoot.Value, null, addedEntries); foreach (var entry in GetSortedEntries(roots)) { AddEntry(entry.Id, parent, addedEntries); } + Tree.SetAllExpanded(true); } - private TreeItem? AddEntry(ProtoId id, TreeItem? parent, HashSet> addedEntries) + private TreeItem? AddEntry(ProtoId id, + TreeItem? parent, + HashSet> addedEntries) { if (!_entries.TryGetValue(id, out var entry)) return null; @@ -179,22 +201,6 @@ private void RepopulateTree(List>? roots = null, Pr return item; } - public void HandleClick(string link) - { - if (!_entries.TryGetValue(link, out var entry)) - return; - - if (Tree.TryGetIndexFromMetadata(entry, out var index)) - { - Tree.ExpandParentEntries(index.Value); - Tree.SetSelectedIndex(index); - } - else - { - ShowGuide(entry); - } - } - private void HandleFilter() { var emptySearch = SearchBar.Text.Trim().Length == 0; @@ -208,6 +214,5 @@ private void HandleFilter() element.SetHiddenState(true, SearchBar.Text.Trim()); } } - } } diff --git a/Content.Client/Guidebook/DocumentParsingManager.cs b/Content.Client/Guidebook/DocumentParsingManager.cs index e8a0743b9e05..857ae552024f 100644 --- a/Content.Client/Guidebook/DocumentParsingManager.cs +++ b/Content.Client/Guidebook/DocumentParsingManager.cs @@ -1,4 +1,5 @@ using System.Linq; +using Content.Client.Guidebook.Controls; using Content.Client.Guidebook.Richtext; using Content.Shared.Guidebook; using Pidgin; @@ -7,6 +8,7 @@ using Robust.Shared.Prototypes; using Robust.Shared.Reflection; using Robust.Shared.Sandboxing; +using Robust.Shared.Utility; using static Pidgin.Parser; namespace Content.Client.Guidebook; @@ -22,8 +24,10 @@ public sealed partial class DocumentParsingManager [Dependency] private readonly ISandboxHelper _sandboxHelper = default!; private readonly Dictionary> _tagControlParsers = new(); - private Parser _tagParser = default!; private Parser _controlParser = default!; + + private ISawmill _sawmill = default!; + private Parser _tagParser = default!; public Parser> ControlParser = default!; public void Initialize() @@ -32,7 +36,8 @@ public void Initialize() .Assert(_tagControlParsers.ContainsKey, tag => $"unknown tag: {tag}") .Bind(tag => _tagControlParsers[tag]); - _controlParser = OneOf(_tagParser, TryHeaderControl, ListControlParser, TextControlParser).Before(SkipWhitespaces); + _controlParser = OneOf(_tagParser, TryHeaderControl, ListControlParser, TextControlParser) + .Before(SkipWhitespaces); foreach (var typ in _reflectionManager.GetAllChildren()) { @@ -40,6 +45,8 @@ public void Initialize() } ControlParser = SkipWhitespaces.Then(_controlParser.Many()); + + _sawmill = Logger.GetSawmill("Guidebook"); } public bool TryAddMarkup(Control control, ProtoId entryId, bool log = true) @@ -68,37 +75,57 @@ public bool TryAddMarkup(Control control, string text, bool log = true) } catch (Exception e) { - if (log) - Logger.Error($"Encountered error while generating markup controls: {e}"); + _sawmill.Error($"Encountered error while generating markup controls: {e}"); + + control.AddChild(new GuidebookError(text, e.ToStringBetter())); + return false; } return true; } - private Parser CreateTagControlParser(string tagId, Type tagType, ISandboxHelper sandbox) => Map( - (args, controls) => - { - var tag = (IDocumentTag) sandbox.CreateInstance(tagType); - if (!tag.TryParseTag(args, out var control)) - { - Logger.Error($"Failed to parse {tagId} args"); - return new Control(); - } + private Parser CreateTagControlParser(string tagId, Type tagType, ISandboxHelper sandbox) + { + return Map( + (args, controls) => + { + try + { + var tag = (IDocumentTag) sandbox.CreateInstance(tagType); + if (!tag.TryParseTag(args, out var control)) + { + _sawmill.Error($"Failed to parse {tagId} args"); + return new GuidebookError(args.ToString() ?? tagId, $"Failed to parse {tagId} args"); + } - foreach (var child in controls) - { - control.AddChild(child); - } - return control; - }, - ParseTagArgs(tagId), - TagContentParser(tagId)).Labelled($"{tagId} control"); + foreach (var child in controls) + { + control.AddChild(child); + } + + return control; + } + catch (Exception e) + { + var output = args.Aggregate(string.Empty, + (current, pair) => current + $"{pair.Key}=\"{pair.Value}\" "); + + _sawmill.Error($"Tag: {tagId} \n Arguments: {output}/>"); + return new GuidebookError($"Tag: {tagId}\nArguments: {output}", e.ToString()); + } + }, + ParseTagArgs(tagId), + TagContentParser(tagId)) + .Labelled($"{tagId} control"); + } // Parse a bunch of controls until we encounter a matching closing tag. - private Parser> TagContentParser(string tag) => - OneOf( - Try(ImmediateTagEnd).ThenReturn(Enumerable.Empty()), - TagEnd.Then(_controlParser.Until(TryTagTerminator(tag)).Labelled($"{tag} children")) - ); + private Parser> TagContentParser(string tag) + { + return OneOf( + Try(ImmediateTagEnd).ThenReturn(Enumerable.Empty()), + TagEnd.Then(_controlParser.Until(TryTagTerminator(tag)).Labelled($"{tag} children")) + ); + } } diff --git a/Content.Client/Guidebook/DocumentParsingManager.static.cs b/Content.Client/Guidebook/DocumentParsingManager.static.cs index ab38fcb1546d..5d25d8f64525 100644 --- a/Content.Client/Guidebook/DocumentParsingManager.static.cs +++ b/Content.Client/Guidebook/DocumentParsingManager.static.cs @@ -1,4 +1,5 @@ using System.Linq; +using Content.Client.Guidebook.Controls; using Pidgin; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; @@ -14,92 +15,142 @@ public sealed partial class DocumentParsingManager { private const string ListBullet = " › "; - #region Text Parsing - #region Basic Text Parsing - // Try look for an escaped character. If found, skip the escaping slash and return the character. - private static readonly Parser TryEscapedChar = Try(Char('\\').Then(OneOf( - Try(Char('<')), - Try(Char('>')), - Try(Char('\\')), - Try(Char('-')), - Try(Char('=')), - Try(Char('"')), - Try(Char(' ')), - Try(Char('n')).ThenReturn('\n'), - Try(Char('t')).ThenReturn('\t') - ))); + // Parser that consumes a - and then just parses normal rich text with some prefix text (a bullet point). + private static readonly Parser TryEscapedChar = Try(Char('\\') + .Then(OneOf( + Try(Char('<')), + Try(Char('>')), + Try(Char('\\')), + Try(Char('-')), + Try(Char('=')), + Try(Char('"')), + Try(Char(' ')), + Try(Char('n')).ThenReturn('\n'), + Try(Char('t')).ThenReturn('\t') + ))); private static readonly Parser SkipNewline = Whitespace.SkipUntil(Char('\n')); - private static readonly Parser TrySingleNewlineToSpace = Try(SkipNewline).Then(SkipWhitespaces).ThenReturn(' '); + private static readonly Parser TrySingleNewlineToSpace = + Try(SkipNewline).Then(SkipWhitespaces).ThenReturn(' '); private static readonly Parser TextChar = OneOf( TryEscapedChar, // consume any backslashed being used to escape text TrySingleNewlineToSpace, // turn single newlines into spaces Any // just return the character. - ); + ); - // like TextChar, but not skipping whitespace around newlines private static readonly Parser QuotedTextChar = OneOf(TryEscapedChar, Any); + private static readonly Parser QuotedText = + Char('"').Then(QuotedTextChar.Until(Try(Char('"'))).Select(string.Concat)).Labelled("quoted text"); + + private static readonly Parser TryStartList = + Try(SkipNewline.Then(SkipWhitespaces).Then(Char('-'))).Then(SkipWhitespaces); + + private static readonly Parser TryStartTag = Try(Char('<')).Then(SkipWhitespaces); + + private static readonly Parser TryStartParagraph = + Try(SkipNewline.Then(SkipNewline)).Then(SkipWhitespaces); + + private static readonly Parser TryLookTextEnd = + Lookahead(OneOf(TryStartTag, TryStartList, TryStartParagraph, Try(Whitespace.SkipUntil(End)))); + + private static readonly Parser TextParser = + TextChar.AtLeastOnceUntil(TryLookTextEnd).Select(string.Concat); + + private static readonly Parser TextControlParser = Try(Map(text => + { + var rt = new RichTextLabel + { + HorizontalExpand = true, + Margin = new Thickness(0, 0, 0, 15.0f) + }; + + var msg = new FormattedMessage(); + // THANK YOU RICHTEXT VERY COOL + // (text doesn't default to white). + msg.PushColor(Color.White); + + // If the parsing fails, don't throw an error and instead make an inline error message + string? error; + if (!msg.TryAddMarkup(text, out error)) + { + Logger.GetSawmill("Guidebook").Error("Failed to parse RichText in Guidebook"); + + return new GuidebookError(text, error); + } + + msg.Pop(); + rt.SetMessage(msg); + return rt; + }, + TextParser) + .Cast()) + .Labelled("richtext"); + + private static readonly Parser HeaderControlParser = Try(Char('#')) + .Then(SkipWhitespaces.Then(Map(text => new Label + { + Text = text, + StyleClasses = { "LabelHeadingBigger" } + }, + AnyCharExcept('\n').AtLeastOnceString()) + .Cast())) + .Labelled("header"); + + private static readonly Parser SubHeaderControlParser = Try(String("##")) + .Then(SkipWhitespaces.Then(Map(text => new Label + { + Text = text, + StyleClasses = { "LabelHeading" } + }, + AnyCharExcept('\n').AtLeastOnceString()) + .Cast())) + .Labelled("subheader"); + + private static readonly Parser TryHeaderControl = OneOf(SubHeaderControlParser, HeaderControlParser); + + private static readonly Parser ListControlParser = Try(Char('-')) + .Then(SkipWhitespaces) + .Then(Map( + control => new BoxContainer + { + Children = { new Label { Text = ListBullet, VerticalAlignment = VAlignment.Top }, control }, + Orientation = LayoutOrientation.Horizontal + }, + TextControlParser) + .Cast()) + .Labelled("list"); + + #region Text Parsing + + #region Basic Text Parsing + + // Try look for an escaped character. If found, skip the escaping slash and return the character. + + + // like TextChar, but not skipping whitespace around newlines + + // Quoted text - private static readonly Parser QuotedText = Char('"').Then(QuotedTextChar.Until(Try(Char('"'))).Select(string.Concat)).Labelled("quoted text"); + #endregion #region rich text-end markers - private static readonly Parser TryStartList = Try(SkipNewline.Then(SkipWhitespaces).Then(Char('-'))).Then(SkipWhitespaces); - private static readonly Parser TryStartTag = Try(Char('<')).Then(SkipWhitespaces); - private static readonly Parser TryStartParagraph = Try(SkipNewline.Then(SkipNewline)).Then(SkipWhitespaces); - private static readonly Parser TryLookTextEnd = Lookahead(OneOf(TryStartTag, TryStartList, TryStartParagraph, Try(Whitespace.SkipUntil(End)))); + #endregion // parses text characters until it hits a text-end - private static readonly Parser TextParser = TextChar.AtLeastOnceUntil(TryLookTextEnd).Select(string.Concat); - private static readonly Parser TextControlParser = Try(Map(text => - { - var rt = new RichTextLabel() - { - HorizontalExpand = true, - Margin = new Thickness(0, 0, 0, 15.0f), - }; - - var msg = new FormattedMessage(); - // THANK YOU RICHTEXT VERY COOL - // (text doesn't default to white). - msg.PushColor(Color.White); - msg.AddMarkup(text); - msg.Pop(); - rt.SetMessage(msg); - return rt; - }, TextParser).Cast()).Labelled("richtext"); #endregion #region Headers - private static readonly Parser HeaderControlParser = Try(Char('#')).Then(SkipWhitespaces.Then(Map(text => new Label() - { - Text = text, - StyleClasses = { "LabelHeadingBigger" } - }, AnyCharExcept('\n').AtLeastOnceString()).Cast())).Labelled("header"); - private static readonly Parser SubHeaderControlParser = Try(String("##")).Then(SkipWhitespaces.Then(Map(text => new Label() - { - Text = text, - StyleClasses = { "LabelHeading" } - }, AnyCharExcept('\n').AtLeastOnceString()).Cast())).Labelled("subheader"); - - private static readonly Parser TryHeaderControl = OneOf(SubHeaderControlParser, HeaderControlParser); #endregion - // Parser that consumes a - and then just parses normal rich text with some prefix text (a bullet point). - private static readonly Parser ListControlParser = Try(Char('-')).Then(SkipWhitespaces).Then(Map( - control => new BoxContainer() - { - Children = { new Label() { Text = ListBullet, VerticalAlignment = VAlignment.Top, }, control }, - Orientation = LayoutOrientation.Horizontal, - }, TextControlParser).Cast()).Labelled("list"); - #region Tag Parsing + // closing brackets for tags private static readonly Parser TagEnd = Char('>').Then(SkipWhitespaces); private static readonly Parser ImmediateTagEnd = String("/>").Then(SkipWhitespaces); @@ -107,20 +158,24 @@ public sealed partial class DocumentParsingManager private static readonly Parser TryLookTagEnd = Lookahead(OneOf(Try(TagEnd), Try(ImmediateTagEnd))); //parse tag argument key. any normal text character up until we hit a "=" - private static readonly Parser TagArgKey = LetterOrDigit.Until(Char('=')).Select(string.Concat).Labelled("tag argument key"); + private static readonly Parser TagArgKey = + LetterOrDigit.Until(Char('=')).Select(string.Concat).Labelled("tag argument key"); // parser for a singular tag argument. Note that each TryQuoteOrChar will consume a whole quoted block before the Until() looks for whitespace - private static readonly Parser TagArgParser = Map((key, value) => (key, value), TagArgKey, QuotedText).Before(SkipWhitespaces); + private static readonly Parser TagArgParser = + Map((key, value) => (key, value), TagArgKey, QuotedText).Before(SkipWhitespaces); // parser for all tag arguments - private static readonly Parser> TagArgsParser = TagArgParser.Until(TryLookTagEnd); + private static readonly Parser> TagArgsParser = + TagArgParser.Until(TryLookTagEnd); // parser for an opening tag. private static readonly Parser TryOpeningTag = Try(Char('<')) - .Then(SkipWhitespaces) - .Then(TextChar.Until(OneOf(Whitespace.SkipAtLeastOnce(), TryLookTagEnd))) - .Select(string.Concat).Labelled($"opening tag"); + .Then(SkipWhitespaces) + .Then(TextChar.Until(OneOf(Whitespace.SkipAtLeastOnce(), TryLookTagEnd))) + .Select(string.Concat) + .Labelled("opening tag"); private static Parser> ParseTagArgs(string tag) { @@ -138,5 +193,6 @@ private static Parser TryTagTerminator(string tag) .Then(TagEnd) .Labelled($"closing {tag} tag"); } + #endregion } diff --git a/Resources/Locale/en-US/guidebook/guidebook.ftl b/Resources/Locale/en-US/guidebook/guidebook.ftl index f7f37948f5a4..b6c99766c606 100644 --- a/Resources/Locale/en-US/guidebook/guidebook.ftl +++ b/Resources/Locale/en-US/guidebook/guidebook.ftl @@ -3,6 +3,8 @@ guidebook-placeholder-text = Select an entry. guidebook-placeholder-text-2 = If you're new, head over to "New? Start here!" guidebook-filter-placeholder-text = Filter items +guidebook-parser-error = Parser Error +guidebook-error-message = Error Message guidebook-monkey-unspin = Unspin Monkey guidebook-monkey-disco = Disco Monkey