mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 02:33:38 +00:00
Fixed local source links being refused by Word and LibreOffice
This commit is contained in:
parent
91d0e742de
commit
baa48c80e1
@ -55,8 +55,11 @@ public static class IContentExtensions
|
||||
/// </remarks>
|
||||
/// <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="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>
|
||||
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;
|
||||
|
||||
@ -204,6 +204,26 @@ public static class FileExportFormatExtensions
|
||||
_ => 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>
|
||||
/// Returns the name Pandoc knows the format by.
|
||||
/// </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
|
||||
// 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.")));
|
||||
|
||||
@ -138,8 +138,9 @@ public static partial class SourceExtensions
|
||||
/// Converts a list of sources to a markdown-formatted string.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public static string ToMarkdown(this IList<Source> sources)
|
||||
public static string ToMarkdown(this IList<Source> 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();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
|
||||
/// </summary>
|
||||
@ -171,10 +196,11 @@ public static partial class SourceExtensions
|
||||
/// for this and the chat does not.
|
||||
/// </remarks>
|
||||
/// <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>
|
||||
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))
|
||||
return string.Empty;
|
||||
|
||||
|
||||
39
app/Tests/Tools/FileExportFormatTests.cs
Normal file
39
app/Tests/Tools/FileExportFormatTests.cs
Normal file
@ -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.");
|
||||
}
|
||||
|
||||
[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]
|
||||
public void AKnownPageRidesInTheLinkOfASource()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user