diff --git a/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx b/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx index a193e51ac4b6..39d0b29889cf 100644 --- a/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx +++ b/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx @@ -4,7 +4,7 @@ import Grid from "@mui/material/Grid"; import * as React from "react"; import { useState, useEffect } from "react"; import "./BooksOfCollection.less"; -import { useApiData, useWatchApiData } from "../utils/bloomApi"; +import { post, useApiData, useWatchApiData } from "../utils/bloomApi"; import { BookButton, bookButtonHeight, @@ -86,6 +86,17 @@ export const BooksOfCollection: React.FunctionComponent<{ setReloadParameter(""); }, [unfilteredBooks, props.filter]); + // Notify the backend that the collection pane is ready to receive book label updates. + // Using a ref callback so this happens after the DOM is rendered, without needing useEffect. + const collectionPaneRef = React.useCallback( + (node: HTMLDivElement | null) => { + if (node && props.isEditableCollection && books.length > 0) { + post("collections/collectionPaneReady"); + } + }, + [props.isEditableCollection, books.length], + ); + //const selectedBookInfo = useMonitorBookSelection(); const collection: ICollection = useApiData( `collections/collectionProps?${collectionQuery}`, @@ -123,6 +134,7 @@ export const BooksOfCollection: React.FunctionComponent<{ key={"BookCollection-" + props.collectionId} className="bookButtonPane" style={{ cursor: "context-menu" }} + ref={collectionPaneRef} > {books.length > 0 && ( public virtual string NameBestForUserDisplay { - get - { - if (BookInfo.FileNameLocked) - { - // The user has explicitly chosen a name to use for the book, distinct from its titles. - return Path.GetFileName(FolderPath); - } - return TitleBestForUserDisplay; - } + get { return BookInfo.GetBestDisplayTitle(null, this); } } /// diff --git a/src/BloomExe/Book/BookInfo.cs b/src/BloomExe/Book/BookInfo.cs index acd908b170b9..92a1e518626a 100644 --- a/src/BloomExe/Book/BookInfo.cs +++ b/src/BloomExe/Book/BookInfo.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Windows.Forms; using Bloom.Api; +using Bloom.Collection; using Bloom.Edit; using Bloom.Utils; using L10NSharp; @@ -341,6 +342,8 @@ public string Copyright /// public string QuickTitleUserDisplay => FolderName; + public string ThumbnailLabel; + public bool TryGetPremadeThumbnail(out Image image) { string path = Path.Combine(FolderPath, "thumbnail.png"); @@ -851,9 +854,66 @@ internal string GetBestTitleForUserDisplay( List langCodes, bool ignoreFolderName = false ) + { + return GetBestDisplayTitle(null, null, langCodes, ignoreFolderName); + } + + internal string GetBestDisplayTitle( + CollectionSettings settings, // may be null + Book book, // may be null, but if we have it, we can use it to get the title without having to read the html again + List langCodes = null, + bool ignoreFolderName = false + ) { if (!ignoreFolderName && FileNameLocked) + { + // The user has explicitly chosen a name to use for the book, distinct from its titles. return FolderName; + } + if (IsInEditableCollection) + { + // If we have the book, we can use that for getting the title. + // If we have the settings but not the book, we can use the settings to get the title from the html. + if (!string.IsNullOrEmpty(FolderPath) && (book != null || settings != null)) + { + // Use the loaded book if we have it to get the best title. + if (FolderPath == book?.FolderPath && book.BookInfo.SaveContext == SaveContext) + { + if (langCodes == null) + langCodes = book.BookData.GetBasicBookLanguageCodes().ToList(); + return Book.GetBestTitleForDisplay( + book.BookData.GetMultiTextVariableOrEmpty("bookTitle"), + langCodes, + IsInEditableCollection + ); + } + else + { + // If we can create a BookData, we can get the title from there. + var htmlPath = BookStorage.FindBookHtmlInFolder(FolderPath); + if ( + !string.IsNullOrEmpty(htmlPath) + && RobustFile.Exists(htmlPath) + && settings != null + ) + { + var dom = HtmlDom.CreateFromHtmlFile(htmlPath); + var bookData = new BookData(dom, settings, null); + if (langCodes == null) + langCodes = bookData.GetBasicBookLanguageCodes().ToList(); + return Book.GetBestTitleForDisplay( + bookData.GetMultiTextVariableOrEmpty("bookTitle"), + langCodes, + IsInEditableCollection + ); + } + } + } + } + // If we still don't have a title, try to get one from the metadata. + // This code is used for all books that are not in the editable collection, and for any books + // in the editable collection that don't seem to have any title in the HTML. (The latter case + // may be hopeless, but this check isn't very expensive.) try { // JSON parsing requires newlines to be double quoted with backslashes inside string values. @@ -867,13 +927,20 @@ internal string GetBestTitleForUserDisplay( // behave as expected. foreach (var lang in langs.Where((l) => l != "item")) multiText[lang] = titles[lang].Trim(); - return Book.GetBestTitleForDisplay(multiText, langCodes, IsInEditableCollection); + if (langCodes == null) + langCodes = langs.Where((l) => l != "item").ToList(); + if (langCodes.Count > 0) + return Book.GetBestTitleForDisplay( + multiText, + langCodes, + IsInEditableCollection + ); } catch (Exception e) { Console.WriteLine(e); } - return Title; + return Title; // Title may be empty, but we have no better option at this point. } private static void SafelyAddToIdSet( diff --git a/src/BloomExe/Book/BookStorage.cs b/src/BloomExe/Book/BookStorage.cs index 238f88310af2..f828cf53c9c1 100644 --- a/src/BloomExe/Book/BookStorage.cs +++ b/src/BloomExe/Book/BookStorage.cs @@ -1844,9 +1844,9 @@ public void SetBookName(string name) Debug.Fail("(debug mode only): could not rename the folder"); } - RaiseBookRenamedEvent(fromToPair); - OnFolderPathChanged(); + + RaiseBookRenamedEvent(fromToPair); } // Move a file, possibly only changing the case of the name. @@ -1914,9 +1914,8 @@ public void RestoreBookName(string restoredName) string restoredPath = Path.Combine(Path.GetDirectoryName(FolderPath), restoredName); var fromToPair = new KeyValuePair(FolderPath, restoredPath); FolderPath = restoredPath; - RaiseBookRenamedEvent(fromToPair); - OnFolderPathChanged(); + RaiseBookRenamedEvent(fromToPair); } private void RaiseBookRenamedEvent(KeyValuePair fromToPair) diff --git a/src/BloomExe/Collection/BookCollection.cs b/src/BloomExe/Collection/BookCollection.cs index 04df2e97e48d..aba49a402f2a 100644 --- a/src/BloomExe/Collection/BookCollection.cs +++ b/src/BloomExe/Collection/BookCollection.cs @@ -24,7 +24,11 @@ public enum CollectionType SourceCollection, } - public delegate BookCollection Factory(string path, CollectionType collectionType); //autofac uses this + public delegate BookCollection Factory( + string path, + CollectionType collectionType, + CollectionSettings collectionSettings = null + ); //autofac uses this public EventHandler CollectionChanged; @@ -37,6 +41,8 @@ public enum CollectionType private static HashSet _changingFolders = new HashSet(); private BloomWebSocketServer _webSocketServer; + private CollectionSettings _collectionSettings; + public static event EventHandler CollectionCreated; //for moq only @@ -53,6 +59,7 @@ public BookCollection( CollectionType collectionType, BookSelection bookSelection, TeamCollectionManager tcm = null, + CollectionSettings collectionSettings = null, BloomWebSocketServer webSocketServer = null ) { @@ -60,6 +67,7 @@ public BookCollection( _bookSelection = bookSelection; _tcManager = tcm; _webSocketServer = webSocketServer; + _collectionSettings = collectionSettings; Type = collectionType; @@ -239,11 +247,13 @@ public void DeleteBook(Book.BookInfo bookInfo) /// public void HandleBookDeletedFromCollection(string folderPath) { - var infoToDelete = _bookInfos.FirstOrDefault(b => b.FolderPath == folderPath); - //Debug.Assert(_bookInfos.Contains(bookInfo)); this will occur if we delete a book from the BloomLibrary section - if (infoToDelete != null) // for paranoia. We shouldn't be trying to delete a book that isn't there. - _bookInfos.Remove(infoToDelete); - + lock (_bookInfoLock) + { + var infoToDelete = _bookInfos.FirstOrDefault(b => b.FolderPath == folderPath); + //Debug.Assert(_bookInfos.Contains(bookInfo)); this will occur if we delete a book from the BloomLibrary section + if (infoToDelete != null) // for paranoia. We shouldn't be trying to delete a book that isn't there. + _bookInfos.Remove(infoToDelete); + } if (CollectionChanged != null) CollectionChanged.Invoke(this, null); } @@ -325,28 +335,34 @@ public string PathToDirectory public void UpdateBookInfo(BookInfo info) { - var oldIndex = _bookInfos.FindIndex(i => i.Id == info.Id); - IComparer comp = new NaturalSortComparer(); - var newKey = Path.GetFileName(info.FolderPath); - if (oldIndex >= 0) + lock (_bookInfoLock) { - // optimize: very often the new one will belong at the same index, - // if that's the case we could just replace. - _bookInfos.RemoveAt(oldIndex); - } + var oldIndex = _bookInfos.FindIndex(i => i.Id == info.Id); + IComparer comp = new NaturalSortComparer(); + var newKey = Path.GetFileName(info.FolderPath); + if (oldIndex >= 0) + { + // optimize: very often the new one will belong at the same index, + // if that's the case we could just replace. + _bookInfos.RemoveAt(oldIndex); + } - int newIndex = _bookInfos.FindIndex(x => - comp.Compare(newKey, Path.GetFileName(x.FolderPath)) <= 0 - ); - if (newIndex < 0) - newIndex = _bookInfos.Count; - _bookInfos.Insert(newIndex, info); + int newIndex = _bookInfos.FindIndex(x => + comp.Compare(newKey, Path.GetFileName(x.FolderPath)) <= 0 + ); + if (newIndex < 0) + newIndex = _bookInfos.Count; + _bookInfos.Insert(newIndex, info); + } NotifyCollectionChanged(); } public void AddBookInfo(BookInfo bookInfo) { - _bookInfos.Add(bookInfo); + lock (_bookInfoLock) + { + _bookInfos.Add(bookInfo); + } NotifyCollectionChanged(); } @@ -356,22 +372,25 @@ public void AddBookInfo(BookInfo bookInfo) /// public void InsertBookInfo(BookInfo bookInfo) { - IComparer comparer = new NaturalSortComparer(); - for (int i = 0; i < _bookInfos.Count; i++) + lock (_bookInfoLock) { - var compare = comparer.Compare(_bookInfos[i].FolderPath, bookInfo.FolderPath); - if (compare == 0) - { - _bookInfos[i] = bookInfo; // Replace - return; - } - if (compare > 0) + IComparer comparer = new NaturalSortComparer(); + for (int i = 0; i < _bookInfos.Count; i++) { - _bookInfos.Insert(i, bookInfo); - return; + var compare = comparer.Compare(_bookInfos[i].FolderPath, bookInfo.FolderPath); + if (compare == 0) + { + _bookInfos[i] = bookInfo; // Replace + return; + } + if (compare > 0) + { + _bookInfos.Insert(i, bookInfo); + return; + } } + _bookInfos.Add(bookInfo); } - _bookInfos.Add(bookInfo); } private bool BackupFileExists(string folderPath) @@ -412,7 +431,10 @@ private void AddBookInfo(string folderPath) ) ? _bookSelection.CurrentSelection.BookInfo : new BookInfo(folderPath, editable, sc); - + bookInfo.ThumbnailLabel = bookInfo.GetBestDisplayTitle( + _collectionSettings, + _bookSelection.CurrentSelection + ); _bookInfos.Add(bookInfo); } catch (Exception e) @@ -500,7 +522,10 @@ private void WatcherOnChange(object sender, FileSystemEventArgs fileSystemEventA { if (_watcherIsDisabled) return; - _bookInfos = null; // Possibly obsolete; next request will update it. + lock (_bookInfoLock) + { + _bookInfos = null; // Possibly obsolete; next request will update it. + } DebounceFolderChanged(fileSystemEventArgs.FullPath); } diff --git a/src/BloomExe/CollectionTab/CollectionModel.cs b/src/BloomExe/CollectionTab/CollectionModel.cs index 2d2b0f1dd724..a925357ac2ae 100644 --- a/src/BloomExe/CollectionTab/CollectionModel.cs +++ b/src/BloomExe/CollectionTab/CollectionModel.cs @@ -360,7 +360,8 @@ private IEnumerable GetBookCollectionsOnce() { editableCollection = _bookCollectionFactory( _pathToCollection, - BookCollection.CollectionType.TheOneEditableCollection + BookCollection.CollectionType.TheOneEditableCollection, + _collectionSettings ); if (_bookCollectionHolder != null) _bookCollectionHolder.TheOneEditableCollection = editableCollection; diff --git a/src/BloomExe/CollectionTab/CollectionTabView.cs b/src/BloomExe/CollectionTab/CollectionTabView.cs index 9b38aa63d196..ae6a6e504e2c 100644 --- a/src/BloomExe/CollectionTab/CollectionTabView.cs +++ b/src/BloomExe/CollectionTab/CollectionTabView.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -11,6 +12,7 @@ using Bloom.ToPalaso; using Bloom.Workspace; using L10NSharp; +using Newtonsoft.Json; using SIL.Reporting; namespace Bloom.CollectionTab @@ -24,6 +26,7 @@ public class CollectionTabView : IBloomTabArea, IDisposable private TeamCollectionManager _tcManager; private bool _isDisposed; private bool _bookChangesPending = false; // bookchanged event while tab not visible + private Book.Book _bookForPendingLabelUpdate = null; // book needing label update when UI ready internal WorkspaceView WorkspaceView { get; set; } @@ -124,8 +127,37 @@ public BookInfo GetBookInfoByFolderPath(string path) private void UpdateForBookChanges(Book.Book book) { + if (book.BookData.GetVariableOrNull("bookTitle", book.Language1Tag) == null) + { + var saveNeeded = false; + if (!string.IsNullOrEmpty(book.BookInfo.Title)) + { + saveNeeded = true; + } + book.BookInfo.Title = ""; + if (!String.IsNullOrEmpty(book.BookInfo.AllTitles)) + { + var titleDict = JsonConvert.DeserializeObject>( + book.BookInfo.AllTitles + ); + if (titleDict != null && titleDict.ContainsKey(book.Language1Tag)) + { + titleDict.Remove(book.Language1Tag); + saveNeeded = true; + book.BookInfo.AllTitles = JsonConvert.SerializeObject(titleDict); + } + } + book.BookInfo.ThumbnailLabel = book.BookInfo.GetBestDisplayTitle( + book.CollectionSettings, + book + ); + if (saveNeeded) + book.BookInfo.Save(); // if we don't save here, the old title stays in the meta.json file. + } _model.UpdateThumbnailAsync(book); - _model.UpdateLabelOfBookInEditableCollection(book); + // Queue the label update to be sent when the collection pane signals it's ready. + _bookForPendingLabelUpdate = book; + // This message causes the preview to update. _webSocketServer.SendEvent("bookContent", "reload"); _bookChangesPending = false; @@ -312,6 +344,20 @@ internal void UpdateBloomLibraryStatus(string bookId) ); } + /// + /// Called by the frontend when the collection pane has finished loading + /// and is ready to receive book label updates. This will realphabetize + /// a renamed book. + /// + internal void ProcessPendingBookLabelUpdate() + { + if (_bookForPendingLabelUpdate != null) + { + _model.UpdateLabelOfBookInEditableCollection(_bookForPendingLabelUpdate); + _bookForPendingLabelUpdate = null; + } + } + public void Dispose() { if (_isDisposed) diff --git a/src/BloomExe/web/controllers/CollectionApi.cs b/src/BloomExe/web/controllers/CollectionApi.cs index a8e0d688aee3..74bdc40839da 100644 --- a/src/BloomExe/web/controllers/CollectionApi.cs +++ b/src/BloomExe/web/controllers/CollectionApi.cs @@ -1,12 +1,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Drawing; using System.Drawing.Imaging; using System.Dynamic; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using Bloom.Api; @@ -21,7 +19,6 @@ using Bloom.ToPalaso; using Bloom.Utils; using Bloom.WebLibraryIntegration; -using Bloom.Workspace; using L10NSharp; using Newtonsoft.Json; using SIL.IO; @@ -38,6 +35,7 @@ public class CollectionApi private BookThumbNailer _thumbNailer; private BloomWebSocketServer _webSocketServer; private readonly EditBookCommand _editBookCommand; + private readonly CollectionTabView _collectionTabView; private Timer _clickTimer = new Timer(); private int _thumbnailEventsToWaitFor = -1; @@ -50,7 +48,8 @@ public CollectionApi( BookSelection bookSelection, EditBookCommand editBookCommand, BookThumbNailer thumbNailer, - BloomWebSocketServer webSocketServer + BloomWebSocketServer webSocketServer, + CollectionTabView collectionTabView ) { _settings = settings; @@ -59,6 +58,7 @@ BloomWebSocketServer webSocketServer _editBookCommand = editBookCommand; _thumbNailer = thumbNailer; _webSocketServer = webSocketServer; + _collectionTabView = collectionTabView; _clickTimer.Interval = SystemInformation.DoubleClickTime; _clickTimer.Tick += _clickTimer_Tick; } @@ -131,6 +131,16 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) true ); + apiHandler.RegisterEndpointHandler( + kApiUrlPart + "collectionPaneReady", + request => + { + _collectionTabView.ProcessPendingBookLabelUpdate(); + request.PostSucceeded(); + }, + true // interacts with data stored on the UI thread + ); + // Note: the get part of this doesn't need to run on the UI thread, or even requiresSync. If it gets called a lot, consider // using different patterns for get and set so we can not use the uI thread for get. apiHandler.RegisterEndpointHandler( @@ -619,6 +629,13 @@ public void HandleBooksRequest(ApiRequest request) ignoreFolderName: true ); } + else if ( + String.IsNullOrEmpty(info.Title) + && !String.IsNullOrEmpty(info.ThumbnailLabel) + ) + { + title = info.ThumbnailLabel; + } return new { id = info.Id, diff --git a/src/BloomTests/TestDoubles/CollectionTab/FakeCollectionModel.cs b/src/BloomTests/TestDoubles/CollectionTab/FakeCollectionModel.cs index 5bec1db58813..f5a9d16e4b97 100644 --- a/src/BloomTests/TestDoubles/CollectionTab/FakeCollectionModel.cs +++ b/src/BloomTests/TestDoubles/CollectionTab/FakeCollectionModel.cs @@ -55,7 +55,8 @@ protected static SourceCollectionsList GetDefaultSourceCollectionsList() public static BookCollection BookCollectionFactory( string path, - BookCollection.CollectionType collectionType + BookCollection.CollectionType collectionType, + CollectionSettings settings = null ) { return new BookCollection(path, collectionType, null);