Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ The spacing you chose around a join hint, as in `kind=inner`, survives formattin
that is how the queries people bring to a session are usually written. Highlighting and
formatting both read Kusto through Microsoft's own parser, and both run locally.

### Pasted code is recognized without a setting
Plain text now comes last in the snippet format order, so pasted DAX, SQL, or KQL arrives
as code. A bare word or a number still pastes as a note. An order you chose in
Preferences is kept; a language added by an update joins it in front of Plain text.

## 1.3.0 - 3 September 2026

### Export a board to PowerPoint
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ With the mouse, selection is automatic: click a container to move it, or drag th

Imported images, LiveViews, and text objects act as containers. A completed stroke is linked when it touches exactly one container, including crossing its edge; a stroke touching multiple containers remains independent. Moving or resizing a container transforms its linked strokes with it. **View → Bring to front** and **View → Send to back** reorder the selected container and those linked strokes. Deleting a container also deletes all of its linked strokes. Undo/redo treats each complete container operation as one action.

Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts, so leaving it first keeps every paste as plain text. Recognized extensions (`.dax`, `.sql`, `.kql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward. Press **F6** to format DAX, SQL, or KQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX, SQL, or KQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. KQL mode reads Kusto through Microsoft's own parser and likewise leaves an invalid query unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it.
Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts and comes last by default, so DAX, SQL, and KQL are recognized on paste; a language claims a snippet only when it carries an operator, function, or keyword, so a bare word stays a note. Put Plain text first to keep every paste plain. A language added by an update joins in front of Plain text unless Plain text is first. Recognized extensions (`.dax`, `.sql`, `.kql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward. Press **F6** to format DAX, SQL, or KQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX, SQL, or KQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. KQL mode reads Kusto through Microsoft's own parser and likewise leaves an invalid query unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it.

Image files, and `.txt`, `.dax`, `.sql`, and `.kql` files, can be dropped directly from File Explorer. Their initial center is the board position at which they were dropped. DAX, SQL, and KQL files open in the matching language mode. Other dropped text files use Snippet format order.

Expand Down
30 changes: 30 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,36 @@ board without one still opens in every release since format 5.

---

## 28. Plain text is the last snippet format, and a language must earn a snippet

**Implemented.**

Snippet format order decides which language a paste becomes: the first that accepts the
text wins, and plain text accepts everything. With plain text first, the shipped default
since 1.0, every paste was a note until someone found the setting; a language added by an
update (KQL in 1.3.0) joined the end of the list, behind plain text, and so never applied.
The goal is that a new language works on the day it ships, with no visit to Preferences.

Two things were changed together, and the second is what makes the first safe:

- **Plain text comes last** in the default order: DAX, SQL, KQL, Plain text. A saved order
equal to a default the application ever shipped was never chosen by anyone, so an
upgrade replaces it (the settings version moved to 16 to do this once). A chosen order
is kept, and a language it does not know joins it in front of plain text wherever plain
text sits, unless plain text is first, which is the one order that means "keep my pastes
plain"; there the new language goes last. The order itself carries the intent, so the
checkbox that was considered, "new languages go before plain text", was not added: it
would say the same thing twice and allow the contradictory state of plain text first with
languages jumping ahead of it.
- **A language claims a snippet only when it carries a signal** of its own. Every parser
accepts a bare name or a number: `Sales` is a DAX table expression and a KQL query. With
plain text last that would have turned a one-word note into code. So DAX needs a
function, operator, keyword, column reference, or variable; T-SQL a keyword or function;
KQL a pipe, query operator, keyword, command, or function. The parser still has the last
word after the signal.

---

## Open questions

- arm64 is not built; add it if Surface devices matter for a pen application.
Expand Down
2 changes: 1 addition & 1 deletion site/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ <h3>Containers</h3>
<h3>Text, DAX, SQL, and KQL</h3>
<p>Paste prefers an image when the clipboard has one, including a file SnagIt or another capture tool left on the clipboard. Paste plain text to create a selected text container.</p>
<p>SVG counts as an image, whether the source application published it as SVG or the markup was simply copied as text — the output of a DAX SVG measure pastes as a picture, not as a snippet. Copying an SVG container puts the markup and a bitmap on the clipboard together, so an editor receives the source and a slide receives the picture.</p>
<p><span class="ui">Help → Preferences</span> sets Snippet format order: paste tries those languages from top to bottom. Plain text always matches, so putting it first keeps paste as plain text. Use the title-bar chip to choose Plain text, DAX, SQL Server, or KQL afterward.</p>
<p><span class="ui">Help → Preferences</span> sets Snippet format order: paste tries those languages from top to bottom, and Plain text comes last, so DAX, SQL, and KQL are recognized while a bare word stays a note. Put Plain text first to keep every paste plain. Use the title-bar chip to change the language afterward.</p>
<p><kbd>F6</kbd> formats DAX, SQL, or KQL on the selected container, without entering edit mode. <kbd>F2</kbd> edits the container, and double-click still centers it and fits it to the canvas; inside an edit <kbd>F6</kbd> formats in place and <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps <code>GO</code> batch separators, and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and likewise leaves an invalid query alone. <kbd>Escape</kbd> restores the previous text, language, and size.</p>

<h3 id="liveview">LiveView</h3>
Expand Down
50 changes: 44 additions & 6 deletions src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,43 @@ public static string Normalize(string? languageId) =>
_ => Plain,
};

