From 54b5fc448af16e389517df38b80e48336f992376 Mon Sep 17 00:00:00 2001 From: tombogle Date: Fri, 12 Dec 2025 12:20:57 -0500 Subject: [PATCH 1/8] Added information about projects and events to track creating and opening them. --- src/SayMore/Model/Project.cs | 45 ++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/SayMore/Model/Project.cs b/src/SayMore/Model/Project.cs index 5d71ed92..374691a2 100644 --- a/src/SayMore/Model/Project.cs +++ b/src/SayMore/Model/Project.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Drawing; +using System.IO; using System.IO.Compression; using System.Linq; using System.Text; @@ -13,20 +13,21 @@ using System.Xml.Serialization; using DesktopAnalytics; using L10NSharp; -using SIL.Extensions; -using SIL.Reporting; -using SIL.Windows.Forms; +using SayMore.Model.Files; +using SayMore.Properties; +using SayMore.Transcription.Model; using SayMore.UI.ComponentEditors; using SayMore.UI.Overview; +using SayMore.Utilities; using SIL.Archiving; using SIL.Archiving.Generic; using SIL.Archiving.IMDI; -using SayMore.Properties; -using SayMore.Transcription.Model; -using SayMore.Model.Files; -using SayMore.Utilities; +using SIL.Archiving.IMDI.Schema; using SIL.Core.ClearShare; +using SIL.Extensions; using SIL.IO; +using SIL.Reporting; +using SIL.Windows.Forms; using static System.IO.Path; namespace SayMore.Model @@ -105,30 +106,40 @@ public Project(string desiredOrExistingSettingsFilePath, throw new ArgumentException("Invalid project path specified", nameof(desiredOrExistingSettingsFilePath)); var saveNeeded = false; + var projectInfo = new Dictionary {{"projectName", Name}}; + if (File.Exists(desiredOrExistingSettingsFilePath)) { RenameEventsToSessions(projectDirectory); Load(); + projectInfo["vernacularISO3CodeAndName"] = VernacularISO3CodeAndName; + projectInfo["analysisISO3CodeAndName"] = AnalysisISO3CodeAndName; + projectInfo["projectLocation"] = Location; + projectInfo["projectRegion"] = Region; + projectInfo["projectCountry"] = Country; + projectInfo["projectContinent"] = Continent; } else { + Analytics.Track("Project Created", projectInfo); Directory.CreateDirectory(projectDirectory); Title = Name; saveNeeded = true; } - if (TranscriptionFont == null) - TranscriptionFont = Program.DialogFont; + TranscriptionFont ??= Program.DialogFont; + projectInfo["transcriptionFont"] = TranscriptionFont.Name; + FreeTranslationFont ??= Program.DialogFont; + projectInfo["freeTranslationFont"] = FreeTranslationFont.Name; - if (FreeTranslationFont == null) - FreeTranslationFont = Program.DialogFont; + Analytics.Track("Project Opened", projectInfo); if (AutoSegmenterMinimumSegmentLengthInMilliseconds < Settings.Default.MinimumSegmentLengthInMilliseconds || - AutoSegmenterMaximumSegmentLengthInMilliseconds <= 0 || - AutoSegmenterMinimumSegmentLengthInMilliseconds >= AutoSegmenterMaximumSegmentLengthInMilliseconds || - AutoSegmenterPreferredPauseLengthInMilliseconds <= 0 || - AutoSegmenterPreferredPauseLengthInMilliseconds > AutoSegmenterMaximumSegmentLengthInMilliseconds || - AutoSegmenterOptimumLengthClampingFactor <= 0) + AutoSegmenterMaximumSegmentLengthInMilliseconds <= 0 || + AutoSegmenterMinimumSegmentLengthInMilliseconds >= AutoSegmenterMaximumSegmentLengthInMilliseconds || + AutoSegmenterPreferredPauseLengthInMilliseconds <= 0 || + AutoSegmenterPreferredPauseLengthInMilliseconds > AutoSegmenterMaximumSegmentLengthInMilliseconds || + AutoSegmenterOptimumLengthClampingFactor <= 0) { saveNeeded = AutoSegmenterMinimumSegmentLengthInMilliseconds != 0 || AutoSegmenterMaximumSegmentLengthInMilliseconds != 0 || AutoSegmenterPreferredPauseLengthInMilliseconds != 0 || !AutoSegmenterOptimumLengthClampingFactor.Equals(0) || saveNeeded; From 393b97bac4d346f3c86fe28789be9c8cc6d33851 Mon Sep 17 00:00:00 2001 From: tombogle Date: Tue, 6 Jan 2026 09:02:02 -0500 Subject: [PATCH 2/8] Added an event to note details of significant progress. Included some minor refactoring and code cleanup --- SayMore.sln.DotSettings | 2 + .../DataGathering/BackgroundFileProcessor.cs | 67 ++++-------- src/SayMore/Model/Project.cs | 100 +++++++++++++++++- src/SayMore/Model/SessionWorkflowInformant.cs | 7 +- src/SayMore/ProjectContext.cs | 30 ++---- src/SayMore/UI/Charts/ChartBarInfo.cs | 2 +- src/SayMore/UI/Charts/HTMLChartBuilder.cs | 17 +-- .../UI/Overview/Statistics/StatisticsView.cs | 5 +- .../Statistics/StatisticsViewModel.cs | 59 ++++------- 9 files changed, 170 insertions(+), 119 deletions(-) diff --git a/SayMore.sln.DotSettings b/SayMore.sln.DotSettings index 95865b2c..2b0d594d 100644 --- a/SayMore.sln.DotSettings +++ b/SayMore.sln.DotSettings @@ -1,6 +1,8 @@  + HTML IE IMDI + ISO MRU OK RAMP diff --git a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs index 8010fe6d..6793371e 100644 --- a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs +++ b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs @@ -11,14 +11,6 @@ namespace SayMore.Model.Files.DataGathering { - /// ---------------------------------------------------------------------------------------- - public interface ISingleListDataGatherer - { - event EventHandler NewDataAvailable; - event EventHandler FinishedProcessingAllFiles; - IEnumerable GetValues(); - } - /// ---------------------------------------------------------------------------------------- /// /// Gives lists of data, indexed by a key into a dictionary @@ -27,7 +19,6 @@ public interface ISingleListDataGatherer public interface IMultiListDataProvider { event EventHandler NewDataAvailable; - event EventHandler FinishedProcessingAllFiles; Dictionary> GetValueLists(bool includeUnattestedFactoryChoices); } @@ -35,7 +26,7 @@ public interface IMultiListDataProvider /// /// This is the base class for processes which live in the background, /// gathering data about the files in the collection so that this data - /// is quickly accesible when needed. + /// is quickly accessible when needed. /// /// ---------------------------------------------------------------------------------------- public abstract class BackgroundFileProcessor : IDisposable where T : class @@ -49,17 +40,17 @@ public abstract class BackgroundFileProcessor : IDisposable where T : class protected readonly IEnumerable _typesOfFilesToProcess; protected readonly Func _fileDataFactory; protected bool _restartRequested = true; - protected Dictionary _fileToDataDictionary = new Dictionary(); + protected Dictionary _fileToDataDictionary = new(); private readonly Queue _pendingFileEvents; private volatile int _suspendEventProcessingCount; - private readonly object _lockObj = new object(); - private readonly object _lockSuspendObj = new object(); + private readonly object _lockObj = new(); + private readonly object _lockSuspendObj = new(); public event EventHandler NewDataAvailable; public event EventHandler FinishedProcessingAllFiles; /// ------------------------------------------------------------------------------------ - public BackgroundFileProcessor(string rootDirectoryPath, + protected BackgroundFileProcessor(string rootDirectoryPath, IEnumerable typesOfFilesToProcess, Func fileDataFactory) { RootDirectoryPath = rootDirectoryPath; @@ -72,8 +63,7 @@ public BackgroundFileProcessor(string rootDirectoryPath, /// ------------------------------------------------------------------------------------ public void Dispose() { - if (_workerThread != null) - _workerThread.Abort(); //will eventually lead to it stopping + _workerThread?.Abort(); //will eventually lead to it stopping _workerThread = null; } @@ -122,15 +112,12 @@ public virtual void ResumeProcessing(bool processAllPendingEventsNow) protected virtual bool GetDoIncludeFile(string path) { var fileName = Path.GetFileName(path); - return (fileName != null && !fileName.StartsWith(".") && - (_typesOfFilesToProcess.Any(t => t.IsMatch(path)))); + return fileName != null && !fileName.StartsWith(".") && + _typesOfFilesToProcess.Any(t => t.IsMatch(path)); } /// ------------------------------------------------------------------------------------ - protected virtual ThreadPriority ThreadPriority - { - get { return ThreadPriority.Lowest; } - } + protected virtual ThreadPriority ThreadPriority => ThreadPriority.Lowest; /// ------------------------------------------------------------------------------------ public virtual void Start() @@ -145,8 +132,7 @@ public virtual void Start() /// ------------------------------------------------------------------------------------ protected virtual void OnNewDataAvailable(T fileData) { - if (NewDataAvailable != null) - NewDataAvailable(this, EventArgs.Empty); + NewDataAvailable?.Invoke(this, EventArgs.Empty); } /// ------------------------------------------------------------------------------------ @@ -193,7 +179,7 @@ private void StartWorking() } catch (Exception error) { - SIL.Reporting.ErrorReport.NotifyUserOfProblem(error, "Background file watching failed."); + ErrorReport.NotifyUserOfProblem(error, "Background file watching failed."); } } @@ -216,13 +202,11 @@ private void ProcessFileEvent(FileSystemEventArgs fileEvent) { try { - if (fileEvent is RenamedEventArgs) + if (fileEvent is RenamedEventArgs e) { - var e = fileEvent as RenamedEventArgs; lock (((ICollection)_fileToDataDictionary).SyncRoot) { - T fileData; - if (_fileToDataDictionary.TryGetValue(e.OldFullPath, out fileData)) + if (_fileToDataDictionary.TryGetValue(e.OldFullPath, out var fileData)) { _fileToDataDictionary.Remove(e.OldFullPath); _fileToDataDictionary[e.FullPath] = fileData; @@ -244,7 +228,7 @@ private void ProcessFileEvent(FileSystemEventArgs fileEvent) Debug.WriteLine(e.Message); Logger.WriteEvent("Handled Exception in {0}.ProcessingFileEvent:\r\n{1}", GetType().Name, e.ToString()); #if DEBUG - SIL.Reporting.ErrorReport.NotifyUserOfProblem(e, "Error gathering data"); + ErrorReport.NotifyUserOfProblem(e, "Error gathering data"); #endif //nothing here is worth crashing over } @@ -255,14 +239,13 @@ public T GetFileData(string filePath) { lock (((ICollection)_fileToDataDictionary).SyncRoot) { - T stats; - if (_fileToDataDictionary.TryGetValue(filePath, out stats)) + if (_fileToDataDictionary.TryGetValue(filePath, out var stats)) return stats; if (GetDoIncludeFile(filePath)) { CollectDataForFile(filePath); - return (_fileToDataDictionary.TryGetValue(filePath, out stats) ? stats : null); + return _fileToDataDictionary.TryGetValue(filePath, out stats) ? stats : null; } return null; } @@ -310,7 +293,7 @@ protected virtual void CollectDataForFile(string path) { ErrorReport.NotifyUserOfProblem(new ShowOncePerSessionBasedOnExactMessagePolicy(), e, string.Format(LocalizationManager.GetString("MainWindow.AutoCompleteValueGathererError", - "An error of type {0} ocurred trying to gather information from file: {1}", + "An error of type {0} occurred trying to gather information from file: {1}", "Parameter 0 is an exception type; parameter 1 is a file name"), e.GetType(), path)); } else @@ -356,7 +339,7 @@ public virtual void ProcessAllFilesInFolder(string folder) /// ------------------------------------------------------------------------------------ public virtual void ProcessAllFiles() { - //now that the watcher is up and running, gather up all existing files + // Now that the watcher is up and running, gather up all existing files lock (((ICollection)_fileToDataDictionary).SyncRoot) { _fileToDataDictionary.Clear(); @@ -416,7 +399,7 @@ private static List WalkDirectoryTree(string topLevelFolder, SearchOptio // First, process all the files directly under this folder try { - // SP-879: Crash reading .DS_Store file on MacOS + // SP-879: Crash reading .DS_Store file on macOS files = Directory.GetFiles(topLevelFolder, "*.*").Where(name => { var fileName = Path.GetFileName(name); @@ -441,7 +424,7 @@ private static List WalkDirectoryTree(string topLevelFolder, SearchOptio var dirs = Directory.GetDirectories(topLevelFolder); foreach (var dir in dirs) { - // Resursive call for each subdirectory. + // Recursive call for each subdirectory. returnVal.AddRange(WalkDirectoryTree(dir, searchOption)); } } @@ -468,16 +451,10 @@ protected bool ShouldStop } /// ------------------------------------------------------------------------------------ - public bool Busy - { - get { return Status.StartsWith(kWorkingStatus); } - } + public bool Busy => Status.StartsWith(kWorkingStatus); /// ------------------------------------------------------------------------------------ - public bool DataUpToDate - { - get { return Status == kUpToDataStatus; } - } + public bool DataUpToDate => Status == kUpToDataStatus; /// ------------------------------------------------------------------------------------ public string Status diff --git a/src/SayMore/Model/Project.cs b/src/SayMore/Model/Project.cs index 374691a2..7512d4d0 100644 --- a/src/SayMore/Model/Project.cs +++ b/src/SayMore/Model/Project.cs @@ -18,11 +18,11 @@ using SayMore.Transcription.Model; using SayMore.UI.ComponentEditors; using SayMore.UI.Overview; +using SayMore.UI.Overview.Statistics; using SayMore.Utilities; using SIL.Archiving; using SIL.Archiving.Generic; using SIL.Archiving.IMDI; -using SIL.Archiving.IMDI.Schema; using SIL.Core.ClearShare; using SIL.Extensions; using SIL.IO; @@ -64,6 +64,21 @@ public class Project : IAutoSegmenterSettings, IRAMPArchivable, IDisposable private bool _needToDisposeFreeTranslationFont; private Font _workingLanguageFont; private bool _needToDisposeWorkingLanguageFont; + + private StatisticsViewModel _statisticsViewModel; + private int _initialNumberOfSessions; + private int _finalNumberOfSessions; + private int _initialNumberOfPersons; + private int _finalNumberOfPersons; + private sealed class MediaDurationStats(TimeSpan initial) + { + public TimeSpan Initial { get; } = initial; + public TimeSpan Current { get; set; } = initial; + + public TimeSpan Delta => Current - Initial; + } + + private Dictionary _mediaDurationStats; public delegate Project Factory(string desiredOrExistingFilePath); @@ -157,6 +172,43 @@ public Project(string desiredOrExistingSettingsFilePath, /// ------------------------------------------------------------------------------------ public void Dispose() { + if (_statisticsViewModel != null) + { + _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; + _statisticsViewModel.NewStatisticsAvailable -= UpdateStatistics; + + var sessionDelta = _finalNumberOfSessions - _initialNumberOfSessions; + var personDelta = _finalNumberOfPersons - _initialNumberOfPersons; + + bool hasProgress = + sessionDelta > 0 || + personDelta > 0 || + _mediaDurationStats.Values.Any(s => s.Delta > TimeSpan.Zero); + + if (hasProgress) + { + var properties = new Dictionary(); + + if (sessionDelta > 0) + properties["SessionsAdded"] = sessionDelta.ToString(); + + if (personDelta > 0) + properties["PersonsAdded"] = personDelta.ToString(); + + foreach (var kvp in _mediaDurationStats) + { + var delta = kvp.Value.Delta; + if (delta > TimeSpan.Zero) + { + properties[$"MediaDurationAdded.{kvp.Key}"] = + delta.TotalSeconds.ToString("F0"); + } + } + + Analytics.Track("ProjectProgress", properties); + } + } + _sessionsRepoFactory = null; if (_needToDisposeTranscriptionFont) TranscriptionFont.Dispose(); @@ -810,5 +862,51 @@ public IEnumerable GetSessionFilesToArchive(Type typeOfArchive, Settings.Default.SessionFileExtension, CancellationToken.None)); } #endregion + + public void TrackStatistics(StatisticsViewModel statisticsViewModel) + { + if (_statisticsViewModel != null) + { + _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; + _statisticsViewModel.NewStatisticsAvailable -= UpdateStatistics; + } + + _statisticsViewModel = statisticsViewModel; + _statisticsViewModel.FinishedGatheringStatisticsForAllFiles += FinishedGatheringStatistics; + } + + private void FinishedGatheringStatistics(object sender, EventArgs e) + { + _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; + _initialNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; + _initialNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; + _mediaDurationStats = + _statisticsViewModel.GetComponentRoleStatisticsPairs() + .ToDictionary( + s => s.Name, + s => new MediaDurationStats(s.Length)); + + _statisticsViewModel.NewStatisticsAvailable += UpdateStatistics; + } + + private void UpdateStatistics(object sender, EventArgs e) + { + _finalNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; + _finalNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; + + foreach (var stat in _statisticsViewModel.GetComponentRoleStatisticsPairs()) + { + if (_mediaDurationStats.TryGetValue(stat.Name, out var entry)) + entry.Current = stat.Length; + else + { + _mediaDurationStats[stat.Name] = + new MediaDurationStats(TimeSpan.Zero) + { + Current = stat.Length + }; + } + } + } } } diff --git a/src/SayMore/Model/SessionWorkflowInformant.cs b/src/SayMore/Model/SessionWorkflowInformant.cs index 7a1d9ca2..1973df83 100644 --- a/src/SayMore/Model/SessionWorkflowInformant.cs +++ b/src/SayMore/Model/SessionWorkflowInformant.cs @@ -14,7 +14,7 @@ namespace SayMore.Model public class SessionWorkflowInformant { private readonly ElementRepository _sessionRepository; - private IEnumerable _componentRoles; + private readonly IEnumerable _componentRoles; [Obsolete("For mocking only")] public SessionWorkflowInformant(){} @@ -28,10 +28,7 @@ public SessionWorkflowInformant(ElementRepository sessionRepository, } /// ------------------------------------------------------------------------------------ - public int NumberOfSessions - { - get { return _sessionRepository.AllItems.Count(); } - } + public int NumberOfSessions => _sessionRepository.AllItems.Count(); /// ------------------------------------------------------------------------------------ /// diff --git a/src/SayMore/ProjectContext.cs b/src/SayMore/ProjectContext.cs index 31973d0a..7e20c5ab 100644 --- a/src/SayMore/ProjectContext.cs +++ b/src/SayMore/ProjectContext.cs @@ -369,47 +369,37 @@ public void Dispose() /// ------------------------------------------------------------------------------------ public void SuspendAudioVideoBackgroundProcesses() { - if (_audioVideoDataGatherer != null) - _audioVideoDataGatherer.SuspendProcessing(); + _audioVideoDataGatherer?.SuspendProcessing(); } /// ------------------------------------------------------------------------------------ public void ResumeAudioVideoBackgroundProcesses(bool processAllPendingEventsNow) { - if (_audioVideoDataGatherer != null) - _audioVideoDataGatherer.ResumeProcessing(processAllPendingEventsNow); + _audioVideoDataGatherer?.ResumeProcessing(processAllPendingEventsNow); } /// ------------------------------------------------------------------------------------ public void SuspendBackgroundProcesses() { - if (_audioVideoDataGatherer != null) - _audioVideoDataGatherer.SuspendProcessing(); + _audioVideoDataGatherer?.SuspendProcessing(); - if (_autoCompleteValueGatherer != null) - _autoCompleteValueGatherer.SuspendProcessing(); + _autoCompleteValueGatherer?.SuspendProcessing(); - if (_fieldGatherer != null) - _fieldGatherer.SuspendProcessing(); + _fieldGatherer?.SuspendProcessing(); - if (_presetGatherer != null) - _presetGatherer.SuspendProcessing(); + _presetGatherer?.SuspendProcessing(); } /// ------------------------------------------------------------------------------------ public void ResumeBackgroundProcesses(bool processAllPendingEventsNow) { - if (_audioVideoDataGatherer != null) - _audioVideoDataGatherer.ResumeProcessing(processAllPendingEventsNow); + _audioVideoDataGatherer?.ResumeProcessing(processAllPendingEventsNow); - if (_autoCompleteValueGatherer != null) - _autoCompleteValueGatherer.ResumeProcessing(processAllPendingEventsNow); + _autoCompleteValueGatherer?.ResumeProcessing(processAllPendingEventsNow); - if (_fieldGatherer != null) - _fieldGatherer.ResumeProcessing(processAllPendingEventsNow); + _fieldGatherer?.ResumeProcessing(processAllPendingEventsNow); - if (_presetGatherer != null) - _presetGatherer.ResumeProcessing(processAllPendingEventsNow); + _presetGatherer?.ResumeProcessing(processAllPendingEventsNow); } /// ------------------------------------------------------------------------------------ diff --git a/src/SayMore/UI/Charts/ChartBarInfo.cs b/src/SayMore/UI/Charts/ChartBarInfo.cs index 196820c6..7cc957df 100644 --- a/src/SayMore/UI/Charts/ChartBarInfo.cs +++ b/src/SayMore/UI/Charts/ChartBarInfo.cs @@ -130,7 +130,7 @@ public ChartBarSegmentInfo(string fieldName, string fieldValue, } catch (InvalidOperationException) { - // SP-854: This can happen if the the list is still loading, "Collection was modified; enumeration operation may not execute." + // SP-854: This can happen if the list is still loading, "Collection was modified; enumeration operation may not execute." // Let the other thread continue and try again. Application.DoEvents(); Thread.Sleep(0); diff --git a/src/SayMore/UI/Charts/HTMLChartBuilder.cs b/src/SayMore/UI/Charts/HTMLChartBuilder.cs index 273a0f44..f24ce6c4 100644 --- a/src/SayMore/UI/Charts/HTMLChartBuilder.cs +++ b/src/SayMore/UI/Charts/HTMLChartBuilder.cs @@ -18,7 +18,7 @@ public class HTMLChartBuilder public const string kNonBreakingSpace = " "; private readonly StatisticsViewModel _statsViewModel; - protected readonly StringBuilder _htmlText = new StringBuilder(6000); + protected readonly StringBuilder _htmlText = new(6000); /// ------------------------------------------------------------------------------------ public HTMLChartBuilder(StatisticsViewModel statsViewModel) @@ -61,7 +61,7 @@ public string GetStatisticsCharts() WriteStageChart(); var backColors = GetStatusSegmentColors(); - var textColors = backColors.ToDictionary(kvp => kvp.Key, kvp => Color.Empty); + var textColors = backColors.ToDictionary(kvp => kvp.Key, _ => Color.Empty); text = LocalizationManager.GetString("ProgressView.ByGenreHeadingText", "By Genre"); WriteChartByFieldPair(text, SessionFileType.kGenreFieldName, SessionFileType.kStatusFieldName, backColors, textColors); @@ -91,8 +91,8 @@ private void WriteStageChart() var sessionsByStage = _statsViewModel.SessionInformant.GetSessionsCategorizedByStage() .Where(r => r.Key.Id != ComponentRole.kConsentComponentRoleId); - var barInfoList = (sessionsByStage.Select( - x => new ChartBarInfo(x.Key.Name, x.Value, x.Key.Color, x.Key.TextColor))).ToList(); + var barInfoList = sessionsByStage.Select( + x => new ChartBarInfo(x.Key.Name, x.Value, x.Key.Color, x.Key.TextColor)).ToList(); ChartBarInfo.CalculateBarSizes(barInfoList); var text = LocalizationManager.GetString("ProgressView.ByStagesHeadingText", "Completed Stages"); @@ -104,7 +104,7 @@ private IDictionary GetStatusSegmentColors() { var statusColors = new Dictionary(); - foreach (var statusName in Enum.GetNames(typeof(Session.Status)).Where(x => x != Session.Status.Skipped.ToString())) + foreach (var statusName in Enum.GetNames(typeof(Session.Status)).Where(x => x != nameof(Session.Status.Skipped))) { statusColors[Session.GetLocalizedStatus(statusName)] = (Color)Properties.Settings.Default[statusName + "StatusColor"]; @@ -135,9 +135,10 @@ private void WriteOverviewSection() foreach (var stats in _statsViewModel.GetComponentRoleStatisticsPairs()) { OpenTableRow(); - WriteTableRowHead(string.Format("{0}:", stats.Name)); - WriteTableCell(stats.Length); - WriteTableCell(stats.Size); + WriteTableRowHead($"{stats.Name}:"); + WriteTableCell(stats.Length.ToString()); + var size = stats.Size == 0 ? "---" : ComponentFile.GetDisplayableFileSize(stats.Size, false); + WriteTableCell(size); CloseTableRow(); } diff --git a/src/SayMore/UI/Overview/Statistics/StatisticsView.cs b/src/SayMore/UI/Overview/Statistics/StatisticsView.cs index 24285be1..83bb1d58 100644 --- a/src/SayMore/UI/Overview/Statistics/StatisticsView.cs +++ b/src/SayMore/UI/Overview/Statistics/StatisticsView.cs @@ -60,7 +60,7 @@ private void UpdateDisplay(ILocalizationManager lm = null) _webBrowser.DocumentStream?.Dispose(); UpdateStatusDisplay(true); - Thread updateDisplayThread = new Thread(() => + var updateDisplayThread = new Thread(() => { var htmlData = new MemoryStream(Encoding.UTF8.GetBytes(_model.HTMLString)); @@ -179,8 +179,7 @@ void HandleNewDataAvailable(object sender, EventArgs e) { // Can't actually call UpdateDisplay from here because this event is fired from // a background (data gathering) thread and updating the browser control on the - // background thread is a no-no. UpdateDisplay will be called when the timer - // tick fires. + // background thread is a no-no. BeginInvoke(new Action(() => UpdateDisplay())); } diff --git a/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs b/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs index 56961419..44ce0b61 100644 --- a/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs +++ b/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs @@ -17,7 +17,7 @@ public class StatisticsViewModel : IDisposable public event EventHandler FinishedGatheringStatisticsForAllFiles; private readonly IEnumerable _componentRoles; - private readonly AudioVideoDataGatherer _backgroundStatisticsGather; + private readonly AudioVideoDataGatherer _backgroundStatisticsGatherer; protected HTMLChartBuilder _chartBuilder; public PersonInformant PersonInformant { get; protected set; } @@ -28,16 +28,18 @@ public class StatisticsViewModel : IDisposable /// ------------------------------------------------------------------------------------ public StatisticsViewModel(Project project, PersonInformant personInformant, SessionWorkflowInformant sessionInformant, IEnumerable componentRoles, - AudioVideoDataGatherer backgroundStatisticsMananager) + AudioVideoDataGatherer backgroundStatisticsManager) { - ProjectName = (project == null ? string.Empty : project.Name); - ProjectPath = (project == null ? string.Empty : project.FolderPath); + ProjectName = project?.Name ?? string.Empty; + ProjectPath = project?.FolderPath ?? string.Empty; PersonInformant = personInformant; SessionInformant = sessionInformant; _componentRoles = componentRoles; - _backgroundStatisticsGather = backgroundStatisticsMananager; - _backgroundStatisticsGather.NewDataAvailable += HandleNewStatistics; - _backgroundStatisticsGather.FinishedProcessingAllFiles += HandleFinishedGatheringStatisticsForAllFiles; + _backgroundStatisticsGatherer = backgroundStatisticsManager; + _backgroundStatisticsGatherer.NewDataAvailable += HandleNewStatistics; + _backgroundStatisticsGatherer.FinishedProcessingAllFiles += HandleFinishedGatheringStatisticsForAllFiles; + + project?.TrackStatistics(this); _chartBuilder = new HTMLChartBuilder(this); } @@ -45,33 +47,21 @@ public StatisticsViewModel(Project project, PersonInformant personInformant, /// ------------------------------------------------------------------------------------ public void Dispose() { - _backgroundStatisticsGather.NewDataAvailable -= HandleNewStatistics; - _backgroundStatisticsGather.FinishedProcessingAllFiles -= HandleFinishedGatheringStatisticsForAllFiles; + _backgroundStatisticsGatherer.NewDataAvailable -= HandleNewStatistics; + _backgroundStatisticsGatherer.FinishedProcessingAllFiles -= HandleFinishedGatheringStatisticsForAllFiles; } /// ------------------------------------------------------------------------------------ - public string Status - { - get { return _backgroundStatisticsGather.Status; } - } + public string Status => _backgroundStatisticsGatherer.Status; /// ------------------------------------------------------------------------------------ - public bool IsDataUpToDate - { - get { return _backgroundStatisticsGather.DataUpToDate; } - } + public bool IsDataUpToDate => _backgroundStatisticsGatherer.DataUpToDate; /// ------------------------------------------------------------------------------------ - public bool IsBusy - { - get { return _backgroundStatisticsGather.Busy; } - } + public bool IsBusy => _backgroundStatisticsGatherer.Busy; /// ------------------------------------------------------------------------------------ - public string HTMLString - { - get { return _chartBuilder.GetStatisticsCharts(); } - } + public string HTMLString => _chartBuilder.GetStatisticsCharts(); /// ------------------------------------------------------------------------------------ public IEnumerable> GetElementStatisticsPairs() @@ -89,13 +79,12 @@ public IEnumerable GetComponentRoleStatisticsPairs() foreach (var role in _componentRoles.Where(def => def.MeasurementType == ComponentRole.MeasurementTypes.Time)) { long bytes = GetTotalComponentRoleFileSizes(role); - var size = (bytes == 0 ? "---" : ComponentFile.GetDisplayableFileSize(bytes, false)); yield return new ComponentRoleStatistics { Name = role.Name, - Length = GetRecordingDurations(role).ToString(), - Size = size + Length = GetRecordingDurations(role), + Size = bytes }; } } @@ -121,7 +110,7 @@ private IEnumerable GetFilteredFileData(ComponentRole role) { var comparer = new SourceAndStandardAudioCoalescingComparer(); // SP-2171: i.MediaFilePath will be empty if the file is zero length (see MediaFileInfo.GetInfo()). This happens often with the generated oral annotation file. - return _backgroundStatisticsGather.GetAllFileData() + return _backgroundStatisticsGatherer.GetAllFileData() .Where(i => !string.IsNullOrEmpty(i.MediaFilePath) && !i.MediaFilePath.EndsWith(Settings.Default.OralAnnotationGeneratedFileSuffix) && role.IsMatch(i.MediaFilePath)) .Distinct(comparer); @@ -130,28 +119,26 @@ private IEnumerable GetFilteredFileData(ComponentRole role) /// ------------------------------------------------------------------------------------ public void Refresh() { - _backgroundStatisticsGather.Restart(); + _backgroundStatisticsGatherer.Restart(); } /// ------------------------------------------------------------------------------------ void HandleNewStatistics(object sender, EventArgs e) { - if (NewStatisticsAvailable != null) - NewStatisticsAvailable(this, EventArgs.Empty); + NewStatisticsAvailable?.Invoke(this, EventArgs.Empty); } /// ------------------------------------------------------------------------------------ void HandleFinishedGatheringStatisticsForAllFiles(object sender, EventArgs e) { - if (FinishedGatheringStatisticsForAllFiles != null) - FinishedGatheringStatisticsForAllFiles(this, EventArgs.Empty); + FinishedGatheringStatisticsForAllFiles?.Invoke(this, EventArgs.Empty); } } public class ComponentRoleStatistics { public string Name { get; set; } - public string Length { get; set; } - public string Size { get; set; } + public TimeSpan Length { get; set; } + public long Size { get; set; } } } \ No newline at end of file From bbdb5e1e962cdb4c941001d01c9260bf0ac6db5e Mon Sep 17 00:00:00 2001 From: tombogle Date: Tue, 6 Jan 2026 09:50:03 -0500 Subject: [PATCH 3/8] Updated copyright to 2026 --- DistFiles/aboutBox.htm | 2 +- src/SayMore/Properties/AssemblyInfo.cs | 2 +- src/SayMore/UI/SplashScreenForm.Designer.cs | 2 +- src/SayMoreTests/Properties/AssemblyInfo.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DistFiles/aboutBox.htm b/DistFiles/aboutBox.htm index 718f9034..d251279a 100644 --- a/DistFiles/aboutBox.htm +++ b/DistFiles/aboutBox.htm @@ -7,7 +7,7 @@ -

Copyright © 2011-2025 SIL Global

+

Copyright © 2011-2026 SIL Global

License

Open source (MIT).

diff --git a/src/SayMore/Properties/AssemblyInfo.cs b/src/SayMore/Properties/AssemblyInfo.cs index dd2e5029..59b87d0a 100644 --- a/src/SayMore/Properties/AssemblyInfo.cs +++ b/src/SayMore/Properties/AssemblyInfo.cs @@ -10,7 +10,7 @@ [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("SIL Global")] [assembly: AssemblyProduct("SayMore")] -[assembly: AssemblyCopyright("Copyright © 2011-2025 SIL Global")] +[assembly: AssemblyCopyright("Copyright © 2011-2026 SIL Global")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/src/SayMore/UI/SplashScreenForm.Designer.cs b/src/SayMore/UI/SplashScreenForm.Designer.cs index eaae9199..d7c0771f 100644 --- a/src/SayMore/UI/SplashScreenForm.Designer.cs +++ b/src/SayMore/UI/SplashScreenForm.Designer.cs @@ -188,7 +188,7 @@ private void InitializeComponent() this.label1.Name = "label1"; this.label1.Size = new System.Drawing.Size(126, 13); this.label1.TabIndex = 7; - this.label1.Text = "© 2011-2025 SIL Global"; + this.label1.Text = "© 2011-2026 SIL Global"; // // SplashScreenForm // diff --git a/src/SayMoreTests/Properties/AssemblyInfo.cs b/src/SayMoreTests/Properties/AssemblyInfo.cs index 0207a0b4..d890f930 100644 --- a/src/SayMoreTests/Properties/AssemblyInfo.cs +++ b/src/SayMoreTests/Properties/AssemblyInfo.cs @@ -9,7 +9,7 @@ [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("SayMoreTests")] -[assembly: AssemblyCopyright("Copyright © 2025")] +[assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] From 57680240b862ecafa94aa178fc898a7b713439ae Mon Sep 17 00:00:00 2001 From: tombogle Date: Tue, 6 Jan 2026 10:07:53 -0500 Subject: [PATCH 4/8] Fixed problems when stats are not fully initialized --- src/SayMore/Model/Project.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/SayMore/Model/Project.cs b/src/SayMore/Model/Project.cs index 7512d4d0..8752271c 100644 --- a/src/SayMore/Model/Project.cs +++ b/src/SayMore/Model/Project.cs @@ -183,6 +183,7 @@ public void Dispose() bool hasProgress = sessionDelta > 0 || personDelta > 0 || + _mediaDurationStats != null && _mediaDurationStats.Values.Any(s => s.Delta > TimeSpan.Zero); if (hasProgress) @@ -195,13 +196,16 @@ public void Dispose() if (personDelta > 0) properties["PersonsAdded"] = personDelta.ToString(); - foreach (var kvp in _mediaDurationStats) + if (_mediaDurationStats != null) { - var delta = kvp.Value.Delta; - if (delta > TimeSpan.Zero) + foreach (var kvp in _mediaDurationStats) { - properties[$"MediaDurationAdded.{kvp.Key}"] = - delta.TotalSeconds.ToString("F0"); + var delta = kvp.Value.Delta; + if (delta > TimeSpan.Zero) + { + properties[$"MediaDurationAdded.{kvp.Key}"] = + delta.TotalSeconds.ToString("F0"); + } } } @@ -878,8 +882,8 @@ public void TrackStatistics(StatisticsViewModel statisticsViewModel) private void FinishedGatheringStatistics(object sender, EventArgs e) { _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; - _initialNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; - _initialNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; + _initialNumberOfSessions = _finalNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; + _initialNumberOfPersons = _finalNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; _mediaDurationStats = _statisticsViewModel.GetComponentRoleStatisticsPairs() .ToDictionary( From edbca9f03c546035eac0cdcaf5fcad61e146b7c3 Mon Sep 17 00:00:00 2001 From: tombogle Date: Mon, 12 Jan 2026 17:00:28 -0500 Subject: [PATCH 5/8] Upgraded SIL.DesktopAnalytics Also, added missing DLL to Installer --- build/SayMore.proj | 2 +- build/TestInstallerBuild.bat | 2 +- src/Installer/Installer.wxs | 5 +++++ src/SayMore/SayMore.csproj | 10 ++++++---- src/SayMoreTests/SayMoreTests.csproj | 8 ++++---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/build/SayMore.proj b/build/SayMore.proj index 41f09ed1..67dd4937 100644 --- a/build/SayMore.proj +++ b/build/SayMore.proj @@ -14,7 +14,7 @@ $(RootDir)/packages/ 16.2.0 $(NuGetPackageRoot)SIL.libpalaso.l10ns/$(PalasoL10nsVersion)/ - $(NuGetPackageRoot)SIL.ReleaseTasks/3.1.1/build/SIL.ReleaseTasks.props + $(NuGetPackageRoot)SIL.ReleaseTasks/3.2.0/build/SIL.ReleaseTasks.props $(LocalPackagesRoot)SIL.BuildTasks/ $(BuildTasksVersionFolder)tools/SIL.BuildTasks.dll $(BuildTasksVersionFolder)build/SIL.BuildTasks.props diff --git a/build/TestInstallerBuild.bat b/build/TestInstallerBuild.bat index c78ef84d..f92a4bfb 100644 --- a/build/TestInstallerBuild.bat +++ b/build/TestInstallerBuild.bat @@ -14,5 +14,5 @@ GOTO pauseforusertoseeoutput REM :unexpectedsystemvariableinuse REM @ECHO Unexpected system variable msbuildpath is in use. Value: %msbuildpath% :pauseforusertoseeoutput -ECHO %CMDCMDLINE% | findstr /i "/c" >nul +ECHO %CMDCMDLINE% | findstr /i /c:"/c" >nul IF NOT errorlevel 1 PAUSE \ No newline at end of file diff --git a/src/Installer/Installer.wxs b/src/Installer/Installer.wxs index 01071197..b2a7137e 100644 --- a/src/Installer/Installer.wxs +++ b/src/Installer/Installer.wxs @@ -200,6 +200,10 @@ are trying to support, you're better off using non-advertised shortcuts. "--> + + + + @@ -378,6 +382,7 @@ are trying to support, you're better off using non-advertised shortcuts. "--> + diff --git a/src/SayMore/SayMore.csproj b/src/SayMore/SayMore.csproj index 10bcf972..a380f6ae 100644 --- a/src/SayMore/SayMore.csproj +++ b/src/SayMore/SayMore.csproj @@ -40,7 +40,7 @@ - + @@ -51,10 +51,10 @@ - + - + All @@ -62,11 +62,13 @@ + + - + diff --git a/src/SayMoreTests/SayMoreTests.csproj b/src/SayMoreTests/SayMoreTests.csproj index 2799d699..93fb5024 100644 --- a/src/SayMoreTests/SayMoreTests.csproj +++ b/src/SayMoreTests/SayMoreTests.csproj @@ -67,7 +67,7 @@ - + @@ -78,13 +78,13 @@ - + - - + + From 016fab77497744ccdae1fbb28fd16a255f59b790 Mon Sep 17 00:00:00 2001 From: tombogle Date: Mon, 12 Jan 2026 23:07:57 -0500 Subject: [PATCH 6/8] Unified on System.Text.Json 9.0.0 to prevent runtime failure: Could not load file or assembly 'System.Text.Json, Version=9.0.0.0' --- src/SayMore/SayMore.csproj | 1 + src/SayMoreTests/SayMoreTests.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/src/SayMore/SayMore.csproj b/src/SayMore/SayMore.csproj index a380f6ae..c5a58b90 100644 --- a/src/SayMore/SayMore.csproj +++ b/src/SayMore/SayMore.csproj @@ -69,6 +69,7 @@ + diff --git a/src/SayMoreTests/SayMoreTests.csproj b/src/SayMoreTests/SayMoreTests.csproj index 93fb5024..b2b02d7b 100644 --- a/src/SayMoreTests/SayMoreTests.csproj +++ b/src/SayMoreTests/SayMoreTests.csproj @@ -86,6 +86,7 @@ + From c71c5fd0cd283e53300ac3b689a877ff8b305f15 Mon Sep 17 00:00:00 2001 From: tombogle Date: Tue, 13 Jan 2026 01:12:04 -0500 Subject: [PATCH 7/8] Improved installer to avoid picking up older DLL versions referenced by tests Added explicit method to call to report project progress. Included advances in completed stages in project progress analytics --- src/Installer/Installer.wxs | 15 +++--- .../DataGathering/BackgroundFileProcessor.cs | 5 +- src/SayMore/Model/Files/FieldUpdater.cs | 3 +- src/SayMore/Model/Project.cs | 53 +++++++++++++++++-- src/SayMore/Program.cs | 6 ++- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/Installer/Installer.wxs b/src/Installer/Installer.wxs index b2a7137e..3ebcb56b 100644 --- a/src/Installer/Installer.wxs +++ b/src/Installer/Installer.wxs @@ -6,7 +6,8 @@ - + + @@ -345,22 +346,22 @@ are trying to support, you're better off using non-advertised shortcuts. "--> - + - + - + - + - + - + diff --git a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs index 6793371e..1fd0cbff 100644 --- a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs +++ b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs @@ -388,8 +388,7 @@ protected virtual void ProcessAllFiles(string topLevelFolder, bool searchSubFold Status = kUpToDataStatus; - if (FinishedProcessingAllFiles != null) - FinishedProcessingAllFiles(this, EventArgs.Empty); + FinishedProcessingAllFiles?.Invoke(this, EventArgs.Empty); } private static List WalkDirectoryTree(string topLevelFolder, SearchOption searchOption) @@ -418,7 +417,7 @@ private static List WalkDirectoryTree(string topLevelFolder, SearchOptio Debug.Print("Directory not found: " + topLevelFolder); } - if ((files != null) && (searchOption == SearchOption.AllDirectories)) + if (files != null && searchOption == SearchOption.AllDirectories) { // Now find all the subdirectories under this directory. var dirs = Directory.GetDirectories(topLevelFolder); diff --git a/src/SayMore/Model/Files/FieldUpdater.cs b/src/SayMore/Model/Files/FieldUpdater.cs index 2aa949b5..976b610c 100644 --- a/src/SayMore/Model/Files/FieldUpdater.cs +++ b/src/SayMore/Model/Files/FieldUpdater.cs @@ -77,8 +77,7 @@ private void FindAndUpdateFiles(ComponentFile file, string idOfFieldToFind, var matchingFiles = GetMatchingFiles(file.FileType); - if (_fieldGatherer != null) - _fieldGatherer.SuspendProcessing(); + _fieldGatherer?.SuspendProcessing(); foreach (var path in matchingFiles) { diff --git a/src/SayMore/Model/Project.cs b/src/SayMore/Model/Project.cs index 8752271c..731fb012 100644 --- a/src/SayMore/Model/Project.cs +++ b/src/SayMore/Model/Project.cs @@ -29,6 +29,7 @@ using SIL.Reporting; using SIL.Windows.Forms; using static System.IO.Path; +using static SayMore.Model.Files.ComponentRole.MeasurementTypes; namespace SayMore.Model { @@ -77,8 +78,16 @@ private sealed class MediaDurationStats(TimeSpan initial) public TimeSpan Delta => Current - Initial; } - + private sealed class RoleCountStats(int initial) + { + public int Initial { get; } = initial; + public int Current { get; set; } = initial; + + public int Delta => Current - Initial; + } + private Dictionary _mediaDurationStats; + private Dictionary _sessionRoleStats; public delegate Project Factory(string desiredOrExistingFilePath); @@ -170,7 +179,7 @@ public Project(string desiredOrExistingSettingsFilePath, } /// ------------------------------------------------------------------------------------ - public void Dispose() + public void ReportProgressIfNeeded() { if (_statisticsViewModel != null) { @@ -184,7 +193,9 @@ public void Dispose() sessionDelta > 0 || personDelta > 0 || _mediaDurationStats != null && - _mediaDurationStats.Values.Any(s => s.Delta > TimeSpan.Zero); + _mediaDurationStats.Values.Any(s => s.Delta > TimeSpan.Zero) || + _sessionRoleStats != null && + _sessionRoleStats.Values.Any(s => s.Delta > 0); ; if (hasProgress) { @@ -208,11 +219,28 @@ public void Dispose() } } } + + if (_sessionRoleStats != null) + { + foreach (var kvp in _sessionRoleStats) + { + var delta = kvp.Value.Delta; + if (delta > 0) + { + properties[$"CompletedStages.{kvp.Key}"] = + delta.ToString(); + } + } + } Analytics.Track("ProjectProgress", properties); } } + } + /// ------------------------------------------------------------------------------------ + public void Dispose() + { _sessionsRepoFactory = null; if (_needToDisposeTranscriptionFont) TranscriptionFont.Dispose(); @@ -890,6 +918,10 @@ private void FinishedGatheringStatistics(object sender, EventArgs e) s => s.Name, s => new MediaDurationStats(s.Length)); + _sessionRoleStats = _statisticsViewModel.SessionInformant.GetSessionsCategorizedByStage() + .Where(s => s.Key.MeasurementType != Time) + .ToDictionary(s => s.Key.Id, s => new RoleCountStats(s.Value.Count())); + _statisticsViewModel.NewStatisticsAvailable += UpdateStatistics; } @@ -911,6 +943,21 @@ private void UpdateStatistics(object sender, EventArgs e) }; } } + + foreach (var stat in _statisticsViewModel.SessionInformant.GetSessionsCategorizedByStage() + .Where(s => s.Key.MeasurementType != Time)) + { + if (_sessionRoleStats.TryGetValue(stat.Key.Id, out var entry)) + entry.Current = stat.Value.Count(); + else + { + _sessionRoleStats[stat.Key.Id] = + new RoleCountStats(0) + { + Current = stat.Value.Count() + }; + } + } } } } diff --git a/src/SayMore/Program.cs b/src/SayMore/Program.cs index af639de5..680a8d22 100644 --- a/src/SayMore/Program.cs +++ b/src/SayMore/Program.cs @@ -235,6 +235,8 @@ static void Main() { Application.Run(); Settings.Default.Save(); + _projectContext?.Project.ReportProgressIfNeeded(); + Analytics.FlushClient(); Logger.WriteEvent("SayMore shutting down"); if (s_countOfContiguousFirstChanceOutOfMemoryExceptions > 1) Logger.WriteEvent("Total number of contiguous OutOfMemoryExceptions: {0}", s_countOfContiguousFirstChanceOutOfMemoryExceptions); @@ -596,8 +598,10 @@ static void ChooseAnotherProject(object sender, EventArgs e) } /// ------------------------------------------------------------------------------------ - static void HandleProjectWindowClosed(object sender, EventArgs e) + private static void HandleProjectWindowClosed(object sender, EventArgs e) { + _projectContext?.Project.ReportProgressIfNeeded(); + SafelyDisposeProjectContext(); ReleaseMutexForThisProject(); From ff5e8632ac5202459c8fa0cd3612f3a1f4b4d9d0 Mon Sep 17 00:00:00 2001 From: tombogle Date: Tue, 13 Jan 2026 10:49:12 -0500 Subject: [PATCH 8/8] Revert errant installer change Deal with race condition to be able to track progress reliably. Remove unnecessary tracking of intermediate updates. --- src/Installer/Installer.wxs | 4 +- .../DataGathering/BackgroundFileProcessor.cs | 14 +- src/SayMore/Model/Project.cs | 360 +++++++++--------- src/SayMore/Program.cs | 4 +- .../Statistics/StatisticsViewModel.cs | 2 +- 5 files changed, 200 insertions(+), 184 deletions(-) diff --git a/src/Installer/Installer.wxs b/src/Installer/Installer.wxs index 3ebcb56b..d0bf3ed8 100644 --- a/src/Installer/Installer.wxs +++ b/src/Installer/Installer.wxs @@ -6,8 +6,8 @@ - - + + diff --git a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs index 1fd0cbff..c3e984d6 100644 --- a/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs +++ b/src/SayMore/Model/Files/DataGathering/BackgroundFileProcessor.cs @@ -122,9 +122,11 @@ protected virtual bool GetDoIncludeFile(string path) /// ------------------------------------------------------------------------------------ public virtual void Start() { - _workerThread = new Thread(StartWorking); - _workerThread.Name = GetType().Name; - _workerThread.Priority = ThreadPriority; + _workerThread = new Thread(StartWorking) + { + Name = GetType().Name, + Priority = ThreadPriority + }; _workerThread.TrySetApartmentState(ApartmentState.STA);//needed in case we eventually show an error & need to talk to email. _workerThread.Start(); } @@ -140,7 +142,7 @@ private void StartWorking() { try { - Status = kWorkingStatus; //NB: this helps simplify unit tests, if go to the busy state before returning + Status = kWorkingStatus; //NB: this helps simplify unit tests, if we go to the busy state before returning using (var watcher = new FileSystemWatcher(RootDirectoryPath)) { @@ -175,7 +177,7 @@ private void StartWorking() } catch (ThreadAbortException) { - //this is fine, it happens when we quit + // This is fine, it happens when we quit } catch (Exception error) { @@ -339,7 +341,7 @@ public virtual void ProcessAllFilesInFolder(string folder) /// ------------------------------------------------------------------------------------ public virtual void ProcessAllFiles() { - // Now that the watcher is up and running, gather up all existing files + // Now that the watcher is up and running, gather all existing files lock (((ICollection)_fileToDataDictionary).SyncRoot) { _fileToDataDictionary.Clear(); diff --git a/src/SayMore/Model/Project.cs b/src/SayMore/Model/Project.cs index 731fb012..f191b18e 100644 --- a/src/SayMore/Model/Project.cs +++ b/src/SayMore/Model/Project.cs @@ -66,28 +66,117 @@ public class Project : IAutoSegmenterSettings, IRAMPArchivable, IDisposable private Font _workingLanguageFont; private bool _needToDisposeWorkingLanguageFont; - private StatisticsViewModel _statisticsViewModel; - private int _initialNumberOfSessions; - private int _finalNumberOfSessions; - private int _initialNumberOfPersons; - private int _finalNumberOfPersons; - private sealed class MediaDurationStats(TimeSpan initial) + private sealed class ProgressStats { - public TimeSpan Initial { get; } = initial; - public TimeSpan Current { get; set; } = initial; + private readonly StatisticsViewModel _model; + + private readonly int _initialNumberOfSessions; + private readonly int _initialNumberOfPersons; - public TimeSpan Delta => Current - Initial; - } - private sealed class RoleCountStats(int initial) - { - public int Initial { get; } = initial; - public int Current { get; set; } = initial; + private sealed class MediaDurationStats(TimeSpan initial) + { + private TimeSpan Initial { get; } = initial; + public TimeSpan Current { get; set; } = initial; + + public TimeSpan Delta => Current - Initial; + } + + private sealed class RoleCountStats(int initial) + { + private int Initial { get; } = initial; + public int Current { get; set; } = initial; + + public int Delta => Current - Initial; + } + + private readonly Dictionary _mediaDurationStats; + private readonly Dictionary _sessionRoleStats; + + internal ProgressStats(StatisticsViewModel model) + { + _model = model; + + _initialNumberOfSessions = _model.SessionInformant.NumberOfSessions; + _initialNumberOfPersons = _model.PersonInformant.NumberOfPeople; + _mediaDurationStats = _model.GetComponentRoleStatisticsPairs() + .ToDictionary(s => s.Name, s => new MediaDurationStats(s.Length)); + + _sessionRoleStats = _model.SessionInformant.GetSessionsCategorizedByStage() + .Where(s => s.Key.MeasurementType != Time) + .ToDictionary(s => s.Key.Id, s => new RoleCountStats(s.Value.Count())); + } + + internal void ReportUpdatedStatistics() + { + if (_model.IsBusy) + Thread.Sleep(200); // Give it a fighting chance to finish. + + var sessionDelta = _model.SessionInformant.NumberOfSessions - _initialNumberOfSessions; + var personDelta = _model.PersonInformant.NumberOfPeople - _initialNumberOfPersons; + + foreach (var stat in _model.GetComponentRoleStatisticsPairs()) + { + if (_mediaDurationStats.TryGetValue(stat.Name, out var entry)) + entry.Current = stat.Length; + else + { + _mediaDurationStats[stat.Name] = + new MediaDurationStats(TimeSpan.Zero) + { + Current = stat.Length + }; + } + } + + foreach (var stat in _model.SessionInformant.GetSessionsCategorizedByStage() + .Where(s => s.Key.MeasurementType != Time)) + { + if (_sessionRoleStats.TryGetValue(stat.Key.Id, out var entry)) + entry.Current = stat.Value.Count(); + else + { + _sessionRoleStats[stat.Key.Id] = new RoleCountStats(0) + { + Current = stat.Value.Count() + }; + } + } + + var properties = new Dictionary(); + + if (sessionDelta > 0) + properties["SessionsAdded"] = sessionDelta.ToString(); + + if (personDelta > 0) + properties["PersonsAdded"] = personDelta.ToString(); + + foreach (var kvp in _mediaDurationStats) + { + var delta = kvp.Value.Delta; + if (delta > TimeSpan.Zero) + { + properties[$"MediaDurationAdded.{kvp.Key}"] = + delta.TotalSeconds.ToString("F0"); + } + } + + foreach (var kvp in _sessionRoleStats) + { + var delta = kvp.Value.Delta; + if (delta > 0) + { + properties[$"CompletedStages.{kvp.Key}"] = + delta.ToString(); + } + } - public int Delta => Current - Initial; + if (properties.Any()) + Analytics.Track("ProjectProgress", properties); + } } - private Dictionary _mediaDurationStats; - private Dictionary _sessionRoleStats; + private StatisticsViewModel _statisticsViewModel; + private ProgressStats _progressStats; public delegate Project Factory(string desiredOrExistingFilePath); @@ -179,68 +268,35 @@ public Project(string desiredOrExistingSettingsFilePath, } /// ------------------------------------------------------------------------------------ - public void ReportProgressIfNeeded() + public void ReportProgressIfAny() { - if (_statisticsViewModel != null) - { - _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; - _statisticsViewModel.NewStatisticsAvailable -= UpdateStatistics; - - var sessionDelta = _finalNumberOfSessions - _initialNumberOfSessions; - var personDelta = _finalNumberOfPersons - _initialNumberOfPersons; - - bool hasProgress = - sessionDelta > 0 || - personDelta > 0 || - _mediaDurationStats != null && - _mediaDurationStats.Values.Any(s => s.Delta > TimeSpan.Zero) || - _sessionRoleStats != null && - _sessionRoleStats.Values.Any(s => s.Delta > 0); ; - - if (hasProgress) - { - var properties = new Dictionary(); - - if (sessionDelta > 0) - properties["SessionsAdded"] = sessionDelta.ToString(); - - if (personDelta > 0) - properties["PersonsAdded"] = personDelta.ToString(); + if (_statisticsViewModel == null) + return; + + _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; - if (_mediaDurationStats != null) - { - foreach (var kvp in _mediaDurationStats) - { - var delta = kvp.Value.Delta; - if (delta > TimeSpan.Zero) - { - properties[$"MediaDurationAdded.{kvp.Key}"] = - delta.TotalSeconds.ToString("F0"); - } - } - } - - if (_sessionRoleStats != null) - { - foreach (var kvp in _sessionRoleStats) - { - var delta = kvp.Value.Delta; - if (delta > 0) - { - properties[$"CompletedStages.{kvp.Key}"] = - delta.ToString(); - } - } - } + // Really unlikely, but if we get here before the initial gathering is done, we can't do anything. + if (_progressStats == null) + return; - Analytics.Track("ProjectProgress", properties); - } + try + { + _progressStats.ReportUpdatedStatistics(); } + catch (ObjectDisposedException e) + { + // This probably should be impossible, but just in case, we don't want reporting stats to + // crash the program. + Logger.WriteError(e); + } + _progressStats = null; // This ensures we only report once per project open. } /// ------------------------------------------------------------------------------------ public void Dispose() { + _progressStats = null; // Probably already done, but it also had a copy of _statisticsViewModel. + _statisticsViewModel?.Dispose(); _sessionsRepoFactory = null; if (_needToDisposeTranscriptionFont) TranscriptionFont.Dispose(); @@ -423,54 +479,6 @@ public void Save() _accessProtocolChanged = false; } - /// ------------------------------------------------------------------------------------ - public string GetFileDescription(string key, string file) - { - var description = (key == string.Empty ? "SayMore Session File" : "SayMore Contributor File"); - - if (file.ToLower().EndsWith(Settings.Default.SessionFileExtension)) - description = "SayMore Session Metadata (XML)"; - else if (file.ToLower().EndsWith(Settings.Default.PersonFileExtension)) - description = "SayMore Contributor Metadata (XML)"; - else if (file.ToLower().EndsWith(Settings.Default.MetadataFileExtension)) - description = "SayMore File Metadata (XML)"; - - return description; - } - - /// ------------------------------------------------------------------------------------ - public void SetAdditionalMetsData(RampArchivingDlgViewModel model) - { - foreach (var session in GetAllSessions(CancellationToken.None)) - { - model.SetScholarlyWorkType(ScholarlyWorkType.PrimaryData); - model.SetDomains(SilDomain.Ling_LanguageDocumentation); - - var value = session.MetaDataFile.GetStringValue(SessionFileType.kDateFieldName, null); - if (!string.IsNullOrEmpty(value)) - model.SetCreationDate(value); - - // Return the session's note as the abstract portion of the package's description. - value = session.MetaDataFile.GetStringValue(SessionFileType.kSynopsisFieldName, null); - if (!string.IsNullOrEmpty(value)) - model.SetAbstract(value, string.Empty); - - // Set contributors - var contribsVal = session.MetaDataFile.GetValue(SessionFileType.kContributionsFieldName, null); - if (contribsVal is ContributionCollection contributions && contributions.Count > 0) - model.SetContributors(contributions); - - // Return total duration of source audio/video recordings. - TimeSpan totalDuration = session.GetTotalDurationOfSourceMedia(); - if (totalDuration.Ticks > 0) - model.SetAudioVideoExtent($"Total Length of Source Recordings: {totalDuration}"); - - //First session details are enough for "Archive RAMP (SIL)..." from Project menu - break; - } - } - - /// ------------------------------------------------------------------------------------ public void Load() { @@ -578,26 +586,26 @@ private static string GetFontErrorMessage(string description, string settingValu } /// ------------------------------------------------------------------------------------ - private string GetStringSettingValue(XElement project, string elementName, string defaultValue) + private static string GetStringSettingValue(XElement project, string elementName, string defaultValue) { var element = project.Element(elementName); return element == null ? defaultValue : element.Value; } /// ------------------------------------------------------------------------------------ - private int GetIntAttributeValue(XElement project, string attribName, string fallbackAttribName = null) + private static int GetIntAttributeValue(XElement project, string attribName, string fallbackAttribName = null) { var attrib = project.Attribute(attribName); if (attrib == null && fallbackAttribName != null) attrib = project.Attribute(fallbackAttribName); - return (attrib != null && Int32.TryParse(attrib.Value, out var val)) ? val : default; + return attrib != null && Int32.TryParse(attrib.Value, out var val) ? val : 0; } /// ------------------------------------------------------------------------------------ - private double GetDoubleAttributeValue(XElement project, string attribName) + private static double GetDoubleAttributeValue(XElement project, string attribName) { var attrib = project.Attribute(attribName); - return (attrib != null && Double.TryParse(attrib.Value, out var val)) ? val : default; + return attrib != null && Double.TryParse(attrib.Value, out var val) ? val : 0; } /// ------------------------------------------------------------------------------------ @@ -653,6 +661,53 @@ internal IEnumerable GetAllSessions(CancellationToken cancellationToken } #region Archiving + /// ------------------------------------------------------------------------------------ + public string GetFileDescription(string key, string file) + { + var description = key == string.Empty ? "SayMore Session File" : "SayMore Contributor File"; + + if (file.ToLower().EndsWith(Settings.Default.SessionFileExtension)) + description = "SayMore Session Metadata (XML)"; + else if (file.ToLower().EndsWith(Settings.Default.PersonFileExtension)) + description = "SayMore Contributor Metadata (XML)"; + else if (file.ToLower().EndsWith(Settings.Default.MetadataFileExtension)) + description = "SayMore File Metadata (XML)"; + + return description; + } + + /// ------------------------------------------------------------------------------------ + public void SetAdditionalMetsData(RampArchivingDlgViewModel model) + { + foreach (var session in GetAllSessions(CancellationToken.None)) + { + model.SetScholarlyWorkType(ScholarlyWorkType.PrimaryData); + model.SetDomains(SilDomain.Ling_LanguageDocumentation); + + var value = session.MetaDataFile.GetStringValue(SessionFileType.kDateFieldName, null); + if (!string.IsNullOrEmpty(value)) + model.SetCreationDate(value); + + // Return the session's note as the abstract portion of the package's description. + value = session.MetaDataFile.GetStringValue(SessionFileType.kSynopsisFieldName, null); + if (!string.IsNullOrEmpty(value)) + model.SetAbstract(value, string.Empty); + + // Set contributors + var contribsVal = session.MetaDataFile.GetValue(SessionFileType.kContributionsFieldName, null); + if (contribsVal is ContributionCollection contributions && contributions.Count > 0) + model.SetContributors(contributions); + + // Return total duration of source audio/video recordings. + TimeSpan totalDuration = session.GetTotalDurationOfSourceMedia(); + if (totalDuration.Ticks > 0) + model.SetAudioVideoExtent($"Total Length of Source Recordings: {totalDuration}"); + + //First session details are enough for "Archive RAMP (SIL)..." from Project menu + break; + } + } + /// ------------------------------------------------------------------------------------ public string ArchiveInfoDetails => LocalizationManager.GetString("DialogBoxes.ArchivingDlg.ProjectArchivingInfoDetails", @@ -898,66 +953,25 @@ public IEnumerable GetSessionFilesToArchive(Type typeOfArchive, public void TrackStatistics(StatisticsViewModel statisticsViewModel) { if (_statisticsViewModel != null) - { _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; - _statisticsViewModel.NewStatisticsAvailable -= UpdateStatistics; - } _statisticsViewModel = statisticsViewModel; _statisticsViewModel.FinishedGatheringStatisticsForAllFiles += FinishedGatheringStatistics; + if (_statisticsViewModel.IsDataUpToDate) + { + // It either finished before we could hook the event, or we hit the race condition. + FinishedGatheringStatistics(_statisticsViewModel, null); + } } private void FinishedGatheringStatistics(object sender, EventArgs e) { _statisticsViewModel.FinishedGatheringStatisticsForAllFiles -= FinishedGatheringStatistics; - _initialNumberOfSessions = _finalNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; - _initialNumberOfPersons = _finalNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; - _mediaDurationStats = - _statisticsViewModel.GetComponentRoleStatisticsPairs() - .ToDictionary( - s => s.Name, - s => new MediaDurationStats(s.Length)); - - _sessionRoleStats = _statisticsViewModel.SessionInformant.GetSessionsCategorizedByStage() - .Where(s => s.Key.MeasurementType != Time) - .ToDictionary(s => s.Key.Id, s => new RoleCountStats(s.Value.Count())); + // Check for race condition (see above). + if (_progressStats != null) + return; - _statisticsViewModel.NewStatisticsAvailable += UpdateStatistics; - } - - private void UpdateStatistics(object sender, EventArgs e) - { - _finalNumberOfSessions = _statisticsViewModel.SessionInformant.NumberOfSessions; - _finalNumberOfPersons = _statisticsViewModel.PersonInformant.NumberOfPeople; - - foreach (var stat in _statisticsViewModel.GetComponentRoleStatisticsPairs()) - { - if (_mediaDurationStats.TryGetValue(stat.Name, out var entry)) - entry.Current = stat.Length; - else - { - _mediaDurationStats[stat.Name] = - new MediaDurationStats(TimeSpan.Zero) - { - Current = stat.Length - }; - } - } - - foreach (var stat in _statisticsViewModel.SessionInformant.GetSessionsCategorizedByStage() - .Where(s => s.Key.MeasurementType != Time)) - { - if (_sessionRoleStats.TryGetValue(stat.Key.Id, out var entry)) - entry.Current = stat.Value.Count(); - else - { - _sessionRoleStats[stat.Key.Id] = - new RoleCountStats(0) - { - Current = stat.Value.Count() - }; - } - } + _progressStats = new ProgressStats(_statisticsViewModel); } } } diff --git a/src/SayMore/Program.cs b/src/SayMore/Program.cs index 680a8d22..7f2a60dd 100644 --- a/src/SayMore/Program.cs +++ b/src/SayMore/Program.cs @@ -235,7 +235,7 @@ static void Main() { Application.Run(); Settings.Default.Save(); - _projectContext?.Project.ReportProgressIfNeeded(); + _projectContext?.Project.ReportProgressIfAny(); Analytics.FlushClient(); Logger.WriteEvent("SayMore shutting down"); if (s_countOfContiguousFirstChanceOutOfMemoryExceptions > 1) @@ -600,7 +600,7 @@ static void ChooseAnotherProject(object sender, EventArgs e) /// ------------------------------------------------------------------------------------ private static void HandleProjectWindowClosed(object sender, EventArgs e) { - _projectContext?.Project.ReportProgressIfNeeded(); + _projectContext?.Project.ReportProgressIfAny(); SafelyDisposeProjectContext(); ReleaseMutexForThisProject(); diff --git a/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs b/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs index 44ce0b61..80205b8b 100644 --- a/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs +++ b/src/SayMore/UI/Overview/Statistics/StatisticsViewModel.cs @@ -36,8 +36,8 @@ public StatisticsViewModel(Project project, PersonInformant personInformant, SessionInformant = sessionInformant; _componentRoles = componentRoles; _backgroundStatisticsGatherer = backgroundStatisticsManager; - _backgroundStatisticsGatherer.NewDataAvailable += HandleNewStatistics; _backgroundStatisticsGatherer.FinishedProcessingAllFiles += HandleFinishedGatheringStatisticsForAllFiles; + _backgroundStatisticsGatherer.NewDataAvailable += HandleNewStatistics; project?.TrackStatistics(this);