Skip to content

Conversation

@josephmyers
Copy link
Collaborator

@josephmyers josephmyers commented Nov 13, 2025

The notes are fetched from the Paratext local copy, and the relevant pieces are parsed. The back end replaces the tag numbers with their user-displayed text (for readability).

The front end makes this call when the user clicks the Paratext Import option in the import dialog. It first gathers the available tags and displays them as a dropdown. Once a value is chosen, it filters the notes down to the selection, and feeds them to the same display used for the Transcelerator questions.


This change is Reviewable

@josephmyers josephmyers added the will require testing PR should not be merged until testers confirm testing is complete label Nov 13, 2025
@codecov
Copy link

codecov bot commented Nov 13, 2025

Codecov Report

❌ Patch coverage is 67.71300% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.69%. Comparing base (b78d54a) to head (0cbf870).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...stions-dialog/import-questions-dialog.component.ts 70.09% 26 Missing and 6 partials ⚠️
...IL.XForge.Scripture/Services/ParatextDataHelper.cs 70.49% 9 Missing and 9 partials ⚠️
...XForge.Scripture/Controllers/ParatextController.cs 28.57% 13 Missing and 2 partials ⚠️
...c/SIL.XForge.Scripture/Services/ParatextService.cs 66.66% 2 Missing and 3 partials ⚠️
...ripture/ClientApp/src/app/core/paratext.service.ts 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3570      +/-   ##
==========================================
- Coverage   82.78%   82.69%   -0.10%     
==========================================
  Files         610      611       +1     
  Lines       37239    37446     +207     
  Branches     6100     6141      +41     
==========================================
+ Hits        30830    30967     +137     
- Misses       5481     5535      +54     
- Partials      928      944      +16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@josephmyers
Copy link
Collaborator Author

One thing I intentionally haven't done here is to exclude resolved notes/questions, the reasons being that, if we do, there's no option for the user to bypass that and that, if they really don't want to see the item in the import list, they can just delete it in Paratext.

@Nateowami Nateowami changed the title SF-3578 Add Import Questions from Paratext SF-3578 Add Import Questions from Paratext notes Nov 25, 2025
@josephmyers josephmyers force-pushed the feature/SF-3578 branch 4 times, most recently from b5d8d6e to d8a97d9 Compare December 4, 2025 17:27
@josephmyers josephmyers marked this pull request as ready for review December 4, 2025 17:36
josephmyers and others added 13 commits December 8, 2025 14:37
The notes are fetched from the Paratext local copy, and the relevant pieces are parsed. The back end replaces the tag numbers with their user-displayed text (for readability).

The front end makes this call when the user clicks the Paratext Import option in the import dialog. It first gathers the available tags and displays them as a dropdown. Once a value is chosen, it filters the notes down to the selection, and feeds them to the same display used for the Transcelerator questions.

The brunt of this is done, but there are a number of edge cases to work through. And tests.
And fixed layout squashing on intermediate sizes

And hooked up Learn More to Transcelerator help page (until this feature gets its own)
Before, follow-on steps in the dialog were "cancel" buttons, which would just close the dialog. This wasn't great UX. Now, all the major states of the dialog should allow the user to get back to the initial state. I had to split out the filter state into two states, since the Back for the Paratext notes (the new step) has to go to the Tag screen, not the initial.
Front end tests still remaining
… use Where

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
… use Where

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Copy link
Collaborator

@pmachapman pmachapman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some preliminary feedback on the .NET side of things.

@pmachapman reviewed 11 of 18 files at r1, 2 of 10 files at r2.
@pmachapman dismissed @github-advanced-security[bot] from 2 discussions.
Reviewable status: 13 of 22 files reviewed, 15 unresolved discussions (waiting on @josephmyers)


src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 37 at r3 (raw file):

        @"\n\s*<",
        RegexOptions.CultureInvariant | RegexOptions.Compiled
    );

You can take advantage of .Net 8.0's Regular Expression source generator, which generates faster regex code under the hood than setting the compiled flag:

public static partial class NotesFormatter
{
    private const string NotesSchemaVersion = "1.1";

    /// <summary>
    /// The regular expression for finding whitespace before XML tags.
    /// </summary>
    /// <remarks>This is used by <see cref="ParseNotesToXElement"/>.</remarks>
    [GeneratedRegex(@"\n\s*<", RegexOptions.CultureInvariant)]
    private static partial Regex WhitespaceBeforeTagsRegex();

Code quote:

public static class NotesFormatter
{
    private const string NotesSchemaVersion = "1.1";