public static IReadOnlyList<string> All { get; } = [Plain, Dax, SqlServer, Kql];
/// <summary>
/// The default snippet format order, which is also the order a language
/// missing from a saved order joins it in. Plain text accepts everything,
/// so it comes last: a paste is code if any language says it is, and text
/// otherwise, with no setting to change.
/// </summary>
public static IReadOnlyList<string> All { get; } = [Dax, SqlServer, Kql, Plain];

/// <summary>
/// The default orders earlier releases wrote into settings, with plain text
/// first. A saved order equal to one of these was never chosen by anyone, so
/// an upgrade may replace it with <see cref="All"/>.
/// </summary>
public static IReadOnlyList<IReadOnlyList<string>> LegacyDefaultOrders { get; } =
[
[Plain, Dax, SqlServer],
[Plain, Dax, SqlServer, Kql],
];

public static bool IsLegacyDefaultOrder(IEnumerable<string>? languageIds)
{
if (languageIds is null)
{
return false;
}

var order = languageIds.Select(Normalize).ToArray();
return LegacyDefaultOrders.Any(legacy => legacy.SequenceEqual(order, StringComparer.Ordinal));
}

/// <summary>
/// Drops unknown names and duplicates, and adds the languages the saved
/// order does not know. Those go in front of plain text wherever it sits,
/// because an order that puts plain text after some languages means "try
/// the languages first"; only an order that starts with plain text means
/// "keep my pastes plain", and there they go last.
/// </summary>
public static IReadOnlyList<string> NormalizeOrder(IEnumerable<string>? languageIds)
{
var ordered = new List<string>();
Expand All @@ -273,12 +308,15 @@ public static IReadOnlyList<string> NormalizeOrder(IEnumerable<string>? language
}
}

foreach (var languageId in All)
var missing = All.Where(languageId => !ordered.Contains(languageId, StringComparer.Ordinal)).ToArray();
var plainIndex = ordered.IndexOf(Plain);
if (plainIndex > 0)
{
if (!ordered.Contains(languageId, StringComparer.Ordinal))
{
ordered.Add(languageId);
}
ordered.InsertRange(plainIndex, missing);
}
else
{
ordered.AddRange(missing);
}

return ordered;
Expand Down
15 changes: 14 additions & 1 deletion src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,14 @@ public sealed class AppSettings

public static class AppSettingsSerializer
{
public const int CurrentVersion = 15;
public const int CurrentVersion = 16;

/// <summary>
/// The version that moved plain text to the end of the default snippet
/// format order. A file older than this whose order is still a shipped
/// default was never customized, and takes the new default.
/// </summary>
private const int VersionWithPlainTextLast = 16;

private static readonly JsonSerializerOptions JsonOptions = new()
{
Expand Down Expand Up @@ -278,6 +285,12 @@ private static AppSettings Normalize(AppSettings? settings)
settings.Laser = LaserSettings.Normalize(settings.Laser);
settings.PenButtons = PenButtonSettings.Normalize(settings.PenButtons);
settings.Export = ExportSettings.Normalize(settings.Export);
if (settings.Version < VersionWithPlainTextLast &&
TextLanguageIds.IsLegacyDefaultOrder(settings.SnippetFormatOrder))
{
settings.SnippetFormatOrder = [.. TextLanguageIds.All];
}

settings.SnippetFormatOrder = [.. TextLanguageIds.NormalizeOrder(settings.SnippetFormatOrder)];
settings.LatestKnownVersion = NormalizeVersionId(settings.LatestKnownVersion);
settings.LastDismissedVersion = NormalizeVersionId(settings.LastDismissedVersion);
Expand Down
27 changes: 27 additions & 0 deletions src/SQLBI.Whiteboard.Dax/DaxLanguageEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ public static bool TryFormat(
}
}

/// <summary>
/// Whether the text has something only DAX would have: a function, a
/// keyword, an operator, a column reference, or a variable. A bare name or
/// a number parses as DAX too, and pasted as a note it must stay a note.
/// </summary>
public static bool LooksLike(string source)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}

