diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 2c8c919ea5b4..46c45f3f3f44 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -2117,6 +2117,11 @@ Bloom had trouble saving a page. Please click Details below and report this to us. Then quit Bloom, run it again, and check to see if the page you just edited is missing anything. Sorry! ID: Errors.CouldNotSavePage + + You are running Bloom {0} + ID: Errors.CurrentRunningVersion + The placeholder {0} represents the current version of Bloom. For example, {0} might be replaced with "4.3" + This might be caused by Windows Defender "Controlled Folder Access" or some other virus protection. ID: Errors.DefenderFolderProtection @@ -2131,6 +2136,11 @@ Your computer denied Bloom access to the book. You may need technical help in setting the operating system permissions for this file. ID: Errors.DeniedAccess + + Upgrade to the latest Bloom (requires Internet connection) + ID: Errors.DownloadLatestVersion + This is a link that will take them to the Downloads (Installers) web page + There was a problem selecting the book. Restarting Bloom may fix the problem. If not, please click the 'Details' button and report the problem to the Bloom Developers. ID: Errors.ErrorSelecting @@ -2141,12 +2151,28 @@ ID: Errors.ErrorUpdating OLD TEXT (before 3.9, added spaces): There was a problem updating the book. Restarting Bloom may fix the problem. If not, please click the 'Details' button and report the problem to the Bloom Developers. + + This book requires Bloom {0} or greater because it uses the following features: + ID: Errors.FeatureRequiresNewerVersionPlural + The placeholder {0} represents the required version of Bloom. For example, {0} might be replaced with "4.4" + A bulleted list of features (in English) will follow underneath this text + + + This book requires Bloom {0} or greater because it uses the feature \"{1}\". + ID: Errors.FeatureRequiresNewerVersionSingular + The placeholder {0} represents the required version of Bloom. For example, {0} might be replaced with "4.4" + The placeholder {1} represents a short description of the feature For example, {1} might be replaced with "Whole Text Audio" + {0} requires a newer version of Bloom. Download the latest version of Bloom from {1} ID: Errors.NeedNewerVersion {0} will get the name of the book, {1} will give a link to open the Bloom Library Web page. OLD TEXT (before 3.9, removed period): {0} requires a newer version of Bloom. Download the latest version of Bloom from {1}. + + "This book needs a new version of Bloom. + ID: Errors.NewVersionNeededHeader + Bloom had a problem deleting this file: {0} ID: Errors.ProblemDeletingFile diff --git a/src/BloomExe/BloomExe.csproj b/src/BloomExe/BloomExe.csproj index e0db90fb0954..d5e0e05f0a07 100644 --- a/src/BloomExe/BloomExe.csproj +++ b/src/BloomExe/BloomExe.csproj @@ -299,6 +299,7 @@ + diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 2e57a8821fb7..3e858f0a7271 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; @@ -565,7 +565,7 @@ private HtmlDom GetBookDomWithStyleSheets(params string[] cssFileNames) private HtmlDom GetErrorDom(string extraMessages="") { var builder = new StringBuilder(); - builder.Append(""); + builder.Append(""); if(_storage != null) { @@ -578,14 +578,17 @@ private HtmlDom GetErrorDom(string extraMessages="") // often GetBrokenBookRecommendation and FatalErrorDescription both come from _storage.ErrorMessagesHtml. // Try not to say the same thing twice. - if (!builder.ToString().Contains(FatalErrorDescription)) + if (FatalErrorDescription != null && !builder.ToString().Contains(FatalErrorDescription)) builder.Append(FatalErrorDescription); builder.Append("

"+ WebUtility.HtmlEncode(extraMessages)+"

"); - var message = LocalizationManager.GetString("Errors.ReportThisProblemButton", "Report this problem to Bloom Support"); - builder.AppendFormat( - ""); + if (_storage.ErrorAllowsReporting) + { + var message = LocalizationManager.GetString("Errors.ReportThisProblemButton", "Report this problem to Bloom Support"); + builder.AppendFormat( + ""); + } builder.Append(""); @@ -719,6 +722,7 @@ public virtual HtmlDom GetPreviewHtmlFileForWholeBook() { return GetErrorDom(_storage.GetValidateErrors()); } + var previewDom= GetBookDomWithStyleSheets("previewMode.css", "origami.css"); AddCreationTypeAttribute(previewDom); diff --git a/src/BloomExe/Book/BookStorage.cs b/src/BloomExe/Book/BookStorage.cs index b01b24bc39d2..72b4e99fceda 100644 --- a/src/BloomExe/Book/BookStorage.cs +++ b/src/BloomExe/Book/BookStorage.cs @@ -53,6 +53,7 @@ public interface IBookStorage HtmlDom MakeDomRelocatable(HtmlDom dom); string SaveHtml(HtmlDom bookDom); void SetBookName(string name); + string GetHtmlMessageIfFeatureIncompatibility(); string GetValidateErrors(); void CheckBook(IProgress progress,string pathToFolderOfReplacementImages = null); void UpdateBookFileAndFolderName(CollectionSettings settings); @@ -65,6 +66,7 @@ public interface IBookStorage BookInfo BookInfo { get; set; } string NormalBaseForRelativepaths { get; } string InitialLoadErrors { get; } + bool ErrorAllowsReporting { get; } void UpdateSupportFiles(); void Update(string fileName, string factoryPath = ""); string Duplicate(); @@ -101,8 +103,10 @@ public class BookStorage : IBookStorage public event EventHandler FolderPathChanged; + // Returns any errors reported while loading the book (during 'expensive initialization'). public string InitialLoadErrors { get; private set; } + public bool ErrorAllowsReporting { get; private set; } // True if we want to display a Report to Bloom Support button public BookInfo BookInfo { @@ -217,9 +221,9 @@ public bool TryGetPremadeThumbnail(string fileName, out Image image) public HtmlDom Dom { get; private set; } - public static string GetHtmlMessageIfVersionIsIncompatibleWithThisBloom(HtmlDom dom,string path) + public static string GetHtmlMessageIfVersionIsIncompatibleWithThisBloom(HtmlDom dom, string path) { - var versionString = dom.GetMetaValue("BloomFormatVersion", "").Trim(); + var versionString = dom.GetMetaValue("BloomFormatVersion", "").Trim(); if (string.IsNullOrEmpty(versionString)) return "";// "This file lacks the following required element: "; @@ -230,15 +234,70 @@ public static string GetHtmlMessageIfVersionIsIncompatibleWithThisBloom(HtmlDom if (versionFloat > float.Parse(kBloomFormatVersion, CultureInfo.InvariantCulture)) { var msg = LocalizationManager.GetString("Errors.NeedNewerVersion", - "{0} requires a newer version of Bloom. Download the latest version of Bloom from {1}","{0} will get the name of the book, {1} will give a link to open the Bloom Library Web page."); + "{0} requires a newer version of Bloom. Download the latest version of Bloom from {1}", "{0} will get the name of the book, {1} will give a link to open the Bloom Library Web page."); msg = string.Format(msg, path, string.Format("BloomLibrary.org", UrlLookup.LookupUrl(UrlType.LibrarySite))); - msg += string.Format(". (Format {0} vs. {1})",versionString, kBloomFormatVersion); + msg += string.Format(". (Format {0} vs. {1})", versionString, kBloomFormatVersion); return msg; } return null; } + // Returns HTML with error message for any features that this book contains which cannot be opened by this version of Bloom. + // Note that although we don't allow the user to open the book (because if this version opens and saves the book, it will cause major problems for a later version of Bloom), + // here isn't actually any corruption or malformed data or anything particularly wrong with the book storage. So, we need to handle these kind of errors differently than validation errors. + public string GetHtmlMessageIfFeatureIncompatibility() + { + // Check if there are any features in this file format (which is readable), but which won't be supported (and have effects bad enough to warrant blocking opening) in this version. + string featureVersionRequirementJson = Dom.GetMetaValue("FeatureRequirement", ""); + if (String.IsNullOrEmpty(featureVersionRequirementJson)) + { + return ""; + } + VersionRequirement[] featureVersionRequirementList = (VersionRequirement[])JsonConvert.DeserializeObject(featureVersionRequirementJson, typeof(VersionRequirement[])); + + if (featureVersionRequirementList != null && featureVersionRequirementList.Length >= 1) + { + string currentBloomDesktopVersion = typeof(BookStorage).Assembly?.GetName()?.Version?.ToString() ?? "1.0"; + var breakingFeatureRequirements = featureVersionRequirementList.Where(x => VersionComparer.IsLessThanVersion(currentBloomDesktopVersion, x.BloomDesktopMinVersion)); + + // Note: even though versionRequirements is guaranated non-empty by now, the ones that actually break our current version of Bloom DESKTOP could be empty. + if (breakingFeatureRequirements.Count() >= 1) + { + string messageNewVersionNeededHeader = LocalizationManager.GetString("Errors.NewVersionNeededHeader", "This book needs a new version of Bloom."); + string messageCurrentRunningVersion = String.Format(LocalizationManager.GetString("Errors.CurrentRunningVersion", "You are running Bloom {0}"), currentBloomDesktopVersion); + string messageDownloadLatestVersion = LocalizationManager.GetString("Errors.DownloadLatestVersion", "Upgrade to the latest Bloom (requires Internet connection)"); + + string messageFeatureRequiresNewerVersion; + if (breakingFeatureRequirements.Count() == 1) + { + var requirement = breakingFeatureRequirements.First(); + messageFeatureRequiresNewerVersion = String.Format(LocalizationManager.GetString("Errors.FeatureRequiresNewerVersionSingular", "This book requires Bloom {0} or greater because it uses the feature \"{1}\"."), requirement.BloomDesktopMinVersion, requirement.FeaturePhrase) + "
"; + } + else + { + var sortedRequirements = breakingFeatureRequirements.OrderByDescending(x => x.BloomDesktopMinVersion, new VersionComparer()); + var highestVersionRequired = sortedRequirements.First().BloomDesktopMinVersion; + + messageFeatureRequiresNewerVersion = String.Format(LocalizationManager.GetString("Errors.FeatureRequiresNewerVersionPlural", "This book requires Bloom {0} or greater because it uses the following features:"), highestVersionRequired); + + string listItemsHtml = String.Join("", sortedRequirements.Select(x => $"
  • {x.FeaturePhrase}
  • ")); + messageFeatureRequiresNewerVersion += $"
      {listItemsHtml}
    "; + } + + string message = + $"{messageNewVersionNeededHeader}


    " + + $"{messageCurrentRunningVersion}. {messageFeatureRequiresNewerVersion}

    " + + $"{messageDownloadLatestVersion}"; // Enhance: is there a market-specific version of Bloom Library? If so, ideal to link to it somehow. + + return message; + } + } + + return ""; + } + + public bool GetLooksOk() { return RobustFile.Exists(PathToExistingHtml) && String.IsNullOrEmpty(ErrorMessagesHtml); @@ -259,6 +318,20 @@ public void Save() Dom.UpdateMetaElement("BloomFormatVersion", kBloomFormatVersion); } BookInfo.FormatVersion = kBloomFormatVersion; + + VersionRequirement[] requiredVersions = GetRequiredVersions(Dom); + if (requiredVersions != null && requiredVersions.Length >= 1) + { + string json = JsonConvert.SerializeObject(requiredVersions); + Dom.UpdateMetaElement("FeatureRequirement", json); + } + else + { + // TODO: Unit test for this if possible + // Might be necessary if you duplicated a book, or modified a book such that it no longer needs this + Dom.RemoveMetaElement("FeatureRequirement"); + } + var watch = Stopwatch.StartNew(); string tempPath = SaveHtml(Dom); watch.Stop(); @@ -294,6 +367,26 @@ public void Save() BookInfo.Save(); } + // Determines which features will have serious breaking effects if not opened in the proper version of any relevant Bloom products + // Note: This should include not only BloomDesktop considerations, but needs to insert enough information for things like BloomReader to be able to figure it out too + public static VersionRequirement[] GetRequiredVersions(HtmlDom dom) + { + var reqList = new List(); + + if (dom.DoesContainNarrationAudioRecordedUsingWholeTextBox()) + { + reqList.Add(new VersionRequirement() + { + FeatureId = "wholeTextBoxAudio", + FeaturePhrase = "Whole Text Box Audio", + BloomDesktopMinVersion = "4.4", + BloomReaderMinVersion = "1.0" + }); + } + + return reqList.OrderByDescending(x => x.BloomDesktopMinVersion, new VersionComparer()).ToArray(); + } + public const string BackupFilename = "bookhtml.bak"; // need to know this in BookCollection too. private string GetBackupFilePath() @@ -1014,6 +1107,7 @@ private void ExpensiveInitialization(bool forSelectedBook = false) else { ErrorMessagesHtml = WebUtility.HtmlEncode(error.Message); + ErrorAllowsReporting = true; Logger.WriteEvent("*** ERROR in " + PathToExistingHtml); Logger.WriteEvent("*** ERROR: " + error.Message.Replace("{", "{{").Replace("}", "}}")); return; @@ -1065,11 +1159,25 @@ private void ExpensiveInitialization(bool forSelectedBook = false) ErrorMessagesHtml = incompatibleVersionMessage; Logger.WriteEvent("*** ERROR: " + incompatibleVersionMessage); _errorAlreadyContainsInstructions = true; + ErrorAllowsReporting = true; return; } else { - Logger.WriteEvent("BookStorage Loading Dom from {0}", PathToExistingHtml); + var incompatibleFeatureMessage = GetHtmlMessageIfFeatureIncompatibility(); + if (!String.IsNullOrWhiteSpace(incompatibleFeatureMessage)) + { + ErrorMessagesHtml = incompatibleFeatureMessage; + Logger.WriteEvent("*** ERROR: " + incompatibleFeatureMessage); + _errorAlreadyContainsInstructions = true; + ErrorAllowsReporting = false; // This doesn't any corruption or bugs in the code, so no reporting button needed. + return; + } + + else + { + Logger.WriteEvent("BookStorage Loading Dom from {0}", PathToExistingHtml); + } } } @@ -1143,6 +1251,7 @@ private void ProcessAccessDeniedError(UnauthorizedAccessException error) message += "

    " + String.Format(seeAlso, "" + helpUrl + ""); ErrorMessagesHtml = message; _errorAlreadyContainsInstructions = true; + ErrorAllowsReporting = true; } /// @@ -1187,6 +1296,7 @@ public void UpdateSupportFiles() catch (Exception error) { ErrorMessagesHtml = WebUtility.HtmlEncode(error.Message); + ErrorAllowsReporting = true; } CopyBrandingFiles(); diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index f55c51a51a43..07df6d61f534 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1825,6 +1825,12 @@ public static XmlNodeList SelectAudioSentenceElementsWithRecordingMd5(XmlElement return element.SafeSelectNodes("descendant-or-self::node()[contains(@class,'audio-sentence') and @recordingmd5]"); } + public bool DoesContainNarrationAudioRecordedUsingWholeTextBox() + { + var nodes = _dom.SafeSelectNodes("//*[@data-audiorecordingmode='TextBox']"); + return nodes?.Count >= 1; + } + public static bool IsImgOrSomethingWithBackgroundImage(XmlElement element) { return element.SelectNodes("self::img | self::*[contains(@style,'background-image')]").Count == 1; diff --git a/src/BloomExe/Book/VersionRequirements.cs b/src/BloomExe/Book/VersionRequirements.cs new file mode 100644 index 000000000000..71b5ab6f8f32 --- /dev/null +++ b/src/BloomExe/Book/VersionRequirements.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace Bloom.Book +{ + public struct VersionRequirement + { + public string BloomDesktopMinVersion { get; set; } + public string BloomReaderMinVersion { get; set; } + public string FeatureId { get; set; } + public string FeaturePhrase { get; set; } + } + + public class VersionComparer : IComparer + { + int IComparer.Compare(T version1, T version2) + { + string version1Str = version1.ToString(); + string version2Str = version2.ToString(); + + return VersionComparer.CompareToVersion(version1Str, version2Str); + } + } + + public class VersionComparer + { + public static bool IsLessThanVersion(string version1, string version2) + { + if (version1 == null || version2 == null) + { + return false; + } + + return CompareToVersion(version1, version2) < 0; + } + + public static int CompareToVersion(string version1, string version2) + { + // I have no idea what CompareTo is supposed to return... it should return "false" for all the boolean comparisons you ask about a null, but that doesn't make sense in CompareTo land. + Debug.Assert(version1 != null, "version1 must not be null"); + Debug.Assert(version2 != null, "version2 must not be null"); + + if (version1 == version2) + { + // Equal + return 0; + } + + int nextIndexToProcess1 = 0; + int nextIndexToProcess2 = 0; + do + { + int? nextPart1 = ExtractNextPartOfVersionNumber(version1, ref nextIndexToProcess1); + int? nextPart2 = ExtractNextPartOfVersionNumber(version2, ref nextIndexToProcess2); + + // If they are null, I basically treat it the same as if they didn't exist. + if (nextPart1 == null) + { + if (nextPart2 == null) + { + return 0; + } + else + { + return -1; + } + } + else if (nextPart2 == null) + { + return 1; + } + else + { + if (nextPart1 < nextPart2) + { + return -1; + } + else if (nextPart1 > nextPart2) + { + return 1; + } + else + { + // They are equal... instead of returning 0, let's look at the next part + // (which we do by doing nothing right now, and just contining on to the next iteration.) + } + } + } while (nextIndexToProcess1 < version1.Length && nextIndexToProcess2 < version2.Length); + + if (nextIndexToProcess1 >= version1.Length) + { + if (nextIndexToProcess2 == version2.Length) + { + // They both ended at the same time without reporting any difference. + // Therefore they are completley requal. + return 0; + } + else + { + // Version 1 ended before Version 2. + // Consider this x.y vs. x.y.z which we could treat as x.y.0 vs. x.y.z and we should return "less than" + + // Check: if the rest of Version 2 is just "0" or "0.0" or so on... these are basically meaningless which would mean these strings are equal despite not being identical. + string remainder = version2.Substring(nextIndexToProcess2); + string significantDigits = remainder.Replace(".", "").Replace("0", ""); + if (significantDigits.Trim().Length == 0) + { + return 0; + } + + return -1; + } + } + else + { + // Version 1 has not ended, but Version 2 must've + // x.y.z vs x.y (x.y.0) + Debug.Assert(nextIndexToProcess2 >= version2.Length, "Code expects Version2 string to have ended but it has not."); + + string remainder = version1.Substring(nextIndexToProcess2); + string significantDigits = remainder.Replace(".", "").Replace("0", ""); + if (significantDigits.Trim().Length == 0) + { + return 0; + } + + return 1; + } + } + + // Extracts a version number of form x.y[.z][.w] etc. + internal static int? ExtractNextPartOfVersionNumber(string version, ref int index) + { + int limit1 = version.IndexOf('.', index); + string nextPart; + if (limit1 < 0) + { + nextPart = version.Substring(index); + index = version.Length; + } + else + { + nextPart = version.Substring(index, limit1 - index); + index = limit1 + 1; + } + + int nextPartInt; + if (int.TryParse(nextPart, out nextPartInt)) + { + return nextPartInt; + } + else + { + return null; + } + } + } +} diff --git a/src/BloomTests/BloomTests.csproj b/src/BloomTests/BloomTests.csproj index dfd8938dd8e0..a1692c28e1f3 100644 --- a/src/BloomTests/BloomTests.csproj +++ b/src/BloomTests/BloomTests.csproj @@ -174,6 +174,7 @@ + diff --git a/src/BloomTests/Book/BookStorageTests.cs b/src/BloomTests/Book/BookStorageTests.cs index 5dd7b76c8b6f..271b1d2faa8a 100644 --- a/src/BloomTests/Book/BookStorageTests.cs +++ b/src/BloomTests/Book/BookStorageTests.cs @@ -71,6 +71,17 @@ public void Save_BookHadEditStyleSheet_NowHasPreviewAndBase() AssertThatXmlIn.HtmlFile(_bookPath).HasSpecifiedNumberOfMatchesForXpath("//link[contains(@href, 'basePage')]", 1); AssertThatXmlIn.HtmlFile(_bookPath).HasSpecifiedNumberOfMatchesForXpath("//link[contains(@href, 'preview')]", 1); } + [Test] + public void Save_BookHadNarrationAudioRecordedByWholeTextBox_AddsFeatureRequirementMetadata() + { + // Enhance: need an example in the future to test the result if two are generated. But right now this is the only feature that generates it. + GetInitialStorageWithCustomHtml("
    "); + AssertThatXmlIn.HtmlFile(_bookPath).HasSpecifiedNumberOfMatchesForXpath("//meta[@name='FeatureRequirement']", 1); + + // Note: No need to HTML-encode the XPath. The comparison will automatically figure that out (I guess by decoding the encoding version) + string expectedContent = "[{\"BloomDesktopMinVersion\":\"4.4\",\"BloomReaderMinVersion\":\"1.0\",\"FeatureId\":\"wholeTextBoxAudio\",\"FeaturePhrase\":\"Whole Text Box Audio\"}]"; + AssertThatXmlIn.HtmlFile(_bookPath).HasSpecifiedNumberOfMatchesForXpath($"//meta[@content='{expectedContent}']", 1); + } [Test] public void CleanupUnusedVideoFiles_BookHadUnusedVideo_VideosRemoved() @@ -378,7 +389,27 @@ public void ValidateBook_ReportsInvalidHtml() var result = storage.ValidateBook(storage.PathToExistingHtml); Assert.IsTrue(result.StartsWith("Bloom-page element not found at root level: someOtherId"), "Bad Html should fail ValidateBook()."); } - + + [Test] + public void ValidateBook_ReportsNewerVersionRequired() + { + var storage = GetInitialStorageWithCustomHtml( +@" + + + + +
    +
    + + +" + ); + + storage.GetHtmlMessageIfFeatureIncompatibility(); + Assert.IsTrue(storage.ErrorMessagesHtml.Contains("or greater because it uses the feature"), "ErrorMessagesHtml"); + Assert.IsFalse(storage.ErrorAllowsReporting, "ErrorAllowsReporting"); + } [Test] public void Save_BookHasMissingImages_NoCrash() { diff --git a/src/BloomTests/Book/VersionRequirementTests.cs b/src/BloomTests/Book/VersionRequirementTests.cs new file mode 100644 index 000000000000..c4c161d22727 --- /dev/null +++ b/src/BloomTests/Book/VersionRequirementTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bloom.Book; +using NUnit.Framework; + + +namespace BloomTests.Book +{ + class VersionRequirementTests + { + [TestCase("1.3", "2.0", -1)] + [TestCase("4.3", "4.4", -1)] + [TestCase("1.9", "1.10", -1)] // Exercise a case that will cause a naive lexicographical test to fail + [TestCase("4.4.0.0", "4.4", 0)] // Note: The actual assembly version in the assembly is x.y.z.w, so it's important to get this right + [TestCase("1.0", "1.0.1", -1)] + [TestCase("1.2.3", "1.2.2.9", 1)] + public void CompareToVersion(string version1, string version2, int expectedResult) + { + int result = VersionComparer.CompareToVersion(version1, version2); + Assert.AreEqual(expectedResult, result); + + int commutativeResult = VersionComparer.CompareToVersion(version2, version1); + Assert.AreEqual(-expectedResult, commutativeResult, "Commutative test"); + } + } +}