public static void SimulateRename(Bloom.TeamCollection.TeamCollection tc, string oldName, string newName) { var oldPath = Path.Combine(tc.LocalCollectionFolder, oldName); var newPath = Path.Combine(tc.LocalCollectionFolder, newName); RobustIO.MoveDirectory(oldPath, newPath); RobustFile.Move(Path.Combine(newPath, oldName + ".htm"), Path.Combine(newPath, newName + ".htm")); tc.HandleBookRename(oldName, newName); }
private static bool UpgradeToCurrentDataFormatVersion(ApplicationMetadata info) { if (info.DataVersion >= Settings.Default.DataFormatVersion) { return(false); } bool retVal = true; Analytics.Track("DataVersionUpgrade", new Dictionary <string, string> { { "old", info.DataVersion.ToString(CultureInfo.InvariantCulture) }, { "new", Settings.Default.DataFormatVersion.ToString(CultureInfo.InvariantCulture) } }); switch (info.DataVersion) { case 0: foreach (var publicationFolder in Project.AllPublicationFolders) { var filesToMove = Directory.GetFiles(publicationFolder); if (!filesToMove.Any()) { continue; } var projectFilePath = Directory.GetFiles(publicationFolder, "*" + kOldProjectExtension).FirstOrDefault(); if (projectFilePath != null) { Exception exception; var metadata = GlyssenDblTextMetadata.Load <GlyssenDblTextMetadata>(projectFilePath, out exception); string recordingProjectName; if (exception != null) { // Just add a directory layer and don't worry about it for now. recordingProjectName = Path.GetFileName(publicationFolder); } else { if (metadata.Identification != null && !string.IsNullOrEmpty(metadata.Identification.Name)) { recordingProjectName = metadata.Identification.Name; } else { recordingProjectName = metadata.Id; } } recordingProjectName = Project.GetDefaultRecordingProjectName(recordingProjectName); var recordingProjectFolder = Path.Combine(publicationFolder, recordingProjectName); Directory.CreateDirectory(recordingProjectFolder); foreach (var file in filesToMove) { File.Move(file, Path.Combine(recordingProjectFolder, Path.GetFileName(file))); } if (Settings.Default.CurrentProject == projectFilePath) { Settings.Default.CurrentProject = Path.Combine(recordingProjectFolder, Path.GetFileName(projectFilePath)); } } } goto case 1; case 1: foreach (var recordingProjectFolder in Project.AllRecordingProjectFolders.ToList()) { var versificationPath = Path.Combine(recordingProjectFolder, DblBundleFileUtils.kVersificationFileName); if (!File.Exists(versificationPath)) { var projectFilePath = Directory.GetFiles(recordingProjectFolder, "*" + kOldProjectExtension).FirstOrDefault(); if (projectFilePath != null) { if (projectFilePath.Equals(SampleProject.SampleProjectFilePath, StringComparison.OrdinalIgnoreCase)) { File.WriteAllText(versificationPath, Resources.EnglishVersification); } else { Exception exception; var metadata = GlyssenDblTextMetadata.Load <GlyssenDblTextMetadata>(projectFilePath, out exception); var origBundlePath = metadata.OriginalReleaseBundlePath; if (string.IsNullOrEmpty(origBundlePath)) { // Note: We didn't support Paratext-based projects until settings version 3 (Glyssen 1.1), // so for this step in the migration process (going from 0 to 1), any project without // OriginalReleaseBundlePath set is invalid (possibly from a really early version of Glyssen // or some g;itch arising from development activity or external mangling of the file). So // we should be able to safely blow this away. try { Project.DeleteProjectFolderAndEmptyContainingFolders(recordingProjectFolder, true); } catch (Exception) { // Oh, well, we tried. Not the end of the world. } } else { var bundle = new GlyssenBundle(origBundlePath); var errorlogPath = Path.Combine(recordingProjectFolder, "errorlog.txt"); bundle.CopyVersificationFile(versificationPath); try { ProjectBase.LoadVersification(versificationPath); } catch (InvalidVersificationLineException ex) { var msg = string.Format(LocalizationManager.GetString("DataMigration.InvalidVersificationFile", "Invalid versification file encountered during data migration. Errors must be fixed or subsequent " + "attempts to open this project will fail.\r\n" + "Project: {0}\r\n" + "Text release Bundle: {1}\r\n" + "Versification file: {2}\r\n" + "Error: {3}"), projectFilePath, origBundlePath, versificationPath, ex.Message); MessageBox.Show(msg, GlyssenInfo.kProduct, MessageBoxButtons.OK, MessageBoxIcon.Warning); File.WriteAllText(errorlogPath, msg); } } } } } } goto case 2; case 2: foreach (var pgProjFile in Project.AllRecordingProjectFolders.SelectMany(d => Directory.GetFiles(d, "*" + kOldProjectExtension))) { var newName = Path.ChangeExtension(pgProjFile, Constants.kProjectFileExtension); File.Move(pgProjFile, newName); if (Settings.Default.CurrentProject == pgProjFile) { Settings.Default.CurrentProject = newName; } } break; // No need to go to case 3, since the problem it fixes would only have been caused by a version of Glyssen with data version 3 case 3: try { RobustIO.DeleteDirectory(Path.GetDirectoryName(SampleProject.SampleProjectFilePath) + " Audio", true); } catch (IOException e) { Logger.WriteError("Unable to clean up superfluous sample Audio Audio folder.", e); } var safeReplacements = new List <Tuple <string, string> >(); var unsafeReplacements = new List <Tuple <string, string> >(); foreach (var folder in Project.AllRecordingProjectFolders.Where(d => d.EndsWith(" Audio Audio"))) { // Because of the way this bug (PG-1192) worked, the most likely thing is that the "correct" // version of the project will have been initially created but then all the actual work will // have gotten saved into the "incorrect" version. If this looks to be the case, we can // probably safely delete the correct one and then rename the incorrect one to have the correct // name. var baseFolder = Path.GetDirectoryName(folder); Debug.Assert(baseFolder != null); var languageFolder = Path.GetDirectoryName(baseFolder); Debug.Assert(languageFolder != null); var langCode = Path.GetFileName(languageFolder); Debug.Assert(langCode != null); var incorrectProjName = Path.GetFileName(folder); Debug.Assert(incorrectProjName != null); var correctProjectName = incorrectProjName.Substring(0, incorrectProjName.Length - " Audio".Length); var correctProjectFolder = Path.Combine(baseFolder, correctProjectName); if (Directory.Exists(correctProjectFolder)) { var glyssenProjFilename = langCode + Constants.kProjectFileExtension; var incorrectProjectFilePath = Path.Combine(folder, glyssenProjFilename); var correctProjectFilePath = Path.Combine(correctProjectFolder, glyssenProjFilename); var finfoIncorrectProject = new FileInfo(incorrectProjectFilePath); var finfoCorrectProject = new FileInfo(correctProjectFilePath); if (finfoCorrectProject.Exists && finfoIncorrectProject.Exists) { if (finfoCorrectProject.LastWriteTimeUtc < finfoIncorrectProject.LastWriteTimeUtc) { var books = Directory.GetFiles(correctProjectFolder, "???.xml").Where(b => Canon.IsBookIdValid(Path.GetFileNameWithoutExtension(b))) .Select(XmlSerializationHelper.DeserializeFromFile <BookScript>); foreach (var book in books) { // If book == null, there was a problem loading it. It may be locked or be in some incompatible format. // In any case, we shouldn't risk assuming we can safely replace it. if (book == null || book.GetScriptBlocks().Any(b => b.UserConfirmed || b.MatchesReferenceText)) { unsafeReplacements.Add(new Tuple <string, string>(folder, correctProjectFolder)); break; } } if (unsafeReplacements.LastOrDefault()?.Item1 == folder) { continue; } try { var projToBackUp = Project.Load(correctProjectFilePath); projToBackUp.CreateBackup("Overwritten by migration 3-4"); } catch (Exception e) { Logger.WriteError("Unable to load project and create backup", e); safeReplacements.Add(new Tuple <string, string>(folder, correctProjectFolder)); continue; } try { RobustIO.DeleteDirectory(correctProjectFolder, true); RobustIO.MoveDirectory(folder, correctProjectFolder); } catch (IOException e) { Logger.WriteError("Unable to replace project after making backup", e); Console.WriteLine(e); unsafeReplacements.Add(new Tuple <string, string>(folder, correctProjectFolder)); } } else { unsafeReplacements.Add(new Tuple <string, string>(folder, correctProjectFolder)); } } } } if (safeReplacements.Any()) { string fmt; if (safeReplacements.Count == 1) { fmt = LocalizationManager.GetString("DataMigration.ConfirmReplacementOfAudioAudio", "Doing this will replace the existing project by the same name, which was originally created by {0}. " + "Since none of the blocks in the project to be overwritten have any user decisions recorded, this seems " + "to be safe, but since {0} failed to make a backup, you need to confirm this. If you choose not to confirm " + "this action, you can either clean up the problem project yourself or verify that is is safe and then restart " + "{0}. You will be asked about this each time you start the program as long as this problem remains unresolved.\r\n" + "Confirm overwriting?", "Param: \"Glyssen\" (product name); " + "This follows the \"AudioAudioProblemPreambleSingle\"."); } else { fmt = LocalizationManager.GetString("DataMigration.ConfirmReplacementsOfAudioAudio", "Doing this will replace the existing projects by the same name, which were originally created by {0}. " + "Since none of the blocks in the projects to be overwritten have any user decisions recorded, this seems " + "to be safe, but since {0} failed to make a backup, you need to confirm this. If you choose not to confirm " + "this action, you can either clean up the problem projects yourself or verify that is is safe and then restart " + "{0}. You will be asked about this each time you start the program as long as these problems remains unresolved.\r\n" + "Confirm overwriting?", "Param: \"Glyssen\" (product name); " + "This follows the \"AudioAudioProblemPreambleMultiple\"."); } var msg = GetAudioAudioProblemPreamble(safeReplacements.Count) + String.Join(Environment.NewLine, safeReplacements.Select(r => r.Item1)) + Environment.NewLine + Environment.NewLine + String.Format(fmt, GlyssenInfo.kProduct); if (DialogResult.Yes == MessageBox.Show(msg, GlyssenInfo.kProduct, MessageBoxButtons.YesNo, MessageBoxIcon.Exclamation, MessageBoxDefaultButton.Button2)) { foreach (var replacement in safeReplacements) { RobustIO.DeleteDirectory(replacement.Item2, true); RobustIO.MoveDirectory(replacement.Item1, replacement.Item2); } safeReplacements.Clear(); } } if (unsafeReplacements.Any()) { string fmt; if (unsafeReplacements.Count == 1) { fmt = LocalizationManager.GetString("DataMigration.NoticeToManuallyFixAudioAudioProject", "However, doing this would replace the existing project by the same name. " + "Since {0} was unable to determine whether this was safe or otherwise failed to do the replacement, it is recommended " + "that you clean up the problem project yourself. You are encouraged to contact a local support person if needed or " + "seek help on {1}. You will be reminded about this each time you start the program as long as this problem remains unresolved.", "Param 0: \"Glyssen\" (product name); " + "Param 1: \"https://community.scripture.software.sil.org/\"" + "This follows the \"AudioAudioProblemPreambleSingle\"."); } else { fmt = LocalizationManager.GetString("DataMigration.NoticeToManuallyFixAudioAudioProjects", "However, doing this would replace the existing projects by the same name. " + "Since {0} was unable to determine whether this was safe or otherwise failed to do the replacements, it is recommended " + "that you clean up the problem projects yourself. You are encouraged to contact a local support person if needed or " + "seek help on {1}. You will be reminded about this each time you start the program as long as these problems remains unresolved.", "Param 0: \"Glyssen\" (product name); " + "Param 1: \"https://community.scripture.software.sil.org/\"" + "This follows the \"AudioAudioProblemPreambleMultiple\"."); } var msg = GetAudioAudioProblemPreamble(unsafeReplacements.Count) + String.Join(Environment.NewLine, unsafeReplacements.Select(r => r.Item1)) + Environment.NewLine + Environment.NewLine + String.Format(fmt, GlyssenInfo.kProduct, Constants.kSupportSite); MessageBox.Show(msg, GlyssenInfo.kProduct, MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } if (unsafeReplacements.Any() || safeReplacements.Any()) { retVal = false; } break; default: throw new Exception("No migration found from the existing data version!"); } if (retVal) { info.DataVersion = Settings.Default.DataFormatVersion; } return(retVal); }
public void OneTimeSetup() { _repoFolder = new TemporaryFolder("SyncAtStartup_Repo"); _collectionFolder = new TemporaryFolder("SyncAtStartup_Local"); FolderTeamCollection.CreateTeamCollectionLinkFile(_collectionFolder.FolderPath, _repoFolder.FolderPath); _mockTcManager = new Mock <ITeamCollectionManager>(); _tcLog = new TeamCollectionMessageLog(TeamCollectionManager.GetTcLogPathFromLcPath(_collectionFolder.FolderPath)); _collection = new FolderTeamCollection(_mockTcManager.Object, _collectionFolder.FolderPath, _repoFolder.FolderPath, tcLog: _tcLog); _collection.CollectionId = Bloom.TeamCollection.TeamCollection.GenerateCollectionId(); TeamCollectionManager.ForceCurrentUserForTests("*****@*****.**"); // Simulate a book that was once shared, but has been deleted from the repo folder (has a tombstone). MakeBook("Should be deleted", "This should be deleted as it has local status but is not shared", true); var bookFolderPath = Path.Combine(_collectionFolder.FolderPath, "Should be deleted"); _collection.DeleteBookFromRepo(bookFolderPath); // Simulate a book that was once shared, but has been deleted from the repo folder. But there is no tombstone. // (Despite the name, it is only converted to a new local in the default case. When we do a First Time Join, // it just gets copied into the repo.) MakeBook("Should be converted to new local", "This should become a new local (no status) book as it has local status but is not in the repo", true); var delPath = Path.Combine(_repoFolder.FolderPath, "Books", "Should be converted to new local.bloom"); RobustFile.Delete(delPath); // Simulate a book newly created locally. Not in repo, but should not be deleted. MakeBook("A book", "This should survive as it has no local status", false); // By the way, like most new books, it got renamed early in life...twice SimulateRename(_collection, "A book", "An early name"); SimulateRename(_collection, "An early name", "New book"); // Simulate a book that needs nothing done to it. It's the same locally and on the repo. MakeBook("Keep me", "This needs nothing done to it"); // Simulate a book that is checked out locally to the current user, but the file has // been deleted on the repo. MakeBook("Keep me too", "This also needs nothing done", false); _collection.WriteLocalStatus("Keep me too", new BookStatus().WithLockedBy("*****@*****.**")); // Simlulate a book that is only in the team repo MakeBook("Add me", "Fetch to local"); var delPathAddMe = Path.Combine(_collectionFolder.FolderPath, "Add me"); SIL.IO.RobustIO.DeleteDirectoryAndContents(delPathAddMe); // Simulate a book that was checked in, then checked out again and renamed, // but not yet checked in. Both "A renamed book" folder and content and "An old name.bloom" // should survive. (Except for an obscure reason when joining a TC...see comment in the test.) MakeBook("An old name", "Should be kept in both places with different names"); _collection.AttemptLock("An old name", "*****@*****.**"); SimulateRename(_collection, "An old name", "an intermediate name"); SimulateRename(_collection, "an intermediate name", "A renamed book"); // Simulate a book that is not checked out locally and has been modified elsewhere MakeBook("Update me", "Needs to be become this locally"); UpdateLocalBook("Update me", "This is supposed to be an older value, not edited locally"); // Simulate a book that is checked out locally but not in the repo, and where the saved local // checksum equals the repo checksum, and it is not checked out in the repo. This would // typically indicate that someone remote forced a checkout, perhaps while this user was // offline, but checked in again without making changes. // Also pretend it has been modified locally. // Test result: collection is updated to indicate the local checkout. Local changes are not lost. MakeBook("Check me out", "Local and remote checksums correspond to this"); UpdateLocalBook("Check me out", "This is supposed to be a newer value from local editing", false); var oldLocalStatus = _collection.GetLocalStatus("Check me out"); var newLocalStatus = oldLocalStatus.WithLockedBy(Bloom.TeamCollection.TeamCollectionManager.CurrentUser); _checkMeOutOriginalChecksum = oldLocalStatus.checksum; _collection.WriteLocalStatus("Check me out", newLocalStatus); // Simulate a book that appears newly-created locally (no local status) but is also in the // repo. This would indicate two people coincidentally creating a book with the same name. // Test result: the local book should get renamed (both folder and htm). // When merging while joining a new TC, this case is treated as a conflict and the // local book is moved to Lost and Found. MakeBook("Rename local", "This content is on the server"); _collection.AttemptLock("Rename local", "*****@*****.**"); UpdateLocalBook("Rename local", "This is a new book created independently"); var statusFilePath = Bloom.TeamCollection.TeamCollection.GetStatusFilePath("Rename local", _collectionFolder.FolderPath); RobustFile.Delete(statusFilePath); // Simulate a book that is checked out locally but also checked out, to a different user // or machine, on the repo. This would indicate some sort of manual intervention, perhaps // while this user was long offline. The book has not been modified locally, but the local // status is out of date. // Test result: local status is updated to reflect the remote checkout, book content updated to repo. MakeBook("Update and undo checkout", "This content is everywhere"); _collection.AttemptLock("Update and undo checkout", "*****@*****.**"); _collection.WriteLocalStatus("Update and undo checkout", _collection.GetStatus("Update and undo checkout").WithLockedBy(Bloom.TeamCollection.TeamCollectionManager.CurrentUser)); // Simulate a book that is checked out locally and not on the server, but the repo and (old) // local checksums are different. The book has not been edited locally. // Test result: book is updated to match repo. Local and remote status should match...review: which wins? MakeBook("Update and checkout", "This content is on the server"); UpdateLocalBook("Update and checkout", "This simulates older content changed remotely but not locally"); _collection.WriteLocalStatus("Update and checkout", _collection.GetLocalStatus("Update and checkout").WithLockedBy(Bloom.TeamCollection.TeamCollectionManager.CurrentUser)); // Simulate a book that is checked out and modified locally, but has also been modified // remotely. // Test result: current local state is saved in lost-and-found. Repo version of book and state // copied to local. Warning to user. MakeBook("Update content and status and warn", "This simulates new content on server"); _collection.AttemptLock("Update content and status and warn", "*****@*****.**"); UpdateLocalBook("Update content and status and warn", "This is supposed to be the newest value from local editing"); var newStatus = _collection.GetStatus("Update content and status and warn").WithLockedBy(Bloom.TeamCollection.TeamCollectionManager.CurrentUser) .WithChecksum("different from either"); _collection.WriteLocalStatus("Update content and status and warn", newStatus); // Simulate a book that is checked out and modified locally, but is also checked out by another // user or machine in the repo. It has not (yet) been modified remotely. // Test result: current local state is saved in lost-and-found. Repo version of book and state // copied to local. Warning to user. MakeBook("Update content and status and warn2", "This simulates new content on server"); _collection.AttemptLock("Update content and status and warn2", "*****@*****.**"); UpdateLocalBook("Update content and status and warn2", "This is supposed to be the newest value from local editing", false); newStatus = _collection.GetStatus("Update content and status and warn2").WithLockedBy(Bloom.TeamCollection.TeamCollectionManager.CurrentUser); _collection.WriteLocalStatus("Update content and status and warn2", newStatus); // Simulate a book which has no local status, but for which the computed checksum matches // the repo one. This could happen if a user obtained the same book independently, // or during initial merging of a local and team collection, where much of the material // was previously duplicated. // Test result: status is copied to local MakeBook("copy status", "Same content in both places"); _collection.AttemptLock("copy status", "*****@*****.**"); statusFilePath = Bloom.TeamCollection.TeamCollection.GetStatusFilePath("copy status", _collectionFolder.FolderPath); RobustFile.Delete(statusFilePath); // Simulate a book that was copied from another TC, using File Explorer. // It therefore has a book.status file, but with a different guid. // Test result: it should survive, and on a new collection sync get copied into the repo var copiedEx = "copied with Explorer"; MakeBook(copiedEx, "This content is only local", false); _collection.WriteLocalStatus(copiedEx, new BookStatus(), collectionId: Bloom.TeamCollection.TeamCollection.GenerateCollectionId()); // Simulate a book that appeared in DropBox when their software found a conflict. // It should NOT be copied locally, but instead moved to Lost and Found, with a report. MakeBook(kConflictName, "This content is only on the repo, apart from conflicting copies"); var conflictFolderPath = Path.Combine(_collectionFolder.FolderPath, kConflictName); SIL.IO.RobustIO.DeleteDirectoryAndContents(conflictFolderPath); _collection.WriteLocalStatus(copiedEx, new BookStatus(), collectionId: Bloom.TeamCollection.TeamCollection.GenerateCollectionId()); // Simulate a corrupt zip file, only in the repo File.WriteAllText(Path.Combine(_repoFolder.FolderPath, "Books", "new corrupt book.bloom"), "This is not a valid zip!"); // Simulate a corrupt zip file that corresponds to a local book. var badZip = "has a bad zip in repo"; MakeBook(badZip, "This book seems to be in both places, but the repo is corrupt"); File.WriteAllText(Path.Combine(_repoFolder.FolderPath, "Books", badZip + ".bloom"), "This is also not a valid zip!"); // Simulate a book that was renamed remotely. That is, there's a local book Old Name, with local status, // and there's no repo book by that name, but there's a repo book New Name (and no such local book). // The book's meta.json indicates they are the same book. // We'll initially make both, with the new name and new content. MakeBook(kNewNameForRemoteRename, "This is the new book content after remote editing and rename"); var oldFolder = Path.Combine(_collectionFolder.FolderPath, kBookRenamedRemotely); var newFolder = Path.Combine(_collectionFolder.FolderPath, kNewNameForRemoteRename); RobustIO.MoveDirectory(newFolder, oldFolder); // made at new path, simulate still at old. var oldPath = Path.Combine(_collectionFolder.FolderPath, kBookRenamedRemotely, kBookRenamedRemotely + ".htm"); // simulate old book name and content RobustFile.WriteAllText(oldPath, "This is the simulated original book content"); RobustFile.Delete(Path.Combine(_collectionFolder.FolderPath, kBookRenamedRemotely, kNewNameForRemoteRename + ".htm")); // get rid of the 'new' content // Simulate a book that is in the repo, where there is a local book that has no status, a different name, // and the same ID. This might indicate (a) that it was renamed by someone else after this user's pre-TC // copy of the collection diverged; (b) that it was renamed by this user after the divergence; // (c) that they were independently copied from some common-ID source. // We will treat this as a conflict, moving the local version to lost and found, even on a first time join. MakeBook(kRepoNameForIdConflict, "This is the repo version of a book that has a no-status local book with the same ID."); // Move the local version to a new folder var oldFolder2 = Path.Combine(_collectionFolder.FolderPath, kRepoNameForIdConflict); var newFolder2 = Path.Combine(_collectionFolder.FolderPath, kLocalNameForIdConflict); RobustIO.MoveDirectory(oldFolder2, newFolder2); var localStatusPath = Bloom.TeamCollection.TeamCollection.GetStatusFilePath(kLocalNameForIdConflict, _collectionFolder.FolderPath); RobustFile.Delete(localStatusPath); // Make a couple of folders that are legitimately present, but not books. var allowedWords = Path.Combine(_collectionFolder.FolderPath, "Allowed Words"); Directory.CreateDirectory(allowedWords); File.WriteAllText(Path.Combine(allowedWords, "some sample.txt"), "This a fake word list"); var sampleTexts = Path.Combine(_collectionFolder.FolderPath, "Sample Texts"); Directory.CreateDirectory(sampleTexts); File.WriteAllText(Path.Combine(sampleTexts, "a sample.txt"), "This a fake sample text"); _progressSpy = new ProgressSpy(); // sut for the whole suite! Assert.That(_collection.SyncAtStartup(_progressSpy, FirstTimeJoin()), Is.True); }