try
{
return Classify(source).Any(span => span.Classification is
DaxTextClassification.Keyword or
DaxTextClassification.Function or
DaxTextClassification.Operator or
DaxTextClassification.ColumnReference or
DaxTextClassification.Variable);
}
catch (Exception)
{
return false;
}
}

public static IReadOnlyList<DaxClassifiedSpan> Classify(string source) =>
DaxClassifier.Classify(source)
.Select(span => new DaxClassifiedSpan(
Expand Down
31 changes: 31 additions & 0 deletions src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,37 @@ public static KqlTextAnalysis Analyze(string source)
}
}

/// <summary>
/// Whether the text has something only KQL would have: a pipe, a query
/// operator, a keyword, a command, or a function. A bare table name is a
/// valid query to the parser, and pasted as a note it must stay a note.
/// </summary>
public static bool LooksLike(string source)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}

if (source.Contains('|'))
{
return true;
}

try
{
return Classify(source).Any(span => span.Classification is
KqlTextClassification.Keyword or
KqlTextClassification.QueryOperator or
KqlTextClassification.Command or
KqlTextClassification.Function);
}
catch (Exception)
{
return false;
}
}

public static IReadOnlyList<KqlClassifiedSpan> Classify(string source) =>
Analyze(source).Spans;

Expand Down
24 changes: 24 additions & 0 deletions src/SQLBI.Whiteboard.SqlServer/SqlServerLanguageEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ public static SqlServerTextAnalysis Analyze(string source)
}
}

/// <summary>
/// Whether the text has something only T-SQL would have: a keyword or a
/// function. The parser rejects most prose on its own; this keeps the
/// acceptance rule the same shape as the other languages'.
/// </summary>
public static bool LooksLike(string source)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}

try
{
return Classify(source).Any(span => span.Classification is
SqlServerTextClassification.Keyword or
SqlServerTextClassification.Function);
}
catch (Exception)
{
return false;
}
}

public static IReadOnlyList<SqlServerClassifiedSpan> Classify(string source) =>
Analyze(source).Spans;

Expand Down
2 changes: 1 addition & 1 deletion src/SQLBI.Whiteboard/SettingsCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ public static class EraserButton
Category = Input,
Title = "Snippet format order",
Summary = "Which language pasted text is tried as first",
Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts, so putting it first keeps every paste as plain text. Recognized file extensions (.dax, .sql, .kql, .txt) keep their language.",
Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts and comes last, so code is recognized and a note stays a note; put it first to keep every paste plain. Recognized file extensions (.dax, .sql, .kql, .txt) keep their language.",
Keywords =
["snippet", "language", "dax", "sql", "kql", "paste", "format", "text", "order"],
Editor = SettingEditorKind.OrderedList,
Expand Down
12 changes: 9 additions & 3 deletions src/SQLBI.Whiteboard/TextLanguages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,13 @@ public bool TryFormat(string source, out string formatted) =>
DaxLanguageEngine.DefaultMaximumLineLength,
out formatted);

// The parser alone is too welcoming: a bare name or a number parses in
// more than one language. The engine says whether there is a real signal.
public bool TryAccept(string source)
{
try
{
return TryFormat(source, out _);
return DaxLanguageEngine.LooksLike(source) && TryFormat(source, out _);
}
catch (Exception)
{
Expand Down Expand Up @@ -235,11 +237,13 @@ public TextLanguageAnalysis Analyze(string source, string fallbackTitle)
public bool TryFormat(string source, out string formatted) =>
SqlServerLanguageEngine.TryFormat(source, out formatted);

// The parser alone is too welcoming: a bare name or a number parses in
// more than one language. The engine says whether there is a real signal.
public bool TryAccept(string source)
{
try
{
return TryFormat(source, out _);
return SqlServerLanguageEngine.LooksLike(source) && TryFormat(source, out _);
}
catch (Exception)
{
Expand Down Expand Up @@ -342,11 +346,13 @@ public TextLanguageAnalysis Analyze(string source, string fallbackTitle)
public bool TryFormat(string source, out string formatted) =>
KqlLanguageEngine.TryFormat(source, out formatted);

// The parser alone is too welcoming: a bare name or a number parses in
// more than one language. The engine says whether there is a real signal.
public bool TryAccept(string source)
{
try
{
return TryFormat(source, out _);
return KqlLanguageEngine.LooksLike(source) && TryFormat(source, out _);
}
catch (Exception)
{
Expand Down
Loading