    /// <summary>
    /// The regular expression for finding whitespace before XML tags.
    /// </summary>
    /// <remarks>This is used by <see cref="ParseNotesToXElement"/>.</remarks>
    private static readonly Regex WhitespaceBeforeTagsRegex = new Regex(
        @"\n\s*<",
        RegexOptions.CultureInvariant | RegexOptions.Compiled
    );

src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 83 at r3 (raw file):

    /// <param name="notesElement">The notes XML element.</param>
    /// <param name="commentTags">The Paratext comment tag collection.</param>
    public static void ReplaceTagIdentifiersWithCommentTags(XElement notesElement, CommentTags commentTags)

This function is only used in a unit test - should it be retained?

Code quote:

public static void ReplaceTagIdentifiersWithCommentTags(XElement notesElement, CommentTags commentTags)

src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 209 at r3 (raw file):

        PropertyInfo[] properties = typeof(CommentTag).GetProperties(BindingFlags.Instance | BindingFlags.Public);
        foreach (PropertyInfo property in properties.Where(property => property.CanRead))

I'm not sure why you are iterating over every property in CommentTag? Did you need the properties marked with [XmlIgnore]? Although see also above the comment about whether we need ReplaceTagIdentifiersWithCommentTags.

Code quote:

        PropertyInfo[] properties = typeof(CommentTag).GetProperties(BindingFlags.Instance | BindingFlags.Public);
        foreach (PropertyInfo property in properties.Where(property => property.CanRead))

src/SIL.XForge.Scripture/Models/ParatextNote.cs line 40 at r3 (raw file):

    public string Icon { get; set; } = string.Empty;
}

Up to you, but it looks like your model could take advantage of required and init, which might help keep use of your objects in future neat and tidy?

using System.Collections.Generic;

namespace SIL.XForge.Scripture.Models;

/// <summary>
/// Represents a Paratext note thread containing one or more comments for a scripture reference.
/// </summary>
public class ParatextNote
{
    public required string Id { get; init; }

    public required string VerseRef { get; init; }

    public required IReadOnlyList<ParatextNoteComment> Comments { get; init; }
}

/// <summary>
/// Represents a single comment that belongs to a Paratext note thread.
/// </summary>
public class ParatextNoteComment
{
    public required string VerseRef { get; init; }

    public required string Content { get; init; }

    public ParatextNoteTag? Tag { get; init; }
}

/// <summary>
/// Represents a Paratext comment tag that has been applied to a comment.
/// </summary>
public class ParatextNoteTag
{
    public int Id { get; init; }

    public required string Name { get; init; }

    public required string Icon { get; init; }
}

Totally optional though - just an idea I had while I was readying your code.

You'd probably also need to update the last line of NotesService.CreateNoteTag() to:

return new ParatextNoteTag { Id = tagId, Name = string.Empty, Icon = string.Empty };

Code quote:

using System;
using System.Collections.Generic;

namespace SIL.XForge.Scripture.Models;

/// <summary>
/// Represents a Paratext note thread containing one or more comments for a scripture reference.
/// </summary>
public class ParatextNote
{
    public string Id { get; set; } = string.Empty;

    public string VerseRef { get; set; } = string.Empty;

    public IReadOnlyList<ParatextNoteComment> Comments { get; set; } = Array.Empty<ParatextNoteComment>();
}

/// <summary>
/// Represents a single comment that belongs to a Paratext note thread.
/// </summary>
public class ParatextNoteComment
{
    public string VerseRef { get; set; } = string.Empty;

    public string Content { get; set; } = string.Empty;

    public ParatextNoteTag? Tag { get; set; }
}

/// <summary>
/// Represents a Paratext comment tag that has been applied to a comment.
/// </summary>
public class ParatextNoteTag
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public string Icon { get; set; } = string.Empty;
}

src/SIL.XForge.Scripture/Services/INotesService.cs line 11 at r3 (raw file):

/// Provides helpers for retrieving and mapping Paratext note threads into Scripture Forge models.
/// </summary>
public interface INotesService

Normally we try and avoid exposing Paratext classes in interfaces, but I see it is unavoidable here.

You could perhaps move this method to IParatextDataHelper?

Code quote:

public interface INotesService

test/SIL.XForge.Scripture.Tests/Services/NotesFormatterTests.cs line 416 at r3 (raw file):

        // SUT
        NotesFormatter.ReplaceTagIdentifiersWithCommentTags(notesElement, commentTags);

I think this is the only place this method is used?

Code quote:

NotesFormatter.ReplaceTagIdentifiersWithCommentTags(notesElement, commentTags);

src/SIL.XForge.Scripture/Services/NotesService.cs line 14 at r3 (raw file):

/// Maps Paratext comment threads into lightweight Scripture Forge note models.
/// </summary>
public class NotesService : INotesService

