mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 05:37:59 +00:00
Fixed local source links being refused by Word and LibreOffice
This commit is contained in:
6 files changed
+125
-7
No files matched your search
@@ -55,8 +55,11 @@ public static class IContentExtensions
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="content">The content to read.</param>
|
/// <param name="content">The content to read.</param>
|
||||||
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
|
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
|
||||||
|
/// <param name="keepPageAnchors">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.</param>
|
||||||
/// <returns>True, when this content carries Markdown text.</returns>
|
/// <returns>True, when this content carries Markdown text.</returns>
|
||||||
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)
|
if (content is not ContentText text)
|
||||||
{
|
{
|
||||||
@@ -65,7 +68,7 @@ public static class IContentExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
var answer = text.Text.Trim();
|
var answer = text.Text.Trim();
|
||||||
var sources = text.Sources.ToExportMarkdown();
|
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
|
||||||
if (sources.Length == 0)
|
if (sources.Length == 0)
|
||||||
{
|
{
|
||||||
markdown = answer;
|
markdown = answer;
|
||||||
|
|||||||
@@ -204,6 +204,26 @@ public static class FileExportFormatExtensions
|
|||||||
_ => WITHOUT_BYTE_ORDER_MARK,
|
_ => WITHOUT_BYTE_ORDER_MARK,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a link into a local file may name the page it points at.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="format">The format.</param>
|
||||||
|
/// <returns>True, when a reader of this format follows such a link.</returns>
|
||||||
|
public static bool FollowsPageAnchors(this FileExportFormat format) => format switch
|
||||||
|
{
|
||||||
|
FileExportFormat.MICROSOFT_WORD or FileExportFormat.OPEN_DOCUMENT_TEXT => false,
|
||||||
|
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the name Pandoc knows the format by.
|
/// Returns the name Pandoc knows the format by.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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);
|
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.")));
|
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported.")));
|
||||||
|
|||||||
@@ -138,8 +138,9 @@ public static partial class SourceExtensions
|
|||||||
/// Converts a list of sources to a markdown-formatted string.
|
/// Converts a list of sources to a markdown-formatted string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sources">The list of sources to convert.</param>
|
/// <param name="sources">The list of sources to convert.</param>
|
||||||
|
/// <param name="keepPageAnchors">Whether a link into a local file may name its page; see the method below.</param>
|
||||||
/// <returns>A markdown-formatted string representing the sources.</returns>
|
/// <returns>A markdown-formatted string representing the sources.</returns>
|
||||||
public static string ToMarkdown(this IList<Source> sources)
|
public static string ToMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var group in sources.GroupSources())
|
foreach (var group in sources.GroupSources())
|
||||||
@@ -152,8 +153,9 @@ public static partial class SourceExtensions
|
|||||||
|
|
||||||
foreach (var numberedSource in group.Sources)
|
foreach (var numberedSource in group.Sources)
|
||||||
{
|
{
|
||||||
|
var url = keepPageAnchors ? numberedSource.Source.URL : WithoutPageAnchor(numberedSource.Source.URL);
|
||||||
sb.Append($"- [{numberedSource.Number}] ");
|
sb.Append($"- [{numberedSource.Number}] ");
|
||||||
AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.Source.URL);
|
AppendMarkdownLink(sb, numberedSource.Source.Title, url);
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +163,29 @@ public static partial class SourceExtensions
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the page off a link into a local file, for a reader which cannot follow it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="url">The link of the source.</param>
|
||||||
|
/// <returns>The link without its fragment, or the link itself when it carries none.</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
|
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -171,10 +196,11 @@ public static partial class SourceExtensions
|
|||||||
/// for this and the chat does not.
|
/// for this and the chat does not.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sources">The list of sources to convert.</param>
|
/// <param name="sources">The list of sources to convert.</param>
|
||||||
|
/// <param name="keepPageAnchors">Whether a link into a local file may name its page.</param>
|
||||||
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
|
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
|
||||||
public static string ToExportMarkdown(this IList<Source> sources)
|
public static string ToExportMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
|
||||||
{
|
{
|
||||||
var sourcesMarkdown = sources.ToMarkdown();
|
var sourcesMarkdown = sources.ToMarkdown(keepPageAnchors);
|
||||||
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
|
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using AIStudio.Tools;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks what AI Studio assumes about the readers of the formats it writes.
|
||||||
|
/// </summary>
|
||||||
|
[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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
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<Source> 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](<https://example.org/article#results>)",
|
||||||
|
"- [2] [Handbook (Page 266)](<file:///Users/someone/My%20Documents/handbook.pdf>)",
|
||||||
|
"- [3] [An older answer](<file:///Users/someone/handbook.pdf>)",
|
||||||
|
}), "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<Source> 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]
|
[Test]
|
||||||
public void AKnownPageRidesInTheLinkOfASource()
|
public void AKnownPageRidesInTheLinkOfASource()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user