diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs index 431b70b8..4d8f2346 100644 --- a/app/MindWork AI Studio/Chat/IContentExtensions.cs +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -55,8 +55,11 @@ public static class IContentExtensions /// /// The content to read. /// The Markdown text including its sources, or an empty string when there is none. + /// Whether a link into a local file may name its page. Only a + /// format whose reader stumbles over such a link says no here; the clipboard and every text + /// format keep the page. /// True, when this content carries Markdown text. - public static bool TryGetExportMarkdown(this IContent content, out string markdown) + public static bool TryGetExportMarkdown(this IContent content, out string markdown, bool keepPageAnchors = true) { if (content is not ContentText text) { @@ -65,7 +68,7 @@ public static class IContentExtensions } var answer = text.Text.Trim(); - var sources = text.Sources.ToExportMarkdown(); + var sources = text.Sources.ToExportMarkdown(keepPageAnchors); if (sources.Length == 0) { markdown = answer; diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs index d6b3eca4..2fde6b79 100644 --- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs +++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs @@ -204,6 +204,26 @@ public static class FileExportFormatExtensions _ => WITHOUT_BYTE_ORDER_MARK, }; + /// + /// Determines whether a link into a local file may name the page it points at. + /// + /// + /// A page is named by the fragment of the link, the way the PDF open parameters call for. A + /// browser and a PDF reader follow that and open the document on the page; Word and LibreOffice + /// take the fragment for part of the file name, look for a file which does not exist, and refuse + /// the link altogether. There the page is dropped, so the link at least opens the document -- + /// which page it was stays in the title of the source. Verified on 2026-09-15 with LibreOffice + /// on an exported .odt. A format added later keeps the page unless it is known to stumble too. + /// + /// The format. + /// True, when a reader of this format follows such a link. + public static bool FollowsPageAnchors(this FileExportFormat format) => format switch + { + FileExportFormat.MICROSOFT_WORD or FileExportFormat.OPEN_DOCUMENT_TEXT => false, + + _ => true, + }; + /// /// Returns the name Pandoc knows the format by. /// diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs index c7ce9738..1b63fa42 100644 --- a/app/MindWork AI Studio/Tools/PandocExport.cs +++ b/app/MindWork AI Studio/Tools/PandocExport.cs @@ -118,7 +118,7 @@ public static class PandocExport // We read the text before we ask for a path: when there is nothing to convert, the user // should learn that right away instead of picking a file first and getting an error afterwards. // - if (!markdownContent.TryGetExportMarkdown(out var markdownText)) + if (!markdownContent.TryGetExportMarkdown(out var markdownText, format.FollowsPageAnchors())) { LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported."))); diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs index 2f343104..3dfe7f5c 100644 --- a/app/MindWork AI Studio/Tools/SourceExtensions.cs +++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs @@ -138,8 +138,9 @@ public static partial class SourceExtensions /// Converts a list of sources to a markdown-formatted string. /// /// The list of sources to convert. + /// Whether a link into a local file may name its page; see the method below. /// A markdown-formatted string representing the sources. - public static string ToMarkdown(this IList sources) + public static string ToMarkdown(this IList sources, bool keepPageAnchors = true) { var sb = new StringBuilder(); foreach (var group in sources.GroupSources()) @@ -152,8 +153,9 @@ public static partial class SourceExtensions foreach (var numberedSource in group.Sources) { + var url = keepPageAnchors ? numberedSource.Source.URL : WithoutPageAnchor(numberedSource.Source.URL); sb.Append($"- [{numberedSource.Number}] "); - AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.Source.URL); + AppendMarkdownLink(sb, numberedSource.Source.Title, url); sb.AppendLine(); } } @@ -161,6 +163,29 @@ public static partial class SourceExtensions return sb.ToString(); } + /// + /// Takes the page off a link into a local file, for a reader which cannot follow it. + /// + /// + /// Everything a local link carries in its fragment is dropped, not only a page: a chunk is no + /// use to any reader either, and what breaks such a link is the fragment itself rather than what + /// stands in it. A web address keeps its fragment untouched, because there the fragment is part + /// of the address and naming a section of a page is exactly what it is for. + /// + /// The link of the source. + /// The link without its fragment, or the link itself when it carries none. + private static string WithoutPageAnchor(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + var cleanedUrl = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty); + if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile || absoluteUri.Fragment.Length == 0) + return url; + + return absoluteUri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped); + } + /// /// Converts a list of sources to a markdown-formatted string, headed by a title of its own. /// @@ -171,10 +196,11 @@ public static partial class SourceExtensions /// for this and the chat does not. /// /// The list of sources to convert. + /// Whether a link into a local file may name its page. /// A markdown-formatted string representing the sources, or an empty string when there are none. - public static string ToExportMarkdown(this IList sources) + public static string ToExportMarkdown(this IList sources, bool keepPageAnchors = true) { - var sourcesMarkdown = sources.ToMarkdown(); + var sourcesMarkdown = sources.ToMarkdown(keepPageAnchors); if (string.IsNullOrWhiteSpace(sourcesMarkdown)) return string.Empty; diff --git a/app/Tests/Tools/FileExportFormatTests.cs b/app/Tests/Tools/FileExportFormatTests.cs new file mode 100644 index 00000000..46e8cb9f --- /dev/null +++ b/app/Tests/Tools/FileExportFormatTests.cs @@ -0,0 +1,39 @@ +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks what AI Studio assumes about the readers of the formats it writes. +/// +[TestFixture] +public sealed class FileExportFormatTests +{ + [Test] + public void OnlyTheTwoOfficeFormatsRefuseAPageInALocalLink() + { + Assert.Multiple(() => + { + Assert.That(FileExportFormat.MICROSOFT_WORD.FollowsPageAnchors(), Is.False, "Word looks for a file whose name ends in the fragment, finds none, and refuses the link."); + Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.FollowsPageAnchors(), Is.False, "LibreOffice does the same, verified on 2026-09-15 with an exported .odt."); + Assert.That(FileExportFormat.HTML.FollowsPageAnchors(), Is.True, "A browser opens the document on the page the fragment names."); + Assert.That(FileExportFormat.MARKDOWN.FollowsPageAnchors(), Is.True); + Assert.That(FileExportFormat.LATEX.FollowsPageAnchors(), Is.True); + }); + } + + [Test] + public void EveryFormatAnAnswerIsWrittenAsHasAnAnswerHere() + { + // Whoever adds a format decides what its reader can follow, rather than inheriting an + // assumption. This fails for a format which nobody thought about, because the list below + // has to name it: + Assert.That(FileExportFormatExtensions.ANSWER_FORMATS, Is.EquivalentTo(new[] + { + FileExportFormat.MICROSOFT_WORD, + FileExportFormat.OPEN_DOCUMENT_TEXT, + FileExportFormat.LATEX, + FileExportFormat.MARKDOWN, + FileExportFormat.HTML, + }), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here."); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/SourceExtensionsTests.cs b/app/Tests/Tools/SourceExtensionsTests.cs index 22cc1e29..b9bf402c 100644 --- a/app/Tests/Tools/SourceExtensionsTests.cs +++ b/app/Tests/Tools/SourceExtensionsTests.cs @@ -137,6 +137,36 @@ public sealed class SourceExtensionsTests 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 AReaderWhichCannotFollowAPageGetsTheDocumentWithoutOne() + { + IList sources = + [ + new("Handbook (Page 266)", "file:///Users/someone/My Documents/handbook.pdf#page=266", SourceOrigin.RAG), + new("An older answer", "file:///Users/someone/handbook.pdf#chunk=3", SourceOrigin.RAG), + new("A section of an article", "https://example.org/article#results", SourceOrigin.LLM), + ]; + + Assert.That(EntriesOf(sources.ToMarkdown(keepPageAnchors: false)), Is.EqualTo(new[] + { + "- [1] [A section of an article]()", + "- [2] [Handbook (Page 266)]()", + "- [3] [An older answer]()", + }), "Word and LibreOffice take the fragment of a local link for part of the file name and refuse the link, so the local links lose it -- and the web link keeps its own, where a fragment names a section of the page and belongs to the address."); + } + + [Test] + public void AReaderWhichFollowsAPageIsToldIt() + { + IList sources = [new("Handbook (Page 266)", "file:///Users/someone/handbook.pdf#page=266", SourceOrigin.RAG)]; + + Assert.Multiple(() => + { + Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "A browser and a PDF reader open the document where the passage is, so they are told the page."); + Assert.That(EntriesOf(sources.ToExportMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "The clipboard and every text format keep it as well; only the two office formats ask for it to be dropped."); + }); + } + [Test] public void AKnownPageRidesInTheLinkOfASource() {