I wonder whether the contents of this class should be moved to IParatextDataHelper, as it is a helper for the logic in ParatextData?

Alternatively I think naming this class to:

public class ParatextNotesService : IParatextNotesService

should help make clear what this class is doing.

Code quote:

public class NotesService : INotesService

src/SIL.XForge.Scripture/Services/NotesService.cs line 17 at r3 (raw file):

{
    public IReadOnlyList<ParatextNote> GetNotes(
        CommentManager commentManager,

Since you perform a null check, you might as well define this argument as nullable:

CommentManager? commentManager,

Code quote:

CommentManager commentManager,

src/SIL.XForge.Scripture/Services/NotesService.cs line 43 at r3 (raw file):

            if (comments.Count == 0)
                continue;

You can simplify this to:

List<ParatextNoteComment> comments =
[
    .. thread.ActiveComments.Select(comment => CreateNoteComment(comment, commentTags)),
];
if (comments.Count == 0)
    continue;

Code quote:

            IReadOnlyList<ParatextComment> activeComments = thread.ActiveComments.ToList();
            if (activeComments.Count == 0)
                continue;

            var comments = new List<ParatextNoteComment>();
            foreach (ParatextComment comment in activeComments)
            {
                comments.Add(CreateNoteComment(comment, commentTags));
            }

            if (comments.Count == 0)
                continue;

src/SIL.XForge.Scripture/Services/NotesService.cs line 53 at r3 (raw file):

                {
                    Id = thread.Id ?? string.Empty,
                    VerseRef = verseRef ?? string.Empty,

This value is never null, as you coalesce it to an empty string below in CreateNoteComment.

Code quote:

VerseRef = verseRef ?? string.Empty,

src/SIL.XForge.Scripture/Services/IParatextService.cs line 57 at r3 (raw file):

    );
    string GetNotes(UserSecret userSecret, string paratextId, int bookNum);
    IReadOnlyList<ParatextNote> GetNoteThreads(UserSecret userSecret, string paratextId);

You should change the signature to be nullable, as you return null in the implementation:

IReadOnlyList<ParatextNote>? GetNoteThreads(UserSecret userSecret, string paratextId)

Code quote:

IReadOnlyList<ParatextNote> GetNoteThreads(UserSecret userSecret, string paratextId);

src/SIL.XForge.Scripture/Services/IParatextService.cs line 59 at r3 (raw file):

    IReadOnlyList<ParatextNote> GetNoteThreads(UserSecret userSecret, string paratextId);
    SyncMetricInfo PutNotes(UserSecret userSecret, string paratextId, XElement notesElement);
    CommentTags? GetCommentTags(UserSecret userSecret, string paratextId);

We should avoid exposing PT classes in interfaces, where possible. See comment below about making this function internal and avoiding the need to expose it in this interface.

Code quote:

CommentTags? GetCommentTags(UserSecret userSecret, string paratextId);

src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs line 572 at r3 (raw file):

                LogMetric("Updating Paratext notes for questions");
                if (questionDocsByBook[text.BookNum].Count > 0)
                {

Why did you remove this? It will slow down syncs for projects without any questions (i.e. most projects)

Code quote:

                if (questionDocsByBook[text.BookNum].Count > 0)
                {

src/SIL.XForge.Scripture/Services/ParatextService.cs line 1275 at r3 (raw file):

    /// <summary> Gets note threads from the Paratext project and maps them to Scripture Forge models. </summary>
    public IReadOnlyList<ParatextNote> GetNoteThreads(UserSecret userSecret, string paratextId)

You should change the signature to be nullable, as you return null:

public IReadOnlyList<ParatextNote>? GetNoteThreads(UserSecret userSecret, string paratextId)

Code quote:

public IReadOnlyList<ParatextNote> GetNoteThreads(UserSecret userSecret, string paratextId)

src/SIL.XForge.Scripture/Services/ParatextService.cs line 1495 at r3 (raw file):

    }

    public CommentTags? GetCommentTags(UserSecret userSecret, string paratextId)

It seems this is public only for unit testing? You could make it internal instead.

Code quote:

public CommentTags? GetCommentTags(UserSecret userSecret, string paratextId)

@pmachapman pmachapman self-assigned this Dec 9, 2025
Copy link
Collaborator Author

@josephmyers josephmyers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: 3 of 25 files reviewed, 15 unresolved discussions (waiting on @pmachapman)


src/SIL.XForge.Scripture/Models/ParatextNote.cs line 40 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

Up to you, but it looks like your model could take advantage of required and init, which might help keep use of your objects in future neat and tidy?

using System.Collections.Generic;

namespace SIL.XForge.Scripture.Models;

/// <summary>
/// Represents a Paratext note thread containing one or more comments for a scripture reference.
/// </summary>
public class ParatextNote
{
    public required string Id { get; init; }

    public required string VerseRef { get; init; }

    public required IReadOnlyList<ParatextNoteComment> Comments { get; init; }
}

/// <summary>
/// Represents a single comment that belongs to a Paratext note thread.
/// </summary>
public class ParatextNoteComment
{
    public required string VerseRef { get; init; }

    public required string Content { get; init; }

    public ParatextNoteTag? Tag { get; init; }
}

/// <summary>
/// Represents a Paratext comment tag that has been applied to a comment.
/// </summary>
public class ParatextNoteTag
{
    public int Id { get; init; }

    public required string Name { get; init; }

    public required string Icon { get; init; }
}

Totally optional though - just an idea I had while I was readying your code.

You'd probably also need to update the last line of NotesService.CreateNoteTag() to:

return new ParatextNoteTag { Id = tagId, Name = string.Empty, Icon = string.Empty };

Done.


src/SIL.XForge.Scripture/Services/IParatextService.cs line 57 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

You should change the signature to be nullable, as you return null in the implementation:

IReadOnlyList<ParatextNote>? GetNoteThreads(UserSecret userSecret, string paratextId)

Done.


src/SIL.XForge.Scripture/Services/IParatextService.cs line 59 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

We should avoid exposing PT classes in interfaces, where possible. See comment below about making this function internal and avoiding the need to expose it in this interface.

Done.


src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 37 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

You can take advantage of .Net 8.0's Regular Expression source generator, which generates faster regex code under the hood than setting the compiled flag:

public static partial class NotesFormatter
{
    private const string NotesSchemaVersion = "1.1";

    /// <summary>
    /// The regular expression for finding whitespace before XML tags.
    /// </summary>
    /// <remarks>This is used by <see cref="ParseNotesToXElement"/>.</remarks>
    [GeneratedRegex(@"\n\s*<", RegexOptions.CultureInvariant)]
    private static partial Regex WhitespaceBeforeTagsRegex();

Done.


src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 83 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

This function is only used in a unit test - should it be retained?

Done. Nope!


src/SIL.XForge.Scripture/Services/NotesFormatter.cs line 209 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

I'm not sure why you are iterating over every property in CommentTag? Did you need the properties marked with [XmlIgnore]? Although see also above the comment about whether we need ReplaceTagIdentifiersWithCommentTags.

Done.


src/SIL.XForge.Scripture/Services/ParatextService.cs line 1275 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

You should change the signature to be nullable, as you return null:

public IReadOnlyList<ParatextNote>? GetNoteThreads(UserSecret userSecret, string paratextId)

Done.


src/SIL.XForge.Scripture/Services/ParatextService.cs line 1495 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

It seems this is public only for unit testing? You could make it internal instead.

Done. Love it


src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs line 572 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

Why did you remove this? It will slow down syncs for projects without any questions (i.e. most projects)

This is critical, otherwise the only books that we poll for questions are the ones that already have questions in SF. Maybe there's a better way to do this?


src/SIL.XForge.Scripture/Services/INotesService.cs line 11 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

Normally we try and avoid exposing Paratext classes in interfaces, but I see it is unavoidable here.

You could perhaps move this method to IParatextDataHelper?

Done.


src/SIL.XForge.Scripture/Services/NotesService.cs line 14 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

I wonder whether the contents of this class should be moved to IParatextDataHelper, as it is a helper for the logic in ParatextData?

Alternatively I think naming this class to:

public class ParatextNotesService : IParatextNotesService

should help make clear what this class is doing.

Done.


src/SIL.XForge.Scripture/Services/NotesService.cs line 17 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

Since you perform a null check, you might as well define this argument as nullable:

CommentManager? commentManager,

Done.


src/SIL.XForge.Scripture/Services/NotesService.cs line 43 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

You can simplify this to:

List<ParatextNoteComment> comments =
[
    .. thread.ActiveComments.Select(comment => CreateNoteComment(comment, commentTags)),
];
if (comments.Count == 0)
    continue;

Done.


src/SIL.XForge.Scripture/Services/NotesService.cs line 53 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

This value is never null, as you coalesce it to an empty string below in CreateNoteComment.

Done.


test/SIL.XForge.Scripture.Tests/Services/NotesFormatterTests.cs line 416 at r3 (raw file):

Previously, pmachapman (Peter Chapman) wrote…

I think this is the only place this method is used?

Done. I had implemented this earlier and then refactored.

@josephmyers
Copy link
Collaborator Author

@pmachapman I've split out the note id property and implemented your back end comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

will require testing PR should not be merged until testers confirm testing is complete

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants