Fixed sources of your own documents not reacting to a click

This commit is contained in:
Thorsten Sommer 2026-09-15 15:52:19 +02:00
parent 9e9efb80e4
commit 0c7fffa2cf
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
9 changed files with 485 additions and 54 deletions

View File

@ -5029,6 +5029,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
-- Are you sure you want to delete the transcription provider '{0}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?"
-- Could not open the file location.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location."
-- Opens {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1170655868"] = "Opens {0}"
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}"
-- Show this file in the file manager of your system
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system"
-- Opens {0} on page {1}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T2402202635"] = "Opens {0} on page {1}"
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error"
-- Could not open the document.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document."
-- Could not open the document: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}"
-- Copy {0} to the clipboard
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard"
@ -12199,6 +12223,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
-- The runtime document endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'."
-- The global shortcut could not be registered because of a desktop integration error.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error."
@ -12226,6 +12253,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes
-- The desktop service returned an invalid response while registering the global shortcut.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut."
-- The runtime document endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details."
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default."
@ -12244,6 +12274,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav
-- Failed to get the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue."
-- The runtime document endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available."
-- AI Studio could not access secure storage. See the log for technical details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details."

View File

@ -223,7 +223,7 @@
}
@if (textContent.Sources.Count > 0)
{
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
<SourcesList Sources="@textContent.Sources"/>
}
</div>
}

View File

@ -0,0 +1,37 @@
@inherits MSGComponentBase
@* The class is what the Markdown renderer wraps its own output in, so the headings and the list
keep the look they had while this list was Markdown. *@
<div class="mud-markdown-body">
@foreach (var group in this.groups)
{
<MudText Typo="Typo.h2">
@group.Heading
</MudText>
<ul>
@foreach (var entry in group.Entries)
{
<li>
@($"[{entry.Number}] ")
@if (entry.Document is { } document)
{
<MudTooltip Text="@this.GetDocumentTooltip(document)" Placement="Placement.Top">
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
@entry.Title
</MudLink>
</MudTooltip>
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
</MudTooltip>
}
else
{
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
@entry.Title
</MudLink>
}
</li>
}
</ul>
}
</div>

View File

@ -0,0 +1,147 @@
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows the sources an answer rests on, grouped and numbered the way the export is.
/// </summary>
/// <remarks>
/// This list used to be Markdown, which read correctly but could not be clicked where it mattered:
/// a Markdown renderer hands every link to the browser, and the browser refuses a file address on a
/// page it loaded over http. A source of the user's own documents therefore did nothing at all.
/// Written out as components, an entry can hand its document to the runtime instead, together with
/// the page the passage was found on.
/// </remarks>
public partial class SourcesList : MSGComponentBase
{
/// <summary>
/// The sources to show.
/// </summary>
[Parameter]
public IList<Source> Sources { get; set; } = [];
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<SourcesList> Logger { get; init; } = null!;
private readonly List<SourceEntryGroup> groups = [];
#region Overrides of ComponentBase
protected override async Task OnParametersSetAsync()
{
this.RebuildGroups();
await base.OnParametersSetAsync();
}
#endregion
/// <summary>
/// Reads the sources once per render instead of once per entry and render.
/// </summary>
/// <remarks>
/// Where a source points is answered by looking at its link, and while an answer streams, this
/// runs again for every chunk. The previous Markdown list was rebuilt and parsed just as often,
/// so this is the cheaper of the two, but it is still worth doing once for the whole list.
/// </remarks>
private void RebuildGroups()
{
this.groups.Clear();
foreach (var group in this.Sources.GroupSources())
{
var entries = new List<SourceEntry>(group.Sources.Count);
foreach (var numberedSource in group.Sources)
{
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document));
}
this.groups.Add(new(group.Heading, entries));
}
}
private string GetDocumentTooltip(SourceDocumentLocation document) => document.PageNumber is > 0
? string.Format(T("Opens {0} on page {1}"), document.Path, document.PageNumber)
: string.Format(T("Opens {0}"), document.Path);
/// <summary>
/// Opens a document in the program the system uses for it.
/// </summary>
/// <remarks>
/// Whether the program can be sent to a page is the runtime's business, and it says afterwards
/// whether it managed to. Nothing is shown about that here: the document is open, and the title
/// of the source names the page anyway.
/// </remarks>
/// <param name="document">The document to open, and the page to show.</param>
private async Task OpenDocument(SourceDocumentLocation document)
{
OpenDocumentResponse response;
try
{
response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not open a source document.");
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document.")));
return;
}
if (response.Success)
return;
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue)));
}
/// <summary>
/// Opens the file browser of the system and selects the document in it.
/// </summary>
/// <remarks>
/// The second way out of the list: a document which the system opens in the wrong program, or
/// which the user wants to move or send on instead of read, is reached from here without being
/// opened. This is the same way out the embeddings page offers for a file it could not read.
/// </remarks>
/// <param name="document">The document to show.</param>
private async Task ShowInFileManager(SourceDocumentLocation document)
{
OpenPathResponse response;
try
{
response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not show a source document in the file manager.");
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
return;
}
if (response.Success)
return;
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
}
/// <summary>
/// One group of the list, prepared so that the markup only has to show it.
/// </summary>
/// <param name="Heading">The heading above the group.</param>
/// <param name="Entries">The entries of the group, in the order they are shown.</param>
private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList<SourceEntry> Entries);
/// <summary>
/// One entry of the list, prepared so that the markup only has to show it.
/// </summary>
/// <param name="Number">The number the source is listed under.</param>
/// <param name="Title">The title of the source.</param>
/// <param name="Link">The address of the source, which a web source is opened by.</param>
/// <param name="Document">The document the source names, or null when it names none.</param>
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document);
}

