diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c0203..f636352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,11 @@ Templates from boards made with the Windows 10 app of Microsoft Whiteboard now c with their title and the notes, lists, text, pictures, shapes, and ink placed on them. They used to be listed as left out. -### Loop components and apps from Microsoft Whiteboard -A Loop component now comes across as a link to it, and an app made by Copilot as an empty -panel of its size. They used to be listed as left out. +### Loop components, apps, work items, and frames from Microsoft Whiteboard +A Loop component now comes across as a link to it, an app made by Copilot as an empty +panel of its size, an Azure DevOps work item as a text container with its title, owner, +and state, and a frame Copilot drew as a panel with its title. They used to be listed as +left out. ## 1.7.3 - 30 September 2026 diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index abe0d12..b163b31 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -71,6 +71,8 @@ no model of the board beyond what it draws. | `Hyperlink` | A preview card with the page's picture, an `a` with the link and its title, and the description. | | `LoopObject`, `HostedFluidObject`, `GroupFluidObject` | Live content that the export does not keep. A Loop component is a `div.loopParentDiv` with the component's address in `data-loop-url`; the export replaces its content with an `a` to that address around the picture `LoopExportThumbnail.svg`, which it does not embed. Not seen in a sample; read from the web client's `LoopComponentConnected` and export code. | | `AppIframeHost` | An app that Copilot made, running in a sandboxed `iframe`. The export replaces the frame with a `#faf9f8` panel and keeps the `div.appFrameContainer` with its `width` and `height` and its header, whose `span.appFrameHeaderTitle` says *App* in the board's language. The app's name, code, and state are not in the export. Not seen in a sample; read from the web client's app frame component and export code. | +| `WorkItem` | An Azure DevOps work item: a `div.WorkItem` card, 220 × 145 pixels in the stylesheet, white with a 4-pixel gold border on its left. A `span.WorkItemTextField` holds a `strong` with the type's `img.WorkItemIcon` (its `alt` names the type) and the ID, followed by the title; then come who it is assigned to and, in `span.WorkItemText`, the word *State* in the board's language and the state. The web client draws a placeholder instead when its `EnableWorkItems` flag is off. Not seen in a sample; read from the web client's `WorkItemConnected`. | +| `Frame` | A frame Copilot drew around objects it grouped: a `section.whiteboardFrame` with its `width` and `height` and a theme class, `whiteboardFrame--blue`, `--green`, `--purple`, `--orange`, or neutral without one. The stylesheet gives it a 3-pixel border, a translucent fill, 14-pixel corners, and a 44-pixel title bar with bold 15-pixel text in `span.whiteboardFrame__titleText`, padded 14 pixels in. A pill names the first source and counts the rest; the full list is in a popover that is drawn only when open. The web client draws nothing when its `EnableFrames` flag is off. Not seen in a sample; read from the web client's frame component. | | `CommentThread` | A pin, described above. | ### Ink @@ -135,6 +137,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | Legacy template | The title's bar as a filled rectangle and its text as a bold label, then each child read by the reader for what it holds: ink, a note, a note grid, a list, a text box, a picture, or a shape. A picture or a shape moves by half its size, because a child is placed from its corner and those are centered on their anchor at the top level. A child of any other kind is counted as a template item | | Loop component | Markdown `TextBoardObject` titled *Loop*, linking to the component, as wide as it was | | App made by Copilot | `ShapeBoardObject` rectangle of the frame's size in the panel's color, with the header's title as its text | +| Work item | Markdown `TextBoardObject` titled *Work item*: the type and ID in bold before the title, then who it is assigned to and its state, as wide as the card | +| Copilot frame | `ShapeBoardObject` rectangle with the theme's border and translucent fill, and its title as a bold label in the theme's text color. Its sources are left out | | Link card | Markdown `TextBoardObject` titled *Link* | | List | Markdown `TextBoardObject` titled *List*: the list's title in bold, then a Markdown list of bullets, or one line per item with ☒ for a done task and ☐ for an open one | | Comment thread | Markdown `TextBoardObject` titled *Comment*, in a column to the right of the board | @@ -238,14 +242,17 @@ parses in about 160 ms. - PowerPoint pages, which the Windows client cannot insert, videos, and Loop components - SVG pictures, which the Windows client refuses to insert - Groups, which the Windows client does not offer -- GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, and apps, - which are read from the web client's code, not from a sample +- GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, apps, work + items, and Copilot frames, which are read from the web client's code, not from a sample The web client's code names every type an export can hold. Its `data-whiteboard-type` values, as of version 26.10910.101 in September 2026, are the types above and these, -which the importer reports and leaves out: `WorkItem`, `Frame`, and `CustomElement`, -an `Unknown` that holds no table, live content without a Loop element, and an app frame -without its box. +which the importer reports and leaves out: `CustomElement`, an `Unknown` that holds no +table, live content without a Loop element, an app frame without its box, and a work item +or a frame drawn as a placeholder. + +The tokenizer yields text that follows an end tag as a tag named `#text`, because a work +item's title comes after the bold ID. Each is reported by type after the import. Sticky notes cannot be rotated in the Windows client, with the mouse, the pen, or touch. diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index 490db7e..1ea3660 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -225,7 +225,7 @@

