@@ -84,6 +84,14 @@ IRepository<UserSecret> userSecrets
8484 /// </remarks>
8585 internal const string BuildStateFinishing = "FINISHING" ;
8686
87+ /// <summary>
88+ /// The Completed build state.
89+ /// </summary>
90+ /// <remarks>
91+ /// Serval returns this state when the build is completed.
92+ /// </remarks>
93+ internal const string BuildStateCompleted = "COMPLETED" ;
94+
8795 private static readonly IEqualityComparer < IList < string > > _listStringComparer = SequenceEqualityComparer . Create (
8896 EqualityComparer < string > . Default
8997 ) ;
@@ -836,7 +844,17 @@ CancellationToken cancellationToken
836844 return buildDto ;
837845 }
838846
839- public async Task < IReadOnlyList < ServalBuildDto > > GetBuildsAsync (
847+ /// <summary>
848+ /// Gets the builds for the specified project.
849+ /// </summary>
850+ /// <param name="curUserId">The current user identifier.</param>
851+ /// <param name="sfProjectId">The Scripture Forge project identifier.</param>
852+ /// <param name="preTranslate">If <c>true</c>, return NMT builds only; otherwise, return SMT builds.</param>
853+ /// <param name="isServalAdmin">If <c>true</c>, the current user is a Serval Administrator.</param>
854+ /// <param name="cancellationToken">The cancellation token.</param>
855+ /// <returns>The builds.</returns>
856+ /// <remarks>This function is virtual to allow mocking in unit tests.</remarks>
857+ public virtual async Task < IReadOnlyList < ServalBuildDto > > GetBuildsAsync (
840858 string curUserId ,
841859 string sfProjectId ,
842860 bool preTranslate ,
@@ -1415,58 +1433,29 @@ public async Task<IReadOnlyList<DocumentRevision>> GetPreTranslationRevisionsAsy
14151433 CancellationToken cancellationToken
14161434 )
14171435 {
1418- // Set up the list of revisions to be returned
1419- List < DocumentRevision > revisions = [ ] ;
1420-
14211436 // Ensure that the user has permission
14221437 await EnsureProjectPermissionAsync ( curUserId , sfProjectId , isServalAdmin , cancellationToken ) ;
14231438
1424- await using IConnection connection = await realtimeService . ConnectAsync ( curUserId ) ;
1425- string id = TextDocument . GetDocId ( sfProjectId , bookNum , chapterNum , TextDocument . Draft ) ;
1426- Op [ ] ops = await connection . GetOpsAsync < TextDocument > ( id ) ;
1439+ IReadOnlyList < ServalBuildDto > builds = await GetBuildsAsync (
1440+ curUserId ,
1441+ sfProjectId ,
1442+ preTranslate : true ,
1443+ isServalAdmin ,
1444+ cancellationToken
1445+ ) ;
1446+ builds = FilterBuildsByBook ( builds , bookNum ) ;
14271447
1428- // If there are no ops, just get the most recent revision from Serval
1429- if ( ops . Length == 0 )
1430- {
1431- ServalBuildDto ? build = await GetLastCompletedPreTranslationBuildAsync (
1432- curUserId ,
1433- sfProjectId ,
1434- isServalAdmin ,
1435- cancellationToken
1436- ) ;
1437- if ( build is not null )
1438- {
1439- revisions . Add (
1440- new DocumentRevision
1441- {
1442- Source = OpSource . Draft ,
1443- Timestamp = build . AdditionalInfo ? . DateFinished ? . UtcDateTime ?? DateTime . UtcNow ,
1444- }
1445- ) ;
1446- }
1447- }
1448- else
1449- {
1450- // Draft Ops are not user created, so we do not need to milestone them,
1451- // like we do in ParatextService.GetRevisionHistoryAsync()
1452- foreach ( Op op in ops )
1453- {
1454- // Allow cancellation
1455- if ( cancellationToken . IsCancellationRequested )
1448+ // Set up the list of revisions to be returned
1449+ List < DocumentRevision > revisions =
1450+ [
1451+ .. builds
1452+ . Where ( b => b . AdditionalInfo ? . DateFinished is not null )
1453+ . Select ( build => new DocumentRevision
14561454 {
1457- break ;
1458- }
1459-
1460- revisions . Add (
1461- new DocumentRevision
1462- {
1463- Source = op . Metadata . Source ?? OpSource . Draft ,
1464- Timestamp = op . Metadata . Timestamp ,
1465- UserId = op . Metadata . UserId ,
1466- }
1467- ) ;
1468- }
1469- }
1455+ Source = OpSource . Draft ,
1456+ Timestamp = build . AdditionalInfo ? . DateFinished ? . UtcDateTime ?? DateTime . UtcNow ,
1457+ } ) ,
1458+ ] ;
14701459
14711460 // Display the revisions in descending order to match the history API endpoint
14721461 revisions . Reverse ( ) ;
@@ -1547,6 +1536,15 @@ CancellationToken cancellationToken
15471536 await using IConnection connection = await realtimeService . ConnectAsync ( userId ) ;
15481537 string id = TextDocument . GetDocId ( sfProjectId , bookNum , chapterNum , TextDocument . Draft ) ;
15491538
1539+ DateTime latestTimestampForRevision = await LatestTimestampForRevisionAsync (
1540+ curUserId ,
1541+ sfProjectId ,
1542+ bookNum ,
1543+ isServalAdmin ,
1544+ timestamp ,
1545+ cancellationToken
1546+ ) ;
1547+
15501548 // First, see if the document exists in the realtime service, if the chapter is not 0
15511549 IDocument < TextDocument > ? textDocument = null ;
15521550 if ( chapterNum != 0 && draftUsfmConfig is null )
@@ -1555,7 +1553,10 @@ CancellationToken cancellationToken
15551553 if ( textDocument . IsLoaded )
15561554 {
15571555 // Retrieve the snapshot if it exists
1558- Snapshot < TextDocument > snapshot = await connection . FetchSnapshotAsync < TextDocument > ( id , timestamp ) ;
1556+ Snapshot < TextDocument > snapshot = await connection . FetchSnapshotAsync < TextDocument > (
1557+ id ,
1558+ latestTimestampForRevision
1559+ ) ;
15591560 if ( snapshot . Data is not null )
15601561 {
15611562 return snapshot . Data ;
@@ -2263,6 +2264,72 @@ private static string GetTranslationEngineId(
22632264 return translationEngineId ;
22642265 }
22652266
2267+ /// <summary>
2268+ /// Retrieves the latest timestamp for the revision corresponding to the specified timestamp.
2269+ /// </summary>
2270+ /// <param name="curUserId">The current user identifier.</param>
2271+ /// <param name="sfProjectId">The Scripture Forge project identifier.</param>
2272+ /// <param name="bookNum">The book number.</param>
2273+ /// <param name="isServalAdmin">If <c>true</c>, the current user is a Serval Administrator.</param>
2274+ /// <param name="timestamp">The timestamp to retrieve the timestamp of the closest revision for.</param>
2275+ /// <param name="cancellationToken">The cancellation token.</param>
2276+ /// <returns>The timestamp of the draft that immediate follows the intended draft revision.</returns>
2277+ /// <remarks>This function is internal so it can be unit tests.</remarks>
2278+ internal async Task < DateTime > LatestTimestampForRevisionAsync (
2279+ string curUserId ,
2280+ string sfProjectId ,
2281+ int bookNum ,
2282+ bool isServalAdmin ,
2283+ DateTime timestamp ,
2284+ CancellationToken cancellationToken
2285+ )
2286+ {
2287+ IReadOnlyList < ServalBuildDto > builds = await GetBuildsAsync (
2288+ curUserId ,
2289+ sfProjectId ,
2290+ preTranslate : true ,
2291+ isServalAdmin ,
2292+ cancellationToken
2293+ ) ;
2294+ builds = FilterBuildsByBook ( builds , bookNum ) ;
2295+
2296+ // See if there is a build that was requested after the timestamp
2297+ DateTimeOffset ? time = builds
2298+ . FirstOrDefault ( b => b . AdditionalInfo ? . DateRequested ? . UtcDateTime > timestamp )
2299+ ? . AdditionalInfo ? . DateRequested ;
2300+
2301+ // If not, search for a build that comes before the timestamp and use the current time if the build exists
2302+ time ??= builds . LastOrDefault ( b => b . AdditionalInfo ? . DateRequested ? . UtcDateTime < timestamp ) is not null
2303+ ? DateTime . UtcNow
2304+ : null ;
2305+
2306+ // Return the latest time to access a draft, or the original timestamp is none is found
2307+ return time ? . UtcDateTime ?? timestamp ;
2308+ }
2309+
2310+ /// <summary>
2311+ /// Filters a list of builds to only those that contain the specified book number in their translation scripture ranges.
2312+ /// </summary>
2313+ /// <param name="builds">The builds.</param>
2314+ /// <param name="bookNum">The book number.</param>
2315+ /// <returns>The builds containing the specified book.</returns>
2316+ private static IReadOnlyList < ServalBuildDto > FilterBuildsByBook ( IReadOnlyList < ServalBuildDto > builds , int bookNum )
2317+ {
2318+ // As we are only parsing books, we do not need to set the versification
2319+ ScriptureRangeParser scriptureRangeParser = new ScriptureRangeParser ( ) ;
2320+ return
2321+ [
2322+ .. builds . Where ( b =>
2323+ b . State == BuildStateCompleted
2324+ && (
2325+ b . AdditionalInfo ? . TranslationScriptureRanges . Any ( r =>
2326+ scriptureRangeParser . GetChapters ( r . ScriptureRange ) . ContainsKey ( Canon . BookNumberToId ( bookNum ) )
2327+ ) ?? false
2328+ )
2329+ ) ,
2330+ ] ;
2331+ }
2332+
22662333 /// <summary>
22672334 /// This method maps Serval API exceptions to the exceptions that Machine.js understands.
22682335 /// </summary>
@@ -2412,9 +2479,9 @@ private static ServalEngineDto UpdateDto(ServalEngineDto engineDto, string sfPro
24122479 /// Ensures that the user has permission to access Serval and the project.
24132480 /// </summary>
24142481 /// <param name="curUserId">The current user identifier.</param>
2415- /// <param name="sfProjectId"></param>
2482+ /// <param name="sfProjectId">The Scripture Forge project identifier. </param>
24162483 /// <param name="isServalAdmin">If <c>true</c>, the current user is a Serval Administrator.</param>
2417- /// <param name="cancellationToken">The cancellatioon token.</param>
2484+ /// <param name="cancellationToken">The cancellation token.</param>
24182485 /// <returns>The project.</returns>
24192486 /// <exception cref="DataNotFoundException">The project does not exist.</exception>
24202487 /// <exception cref="ForbiddenException">
0 commit comments