View File

@ -0,0 +1,13 @@
namespace AIStudio.Tools;
/// <summary>
/// A source together with the number it is listed under.
/// </summary>
/// <remarks>
/// The number runs through the whole list rather than starting over per group, because that is how
/// an answer refers to a source. It is assigned once, where the groups are formed, so the chat and
/// an exported document cannot end up numbering the same list differently.
/// </remarks>
/// <param name="Number">The number this source is listed under, counted from one.</param>
/// <param name="Source">The source itself.</param>
public readonly record struct NumberedSource(int Number, Source Source);

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools;
/// <summary>
/// Where a source points in the file system, and where inside the document it was found.
/// </summary>
/// <param name="Path">The document in the file system, spelled the way this system spells a path.</param>
/// <param name="PageNumber">The page the passage stands on, counted from one, or null when no page is known.</param>
public readonly record struct SourceDocumentLocation(string Path, int? PageNumber);

View File

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@ -79,6 +80,60 @@ public static partial class SourceExtensions
return c is '-' or '.' or '_' or '~' or ':' or '/' or '?' or '#' or '[' or ']' or '@' or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '=';
}
/// <summary>
/// Sorts a list of sources into the groups it is shown in, and numbers them.
/// </summary>
/// <remarks>
/// The order of the groups and the running number are what a reader follows, and they have to
/// be the same wherever the list appears: in the chat, in an exported document, and in the
/// clipboard. This is why both the chat and the Markdown below ask here instead of sorting the
/// list themselves.
/// </remarks>
/// <param name="sources">The list of sources to sort.</param>
/// <returns>The groups which have sources, in the order they are shown; empty when there are none.</returns>
public static IReadOnlyList<SourceGroup> GroupSources(this IList<Source> sources)
{
var llmSources = new List<Source>();
var toolSources = new List<Source>();
var ragSources = new List<Source>();
foreach (var source in sources)
{
switch (source.Origin)
{
case SourceOrigin.LLM:
llmSources.Add(source);
break;
case SourceOrigin.TOOL:
toolSources.Add(source);
break;
case SourceOrigin.RAG:
ragSources.Add(source);
break;
}
}
var groups = new List<SourceGroup>(3);
var sourceNum = 0;
AddGroup(groups, TB("Sources provided by the AI"), llmSources, ref sourceNum);
AddGroup(groups, TB("Sources used by tools"), toolSources, ref sourceNum);
AddGroup(groups, TB("Sources provided by the data providers"), ragSources, ref sourceNum);
return groups;
}
private static void AddGroup(ICollection<SourceGroup> groups, string heading, IReadOnlyList<Source> sources, ref int sourceNum)
{
if (sources.Count == 0)
return;
var numberedSources = new List<NumberedSource>(sources.Count);
foreach (var source in sources)
numberedSources.Add(new(++sourceNum, source));
groups.Add(new(heading, numberedSources));
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// </summary>
@ -87,65 +142,18 @@ public static partial class SourceExtensions
public static string ToMarkdown(this IList<Source> sources)
{
var sb = new StringBuilder();
var ragSources = new List<ISource>();
var toolSources = new List<ISource>();
var sourceNum = 0;
var addedLLMHeaders = false;
foreach (var source in sources)
foreach (var group in sources.GroupSources())
{
switch (source.Origin)
{
case SourceOrigin.RAG:
ragSources.Add(source);
break;
case SourceOrigin.LLM:
if (!addedLLMHeaders)
{
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the AI"));
addedLLMHeaders = true;
}
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
break;
case SourceOrigin.TOOL:
toolSources.Add(source);
break;
}
}
if(toolSources.Count > 0)
{
if(sb.Length > 0)
if (sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources used by tools"));
sb.AppendLine(group.Heading);
foreach (var source in toolSources)
foreach (var numberedSource in group.Sources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
}
}
if(ragSources.Count > 0)
{
if(sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the data providers"));
foreach (var source in ragSources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.Append($"- [{numberedSource.Number}] ");
AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.Source.URL);
sb.AppendLine();
}
}
@ -173,6 +181,55 @@ public static partial class SourceExtensions
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
}
/// <summary>
/// Reads which document a source names, and which page of it.
/// </summary>
/// <remarks>
/// Only a source which names a file has such a location; a web source is opened by the browser
/// and never asks. The page rides in the fragment of the link as `page=N`, which is what the PDF
/// open parameters call for. A chat written before v26.9.1 carries `chunk=N` instead, which names
/// nothing a program could be sent to: such a source keeps its document and loses only the page.
/// </remarks>
/// <param name="source">The source to read.</param>
/// <param name="location">The document and its page, or the default when the source names no file.</param>
/// <returns>Whether the source names a file.</returns>
public static bool TryGetDocumentLocation(this ISource source, out SourceDocumentLocation location)
{
location = default;
if (string.IsNullOrWhiteSpace(source.URL))
return false;
var cleanedUrl = source.URL.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile)
return false;
//
// The link was made from a path of this system, so reading it back gives that path again --
// percent-encoded spaces and umlauts included, and with the separators this system uses.
//
var path = absoluteUri.LocalPath;
if (string.IsNullOrWhiteSpace(path))
return false;
location = new(path, ReadPageFromFragment(absoluteUri.Fragment));
return true;
}
private static int? ReadPageFromFragment(string fragment)
{
const string PAGE_PARAMETER = "page=";
foreach (var parameter in fragment.TrimStart('#').Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (!parameter.StartsWith(PAGE_PARAMETER, StringComparison.OrdinalIgnoreCase))
continue;
if (int.TryParse(parameter.AsSpan(PAGE_PARAMETER.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var pageNumber) && pageNumber > 0)
return pageNumber;
}
return null;
}
/// <summary>
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
/// </summary>

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools;
/// <summary>
/// One group of a source list: a heading and the sources below it.
/// </summary>
/// <param name="Heading">The heading above the group.</param>
/// <param name="Sources">The sources of the group, in the order they are shown.</param>
public readonly record struct SourceGroup(string Heading, IReadOnlyList<NumberedSource> Sources);

View File

@ -85,6 +85,134 @@ public sealed class SourceExtensionsTests
});
}
[Test]
public void TheGroupingIsWhatTheChatAndTheExportBothRead()
{
// Mixed on purpose, and with two sources of one origin, so neither the order of the groups
// nor the order inside a group can come from the order of the input:
IList<Source> sources =
[
new("Handbook", "https://example.org/handbook", SourceOrigin.RAG),
new("Search result", "https://example.org/search", SourceOrigin.TOOL),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
new("Second handbook", "https://example.org/handbook-2", SourceOrigin.RAG),
];
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources(), Has.Count.EqualTo(3), "Each of the three origins has a source, so each of them is a group.");
Assert.That(listed.Select(numbered => numbered.Source.Title), Is.EqualTo(new[] { "Cited by the model", "Search result", "Handbook", "Second handbook" }), "What the AI cited comes first, then what the tools read, then what the data providers gave.");
Assert.That(listed.Select(numbered => numbered.Number), Is.EqualTo(new[] { 1, 2, 3, 4 }), "The number runs through the whole list instead of starting over per group.");
});
}
[Test]
public void AnOriginWithoutSourcesIsNoGroup()
{
IList<Source> sources = [new("Search result", "https://example.org/search", SourceOrigin.TOOL)];
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources().Select(group => group.Sources.Count), Is.EqualTo(new[] { 1 }), "An answer which only used a tool gets one group, not three with two of them empty.");
Assert.That(new List<Source>().GroupSources(), Is.Empty, "An answer nobody had to look up gets no group at all.");
});
}
[Test]
public void TheMarkdownListsExactlyWhatTheGroupingSaysItShould()
{
IList<Source> sources =
[
new("Handbook (Page 12)", "file:///Users/someone/handbook.pdf#page=12", SourceOrigin.RAG),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
];
var entries = EntriesOf(sources.ToMarkdown());
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.That(entries, Has.Count.EqualTo(listed.Count), "Every source the grouping lists is written out, and nothing else is.");
for (var index = 0; index < entries.Count; index++)
Assert.That(entries[index], Does.StartWith($"- [{listed[index].Number}] ").And.Contains(listed[index].Source.Title), "The Markdown and the chat read the same grouping, so a source cannot be numbered one way here and another way there.");
}
[Test]
public void AKnownPageRidesInTheLinkOfASource()
{
var location = LocationOf("file:///Users/someone/My%20Documents/Gr%C3%B6%C3%9Fere%20%C3%9Cbersicht.pdf#page=12");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("Größere Übersicht.pdf").And.Contains("My Documents"), "The percent-encoding of the link is undone, so the program is handed the name the file really has.");
Assert.That(location.PageNumber, Is.EqualTo(12), "This is the page the passage was found on, and the page the document is opened at.");
});
}
[Test]
public void APathOfAWindowsMachineComesBackAsOne()
{
var location = LocationOf("file:///C:/Users/someone/Documents/handbook.pdf#page=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Is.EqualTo(@"C:\Users\someone\Documents\handbook.pdf"), "A drive letter and backslashes are what a program on Windows is handed -- and what the link was made from there.");
Assert.That(location.PageNumber, Is.EqualTo(3));
});
}
[Test]
public void AChatFromBeforeThisReleaseKeepsItsDocumentAndLosesOnlyItsPage()
{
var location = LocationOf("file:///Users/someone/handbook.pdf#chunk=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("handbook.pdf"), "Such a source still names its document, so the click still opens it.");
Assert.That(location.PageNumber, Is.Null, "A chunk is not a page: no program can be sent to one, so the document opens on its first page.");
});
}
[Test]
public void ALinkWithoutAFragmentNamesNoPage()
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf").PageNumber, Is.Null);
}
[Test]
public void APageWhichIsNoPageIsReadAsNone()
{
Assert.Multiple(() =>
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=0").PageNumber, Is.Null, "Pages are counted from one, so a zero is not a page.");
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=-2").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=twelve").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#chunk=3&page=12").PageNumber, Is.EqualTo(12), "A link which already carried a fragment gets the page appended with an ampersand, and it is found there too.");
});
}
[Test]
public void AWebSourceNamesNoDocumentAtAll()
{
// The fragment reads like a page on purpose: what decides is the scheme, not the fragment.
ISource source = new Source("Article", "https://example.org/article#page=12", SourceOrigin.LLM);
Assert.That(source.TryGetDocumentLocation(out _), Is.False, "A web source is opened by the browser and has no path to hand to a program.");
}
/// <summary>
/// Reads where the link of a source points, and fails the test when it points nowhere.
/// </summary>
/// <param name="url">The link of the source.</param>
/// <returns>The document and the page the link names.</returns>
private static SourceDocumentLocation LocationOf(string url)
{
ISource source = new Source("Handbook", url, SourceOrigin.RAG);
Assert.That(source.TryGetDocumentLocation(out var location), Is.True, "This link names a file, so a location is what it has.");
return location;
}
/// <summary>
/// Reads the entries of a source list, without the headings above them.
/// </summary>