What comes across

  • Sticky notes and note grids, as shapes in the same colors with their text.
  • Text boxes, lists, tables drawn with ink, connectors, and links.
  • Templates from the Windows 10 app, with their title and what is placed on them.
  • -
  • Loop components, as links to them.
  • +
  • Loop components as links to them, and Azure DevOps work items with their title, owner, and state.
  • Comments, as text beside the board with the names of their authors.
  • A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight. The background color and reactions are left out. After the import, a message lists anything the board did not bring across.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs b/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs index b449c81..0cc1b15 100644 --- a/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs +++ b/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs @@ -4,9 +4,10 @@ namespace SQLBI.Whiteboard.Core.Import; /// /// The start tags of an HTML page, in document order, with their attributes and the -/// text that follows each one up to the next tag. The Microsoft Whiteboard export is -/// generated markup, so quoting is regular, but it is HTML rather than XML: some -/// elements are never closed. +/// text that follows each one up to the next tag. Text that follows an end tag, such as +/// a title after a bold ID, comes as a tag named #text. The Microsoft Whiteboard +/// export is generated markup, so quoting is regular, but it is HTML rather than XML: +/// some elements are never closed. /// internal static class HtmlTags { @@ -16,6 +17,10 @@ internal static class HtmlTags /// private const int MaximumText = 65536; + public const string TextName = "#text"; + + private static readonly Dictionary NoAttributes = new(StringComparer.OrdinalIgnoreCase); + public static IEnumerable StartTags(string html, int start) { var index = html.LastIndexOf('<', Math.Clamp(start, 0, Math.Max(0, html.Length - 1))); @@ -28,6 +33,25 @@ public static IEnumerable StartTags(string html, int start) yield break; } + if (html[open + 1] == '/') + { + var close = html.IndexOf('>', open); + if (close < 0) + { + yield break; + } + + var after = html.IndexOf('<', close); + var length = (after < 0 ? html.Length : after) - close - 1; + if (length is > 0 and < MaximumText && !html.AsSpan(close + 1, length).IsWhiteSpace()) + { + yield return new HtmlTag(TextName, NoAttributes, WebUtility.HtmlDecode(html.Substring(close + 1, length))); + } + + index = close + 1; + continue; + } + if (!char.IsAsciiLetter(html[open + 1])) { index = open + 1; diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index 89b1e6d..87e84d5 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -29,6 +29,33 @@ public sealed partial class MicrosoftWhiteboardExport private const string CommentTitle = "Comment"; private const string ListTitle = "List"; private const string LoopTitle = "Loop"; + private const string WorkItemTitle = "Work item"; + + /// + /// A work item card in the web client's stylesheet. + /// + private const double WorkItemWidth = 220; + private const double WorkItemHeight = 145; + + /// + /// A Copilot frame in the web client's stylesheet: a 3-pixel border, and a title + /// bar with bold 15-pixel text padded 14 pixels in, centered in its 44 pixels. + /// + private const double FrameBorder = 3; + private const double FrameTitleFontSize = 15; + private static readonly PointD FrameTitleInset = new(17, 14); + + /// + /// A Copilot frame's border, translucent fill, and title color for each theme. + /// + private static readonly Dictionary FrameThemes = new(StringComparer.Ordinal) + { + ["neutral"] = (0xFF6B6967, 0xC2FAF9F8, 0xFF323130), + ["blue"] = (0xFF314AB2, 0xD1EFF3FF, 0xFF314AB2), + ["green"] = (0xFF498205, 0xD1F1FAE8, 0xFF3B6A04), + ["purple"] = (0xFF8764B8, 0xD1F9F5FF, 0xFF5C2E91), + ["orange"] = (0xFFCA5010, 0xD1FFF4EC, 0xFFA4262C), + }; /// /// How wide a Loop component's container is when the page gives no size. @@ -261,6 +288,29 @@ void Skip(string what, int count = 1) items.Add(link); } + break; + case "WorkItem": + if (ReadWorkItem(anchor, tags) is { } workItem) + { + items.Add(workItem); + } + else + { + Skip(anchor.Type); + } + + break; + case "Frame": + var frame = ReadFrame(anchor, tags).ToArray(); + if (frame.Length > 0) + { + items.AddRange(frame); + } + else + { + Skip(anchor.Type); + } + break; case "LoopObject": case "HostedFluidObject": @@ -1209,6 +1259,116 @@ private static IEnumerable ReadTemplateChild(Anchor pla new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20 * anchor.Scale, Black, false, false, false)); } + /// + /// An Azure DevOps work item, drawn as a card when the web client's + /// EnableWorkItems flag is on: a bold line with the type's icon and the ID, + /// then the title, who it is assigned to, and its state. It becomes Markdown titled + /// Work item with those lines, as wide as the card. + /// + private static MicrosoftWhiteboardTextContainer? ReadWorkItem(Anchor anchor, List tags) + { + var cardIndex = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("WorkItem")); + if (cardIndex < 0) + { + return null; + } + + var fieldIndex = tags.FindIndex(cardIndex, tag => tag.HasClass("WorkItemTextField")); + var texts = new List<(int Index, string Text)>(); + for (var index = cardIndex; index < tags.Count; index++) + { + if (!string.IsNullOrWhiteSpace(tags[index].Text)) + { + texts.Add((index, tags[index].Text.Trim())); + } + } + + // The field holds the ID after the icon and the title after the bold part. What + // follows it is who the item is assigned to, then its state. + var type = tags.Skip(cardIndex).FirstOrDefault(tag => tag.HasClass("WorkItemIcon"))?.Attribute("alt"); + var field = fieldIndex < 0 ? [] : texts.Where(text => text.Index >= fieldIndex).Take(2).ToArray(); + var id = field.Length > 1 && field[0].Text.All(char.IsAsciiDigit) ? field[0].Text : null; + var title = id is null ? field.FirstOrDefault().Text : field[1].Text; + var rest = texts.Where(text => field.All(item => item.Index != text.Index)).Select(text => text.Text).ToArray(); + if (string.IsNullOrEmpty(title) && rest.Length == 0) + { + return null; + } + + var heading = string.Join(' ', new[] { Capitalized(type), id }.Where(part => !string.IsNullOrEmpty(part))); + var lines = new List + { + (heading.Length > 0 ? $"**{EscapeMarkdown(heading)}** " : string.Empty) + EscapeMarkdown(title ?? string.Empty), + }; + if (rest.Length > 0) + { + lines.Add(string.Join(" \n", new[] { rest[0], string.Join(' ', rest.Skip(1)) } + .Where(line => line.Length > 0) + .Select(EscapeMarkdown))); + } + + return new MicrosoftWhiteboardTextContainer( + anchor.ToCanvas(anchor.Centered ? new PointD(-WorkItemWidth / 2, -WorkItemHeight / 2) : default), + WorkItemWidth * anchor.Scale, + WorkItemTitle, + string.Join("\n\n", lines.Where(line => line.Trim().Length > 0))); + } + + private static string? Capitalized(string? text) => + string.IsNullOrEmpty(text) ? text : char.ToUpperInvariant(text[0]) + text[1..]; + + /// + /// A frame that Copilot drew around objects it grouped, when the web client's + /// EnableFrames flag is on: a rounded, translucent panel with a border and a + /// title bar in one of five themes. It becomes a rectangle in the theme's colors + /// with its title as a bold label in the bar: its corners are rounded by 14 pixels, + /// which on a frame is closer to square than to a rounded rectangle here. Its sources are left out, + /// because the export keeps only the first one's name. + /// + private static IEnumerable ReadFrame(Anchor anchor, List tags) + { + var frame = tags.FirstOrDefault(tag => tag.HasClass("whiteboardFrame")); + var width = StyleLength(frame?.Attribute("style"), "width") ?? 0; + var height = StyleLength(frame?.Attribute("style"), "height") ?? 0; + if (frame is null || width <= 0 || height <= 0) + { + yield break; + } + + var theme = FrameThemes.Keys.FirstOrDefault(key => frame.HasClass($"whiteboardFrame--{key}")) ?? "neutral"; + var (border, fill, text) = FrameThemes[theme]; + yield return new MicrosoftWhiteboardShape( + anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)), + width * anchor.Scale, + height * anchor.Scale, + anchor.AngleDegrees, + ShapeKind.Rectangle, + border, + fill, + FrameBorder * anchor.Scale, + string.Empty, + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20, Black, false, false, false)); + + var title = tags.FirstOrDefault(tag => tag.HasClass("whiteboardFrame__titleText"))?.Text.Trim(); + if (string.IsNullOrEmpty(title)) + { + title = frame.Attribute("aria-label")?.Trim(); + } + + if (!string.IsNullOrEmpty(title)) + { + yield return new MicrosoftWhiteboardLabel( + anchor.ToCanvas(anchor.Centered ? new PointD(-width / 2, -height / 2) : default), + anchor.Scale, + anchor.AngleDegrees, + FrameTitleInset, + title, + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, FrameTitleFontSize, text, true, false, false), + Math.Max(1, width - (2 * FrameTitleInset.X)), + null); + } + } + /// /// A connector's SVG draws its route from the anchor, and its head as a small /// path moved to one end. A route with corners is kept as a straight line. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index dbeeefa..46bc98f 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -142,6 +142,7 @@ public static void Run() RunTables(); RunTemplate(); RunLive(); + RunCards(); } /// @@ -611,6 +612,51 @@ private static void RunLive() "An app is a panel of its size with its header's title."); } + /// + /// A work item card and a Copilot frame as the web client draws them, and both of + /// them drawn as placeholders when their flags are off. + /// + private const string CardsPage = """ +
    +
    +
    +
    bug4521 Crash when *saving*
    +
    Ana Lopez
    +
    State
    +
    Active
    +
    +
    +
    +
    +
    Ideas
    +
    +
    +
    +
    +
    +
    + """; + + private static void RunCards() + { + var board = MicrosoftWhiteboardExport.Parse(CardsPage, "Cards"); + Assert(board.Skipped.Count == 2 && board.Skipped["WorkItem"] == 1 && board.Skipped["Frame"] == 1, + "A work item and a frame drawn as placeholders are still reported."); + + Assert(board.Items[0] is MicrosoftWhiteboardTextContainer { Title: "Work item", Width: 220 } item && + item.TopLeft == new PointD(100, 100) && + item.Markdown == "**Bug 4521** Crash when \\*saving\\*\n\nAna Lopez \nState Active", + "A work item is Markdown with its type, ID, and title, then who it is assigned to and its state."); + + Assert(board.Items[1] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 800, Height: 600, OutlineArgb: 0xFF314AB2, FillArgb: 0xD1EFF3FF, Thickness: 3 } frame && + frame.Center == new PointD(1400, 300), + "A Copilot frame is a translucent panel in its theme's colors."); + Assert(board.Items[2] is MicrosoftWhiteboardLabel { Text: "Ideas", WrapWidth: 766, Font: { Bold: true, Size: 15, Argb: 0xFF314AB2 } } title && + title.Origin == new PointD(1000, 0) && title.Inset == new PointD(17, 14), + "A Copilot frame's title is bold in its theme's color, inside the title bar."); + Assert(board.Items.Count == 3, "A frame's sources are left out."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));