2
0
mirror of https://github.com/9ParsonsB/Pulsar.git synced 2025-04-05 17:39:39 -04:00

Export version fixes (#83)

* Add file association for .eop, prompt for install dir

* Handle .eop or .aip file passed as arg.

* VS2022 version bump

* Filter neutron stars and black holes from fast spinning criteria.

* Adjustments for new "high value" check

* Refactor herald cache

* Fix element order and namespaces for voice moods.

* Add explicit .Stop() between audio player calls.

* Use nullsafe member access instead of skipping

* Don't queue up a title that's already queued.

* Improve body ordinal handling for explorer speech titles.

* Escape strings being inserted into xml

* Handle flip-flopping JSON type

* Converter for flip-flopping property type

* Use the converter

* Escape characters *before* we wrap it in xml.

* Give Eahlstan his clear button. :D

* Exclude all stars from fast rotation check.

* Close outstanding popup notifications on exit.

* TO DONE

* [Herald] Suppress duplicate notification titles for spoken notifications

If you have notifications from multiple plugins producing notifications with the same title in quick succession (ie. "Body A 1 e" from both Explorer and BioInsights), the title on successive notifications will not be spoken again to save the breath of our friendly Azure speakers.

* Doc update

* Remove unintended member hiding

* Fix export errors when exporting BioInsights data, cleanup

Discovered a couple issues with exporting BioInsights data resulting from using two different types of objects in the data grid; improved error handling as well.

Also cleaned up some old-style read all code.

* Add read-all on launch setting

* Updated framework xml

* Improve high-value body description text

Co-authored-by: Fred Kuipers <mr.fredk@gmail.com>
This commit is contained in:
Jonathan Miller 2022-05-21 13:00:47 -02:30 committed by GitHub
parent 921f3867fa
commit 8de34a141c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 495 additions and 216 deletions

View File

@ -19,6 +19,7 @@ AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=false
DefaultGroupName={#MyAppName}
AllowNoIcons=yes
LicenseFile=C:\Users\Xjph\Source\Repos\MIT.txt
@ -32,6 +33,7 @@ Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64
ChangesAssociations=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
@ -77,6 +79,13 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Registry]
Root: HKA; Subkey: "Software\Classes\.eop\OpenWithProgids"; ValueType: string; ValueName: "ObservatoryPlugin.eop"; ValueData: ""; Flags: uninsdeletevalue
Root: HKA; Subkey: "Software\Classes\ObservatoryPlugin.eop"; ValueType: string; ValueName: ""; ValueData: "Elite Observatory Plugin"; Flags: uninsdeletekey
Root: HKA; Subkey: "Software\Classes\ObservatoryPlugin.eop\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"
Root: HKA; Subkey: "Software\Classes\ObservatoryPlugin.eop\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""
Root: HKA; Subkey: "Software\Classes\Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".eop"; ValueData: ""
[Code]
type

View File

@ -8,7 +8,6 @@ namespace Observatory.NativeNotification
{
public class NativePopup
{
// TODO: This needs to be cleaned up when the app is closed.
private Dictionary<Guid, NotificationView> notifications;
public NativePopup()
@ -67,5 +66,13 @@ namespace Observatory.NativeNotification
});
}
}
public void CloseAll()
{
foreach (var notification in notifications)
{
notification.Value?.Close();
}
}
}
}

View File

@ -9,6 +9,15 @@ namespace Observatory
[STAThread]
static void Main(string[] args)
{
if (args.Length > 0 && System.IO.File.Exists(args[0]))
{
var fileInfo = new System.IO.FileInfo(args[0]);
if (fileInfo.Extension == ".eop" || fileInfo.Extension == ".zip")
System.IO.File.Copy(
fileInfo.FullName,
$"{AppDomain.CurrentDomain.BaseDirectory}{System.IO.Path.DirectorySeparatorChar}plugins{System.IO.Path.DirectorySeparatorChar}{fileInfo.Name}");
}
string version = System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString();
try
{

View File

@ -170,5 +170,10 @@ namespace Observatory.PluginManagement
return folderLocation;
}
}
internal void Shutdown()
{
NativePopup.CloseAll();
}
}
}

View File

@ -34,6 +34,7 @@ namespace Observatory.PluginManagement
public readonly List<DataTable> pluginTables;
public readonly List<(IObservatoryWorker plugin, PluginStatus signed)> workerPlugins;
public readonly List<(IObservatoryNotifier plugin, PluginStatus signed)> notifyPlugins;
private readonly PluginCore core;
private PluginManager()
{
@ -48,7 +49,7 @@ namespace Observatory.PluginManagement
logMonitor.StatusUpdate += pluginHandler.OnStatusUpdate;
logMonitor.LogMonitorStateChanged += pluginHandler.OnLogMonitorStateChanged;
var core = new PluginCore();
core = new PluginCore();
List<IObservatoryPlugin> errorPlugins = new();
@ -346,6 +347,11 @@ namespace Observatory.PluginManagement
return err;
}
internal void Shutdown()
{
core.Shutdown();
}
private static void LoadPlaceholderPlugin(string dllPath, PluginStatus pluginStatus, List<(IObservatoryNotifier plugin, PluginStatus signed)> notifiers)
{
PlaceholderPlugin placeholder = new(new FileInfo(dllPath).Name);

View File

@ -262,5 +262,17 @@ namespace Observatory.Properties {
this["ExportFolder"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
public bool StartReadAll {
get {
return ((bool)(this["StartReadAll"]));
}
set {
this["StartReadAll"] = value;
}
}
}
}

View File

@ -62,5 +62,8 @@
<Setting Name="ExportFolder" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="StartReadAll" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
</Settings>
</SettingsFile>

View File

@ -21,6 +21,11 @@ namespace Observatory.UI
{
DataContext = new MainWindowViewModel(pluginManager)
};
desktop.MainWindow.Closing += (o, e) =>
{
pluginManager.Shutdown();
};
}
base.OnFrameworkInitializationCompleted();

View File

@ -1,10 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Observatory.UI.Models
namespace Observatory.UI.Models
{
public class NotificationModel
{

View File

@ -58,19 +58,16 @@ namespace Observatory.UI.ViewModels
tabs.Add(new CoreModel() { Name = "Core", UI = new BasicUIViewModel(new ObservableCollection<object>()) { UIType = Framework.PluginUI.UIType.Core } });
if (Properties.Core.Default.StartMonitor)
{
ToggleMonitor();
}
if (Properties.Core.Default.StartReadAll)
ReadAll();
}
public void ReadAll()
{
// TODO(fredjk_gh): remove.
SetWorkerReadAllState(true);
LogMonitor.GetInstance.ReadAllJournals();
// TODO(fredjk_gh): remove.
SetWorkerReadAllState(false);
}
public void ToggleMonitor()
@ -84,12 +81,7 @@ namespace Observatory.UI.ViewModels
}
else
{
// HACK: Find a better way of suppressing notifications when pre-reading.
// TODO(fredjk_gh): remove.
SetWorkerReadAllState(true);
logMonitor.Start();
// TODO(fredjk_gh): remove.
SetWorkerReadAllState(false);
ToggleButtonText = "Stop Monitor";
}
}
@ -117,100 +109,129 @@ namespace Observatory.UI.ViewModels
public async void ExportGrid()
{
var exportFolder = Properties.Core.Default.ExportFolder;
if (string.IsNullOrEmpty(exportFolder))
try
{
exportFolder = System.Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
}
var exportFolder = Properties.Core.Default.ExportFolder;
OpenFolderDialog openFolderDialog = new()
{
Directory = exportFolder
};
var application = (IClassicDesktopStyleApplicationLifetime)Avalonia.Application.Current.ApplicationLifetime;
var selectedFolder = await openFolderDialog.ShowAsync(application.MainWindow);
if (!string.IsNullOrEmpty(selectedFolder))
{
Properties.Core.Default.ExportFolder = selectedFolder;
Properties.Core.Default.Save();
exportFolder = selectedFolder;
foreach (var tab in tabs.Where(t => t.Name != "Core"))
if (string.IsNullOrEmpty(exportFolder))
{
var ui = (BasicUIViewModel)tab.UI;
List<object> selectedData;
bool specificallySelected = ui.SelectedItems?.Count > 1;
exportFolder = System.Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
}
if (specificallySelected)
OpenFolderDialog openFolderDialog = new()
{
Directory = exportFolder
};
var application = (IClassicDesktopStyleApplicationLifetime)Avalonia.Application.Current.ApplicationLifetime;
var selectedFolder = await openFolderDialog.ShowAsync(application.MainWindow);
if (!string.IsNullOrEmpty(selectedFolder))
{
Properties.Core.Default.ExportFolder = selectedFolder;
Properties.Core.Default.Save();
exportFolder = selectedFolder;
foreach (var tab in tabs.Where(t => t.Name != "Core"))
{
selectedData = new();
var ui = (BasicUIViewModel)tab.UI;
List<object> selectedData;
bool specificallySelected = ui.SelectedItems?.Count > 1;
foreach (var item in ui.SelectedItems)
selectedData.Add(item);
}
else
{
selectedData = ui.BasicUIGrid.ToList();
}
var columns = selectedData[0].GetType().GetProperties();
Dictionary<string, int> colSize = new();
Dictionary<string, List<string>> colContent = new();
foreach (var column in columns)
{
colSize.Add(column.Name, 0);
colContent.Add(column.Name, new());
}
var lineType = selectedData[0].GetType();
foreach (var line in selectedData)
{
foreach (var column in colContent)
if (specificallySelected)
{
var cellValue = lineType.GetProperty(column.Key).GetValue(line)?.ToString() ?? string.Empty;
column.Value.Add(cellValue);
if (colSize[column.Key] < cellValue.Length)
colSize[column.Key] = cellValue.Length;
selectedData = new();
foreach (var item in ui.SelectedItems)
selectedData.Add(item);
}
}
System.Text.StringBuilder exportData = new();
foreach (var colTitle in colContent.Keys)
{
if (colSize[colTitle] < colTitle.Length)
colSize[colTitle] = colTitle.Length;
exportData.Append(colTitle.PadRight(colSize[colTitle]) + " ");
}
exportData.AppendLine();
for (int i = 0; i < colContent.First().Value.Count; i++)
{
foreach(var column in colContent)
else
{
if (column.Value[i].Length > 0 && !char.IsNumber(column.Value[i][0]) && column.Value[i].Count(char.IsLetter) / (float)column.Value[i].Length > 0.25)
exportData.Append(column.Value[i].PadRight(colSize[column.Key]) + " ");
else
exportData.Append(column.Value[i].PadLeft(colSize[column.Key]) + " ");
selectedData = ui.BasicUIGrid.ToList();
}
var columns = selectedData[0].GetType().GetProperties();
Dictionary<string, int> colSize = new();
Dictionary<string, List<string>> colContent = new();
foreach (var column in columns)
{
colSize.Add(column.Name, 0);
colContent.Add(column.Name, new());
}
foreach (var line in selectedData)
{
var lineType = line.GetType(); // some plugins have different line types, so don't move this out of loop
foreach (var column in colContent)
{
var cellValue = lineType.GetProperty(column.Key)?.GetValue(line)?.ToString() ?? string.Empty;
column.Value.Add(cellValue);
if (colSize[column.Key] < cellValue.Length)
colSize[column.Key] = cellValue.Length;
}
}
System.Text.StringBuilder exportData = new();
foreach (var colTitle in colContent.Keys)
{
if (colSize[colTitle] < colTitle.Length)
colSize[colTitle] = colTitle.Length;
exportData.Append(colTitle.PadRight(colSize[colTitle]) + " ");
}
exportData.AppendLine();
for (int i = 0; i < colContent.First().Value.Count; i++)
{
foreach (var column in colContent)
{
if (column.Value[i].Length > 0 && !char.IsNumber(column.Value[i][0]) && column.Value[i].Count(char.IsLetter) / (float)column.Value[i].Length > 0.25)
exportData.Append(column.Value[i].PadRight(colSize[column.Key]) + " ");
else
exportData.Append(column.Value[i].PadLeft(colSize[column.Key]) + " ");
}
exportData.AppendLine();
}
string exportPath = $"{exportFolder}{System.IO.Path.DirectorySeparatorChar}Observatory Export - {DateTime.UtcNow:yyyyMMdd-HHmmss} - {tab.Name}.txt";
System.IO.File.WriteAllText(exportPath, exportData.ToString());
}
string exportPath = $"{exportFolder}{System.IO.Path.DirectorySeparatorChar}Observatory Export - {DateTime.UtcNow:yyyyMMdd-HHmmss} - {tab.Name}.txt";
System.IO.File.WriteAllText(exportPath, exportData.ToString());
}
}
catch (Exception e)
{
ObservatoryCore.LogError(e, "while exporting data");
ErrorReporter.ShowErrorPopup("Error encountered!",
"An error occurred while exporting; output may be missing or incomplete." + Environment.NewLine +
"Please check the error log (found in your Documents folder) for more details and visit our discord to report it.");
}
}
public void ClearGrid()
{
foreach (var tab in tabs.Where(t => t.Name != "Core"))
{
var ui = (BasicUIViewModel)tab.UI;
var rowTemplate = ui.BasicUIGrid.First();
foreach (var property in rowTemplate.GetType().GetProperties())
{
property.SetValue(rowTemplate, null);
}
ui.BasicUIGrid.Clear();
ui.BasicUIGrid.Add(rowTemplate);
// For some reason UIType's change event will properly
// redraw the grid, not BasicUIGrid's.
ui.RaisePropertyChanged(nameof(ui.UIType));
}
}
public string ToggleButtonText
@ -241,22 +262,6 @@ namespace Observatory.UI.ViewModels
get { return tabs; }
}
// TODO(fredjk_gh): remove.
private void SetWorkerReadAllState(bool isReadingAll)
{
foreach (var worker in workers)
{
if (isReadingAll)
{
worker.ReadAllStarted();
}
else
{
worker.ReadAllFinished();
}
}
}
private static bool CheckUpdate()
{
try

View File

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Observatory.Framework;
using Observatory.Framework;
namespace Observatory.UI.ViewModels
{

View File

@ -27,6 +27,11 @@ namespace Observatory.UI.Views
{
InitializeComponent();
nativePopup = new();
this.DetachedFromVisualTree += (o, e) =>
{
nativePopup.CloseAll();
};
}
private void InitializeComponent()
@ -666,7 +671,7 @@ namespace Observatory.UI.Views
#endregion
#region Monitor On Launch
#region Actions On Launch
TextBlock startMonitorLabel = new() { Text = "Start monitor on Observatory launch" };
CheckBox startMonitorCheckbox = new() { IsChecked = Properties.Core.Default.StartMonitor, Content = startMonitorLabel };
@ -683,6 +688,21 @@ namespace Observatory.UI.Views
Properties.Core.Default.Save();
};
TextBlock startReadAllLabel = new() { Text = "Read All on Observatory launch" };
CheckBox startReadAllCheckbox = new() { IsChecked = Properties.Core.Default.StartReadAll, Content = startReadAllLabel };
startReadAllCheckbox.Checked += (object sender, RoutedEventArgs e) =>
{
Properties.Core.Default.StartReadAll = true;
Properties.Core.Default.Save();
};
startReadAllCheckbox.Unchecked += (object sender, RoutedEventArgs e) =>
{
Properties.Core.Default.StartReadAll = false;
Properties.Core.Default.Save();
};
#endregion
#endregion
@ -753,6 +773,7 @@ namespace Observatory.UI.Views
gridManager.AddSetting(primeSystemContexCheckbox);
gridManager.AddSetting(startMonitorCheckbox);
gridManager.AddSetting(startReadAllCheckbox);
gridManager.AddSettingWithLabel(journalPathLabel, journalPath);
gridManager.AddSetting(journalBrowse);

View File

@ -81,6 +81,14 @@
Content="Export">
Export
</Button>
<Button
Name="clear"
Margin="10"
FontSize="15"
Command="{Binding ClearGrid}"
Content="Clear">
Clear
</Button>
<Button
Name="ToggleMonitor"
Margin="10"

View File

@ -9,18 +9,6 @@ namespace Observatory.Explorer
{
internal static class DefaultCriteria
{
private static IList<string> HighValueNonTerraformablePlanetClasses = new string[] {
"Earthlike body",
"Ammonia world",
"Water world",
};
private static IList<string> HighValueTerraformablePlanetClasses = new string[] {
"Water world",
"High metal content body",
"Rocky body",
};
public static List<(string Description, string Detail, bool SystemWide)> CheckInterest(Scan scan, Dictionary<ulong, Dictionary<int, Scan>> scanHistory, Dictionary<ulong, Dictionary<int, FSSBodySignals>> signalHistory, ExplorerSettings settings)
{
List<(string, string, bool)> results = new();
@ -61,13 +49,25 @@ namespace Observatory.Explorer
#region Value Checks
if (settings.HighValueMappable)
{
if (HighValueTerraformablePlanetClasses.Contains(scan.PlanetClass) && scan.TerraformState?.Length > 0)
IList<string> HighValueNonTerraformablePlanetClasses = new string[] {
"Earthlike body",
"Ammonia world",
"Water world",
};
if (HighValueNonTerraformablePlanetClasses.Contains(scan.PlanetClass) || scan.TerraformState?.Length > 0)
{
results.Add("High-Value Mapping", $"{scan.DistanceFromArrivalLS:0}Ls, {scan.PlanetClass} (TF)");
}
if (HighValueNonTerraformablePlanetClasses.Contains(scan.PlanetClass) && scan.TerraformState?.Length == 0)
{
results.Add("High-Value Mapping", $"{scan.DistanceFromArrivalLS:0}Ls, {scan.PlanetClass}");
var info = new System.Text.StringBuilder();
if (!scan.WasDiscovered)
info.Append("Undiscovered ");
else if (!scan.WasMapped)
info.Append("Unmapped ");
if (scan.TerraformState?.Length > 0)
info.Append("Terraformable ");
results.Add("High-Value Body", $"{(info.Length > 1 ? info.ToString() : string.Empty)}{textInfo.ToTitleCase(scan.PlanetClass)}, {scan.DistanceFromArrivalLS:N0}Ls");
}
}
#endregion
@ -143,7 +143,7 @@ namespace Observatory.Explorer
results.Add("Nested Moon");
}
if (settings.FastRotation && scan.RotationPeriod != 0 && !scan.TidalLock && Math.Abs(scan.RotationPeriod) < 28800 && !isRing)
if (settings.FastRotation && scan.RotationPeriod != 0 && !scan.TidalLock && Math.Abs(scan.RotationPeriod) < 28800 && !isRing && string.IsNullOrEmpty(scan.StarType))
{
results.Add("Non-locked Body with Fast Rotation", $"Period: {scan.RotationPeriod/3600:N1} hours");
}

View File

@ -212,7 +212,7 @@ namespace Observatory.Explorer
bodyAffix = string.Empty;
}
string bodyLabel = scanEvent.PlanetClass == "Barycentre" ? "Barycentre" : "Body";
string bodyLabel = System.Security.SecurityElement.Escape(scanEvent.PlanetClass == "Barycentre" ? "Barycentre" : "Body");
string spokenAffix;
@ -222,13 +222,13 @@ namespace Observatory.Explorer
{
int ringIndex = bodyAffix.Length - 6;
spokenAffix =
"<say-as interpret-as=\"spell-out\">" + bodyAffix.Substring(0, ringIndex) +
"</say-as><break strength=\"weak\"/><say-as interpret-as=\"spell-out\">" +
bodyAffix.Substring(ringIndex, 1) + "</say-as>" + bodyAffix.Substring(ringIndex + 1, bodyAffix.Length - (ringIndex + 1));
"<say-as interpret-as=\"spell-out\">" + bodyAffix[..ringIndex]
+ "</say-as><break strength=\"weak\"/>" + SplitOrdinalForSsml(bodyAffix.Substring(ringIndex, 1))
+ bodyAffix[(ringIndex + 1)..];
}
else
{
spokenAffix = "<say-as interpret-as=\"spell-out\">" + bodyAffix + "</say-as>";
spokenAffix = SplitOrdinalForSsml(bodyAffix);
}
}
else
@ -248,5 +248,18 @@ namespace Observatory.Explorer
}
}
private static string SplitOrdinalForSsml(string ordinalString)
{
var ordinalSegments = ordinalString.Split(' ');
StringBuilder affix = new();
foreach (var ordinalSegment in ordinalSegments)
{
if (ordinalSegment.All(Char.IsDigit))
affix.Append(" " + ordinalSegment);
else
affix.Append("<say-as interpret-as=\"spell-out\">" + ordinalSegment + "</say-as>");
}
return affix.ToString();
}
}
}

View File

@ -80,7 +80,7 @@ namespace Observatory.Explorer
[SettingDisplayName("All Surface Mats In System")]
public bool GoldSystem { get; set; }
[SettingDisplayName("High-Value Mapping")]
[SettingDisplayName("High-Value Body")]
public bool HighValueMappable { get; set; }
[SettingDisplayName("Enable Custom Criteria")]

View File

@ -55,29 +55,81 @@ namespace Observatory.Framework
/// </summary>
public double YPos = -1.0;
/// <summary>
/// Specifies the desired renderings of the notification.
/// Specifies the desired renderings of the notification. Defaults to <see cref="NotificationRendering.All"/>.
/// </summary>
public NotificationRendering Rendering = NotificationRendering.All;
/// <summary>
/// Specifies if some part of the notification should be suppressed. Not supported by all notifiers. Defaults to <see cref="NotificationSuppression.None"/>.
/// </summary>
public NotificationSuppression Suppression = NotificationSuppression.None;
}
/// <summary>
/// Defines constants for suppression of title or detail announcement in a notification.
/// </summary>
[Flags]
public enum NotificationSuppression
{
/// <summary>
/// No suppression.
/// </summary>
None = 0,
/// <summary>
/// Suppress title.
/// </summary>
Title = 1,
/// <summary>
/// Suppress detail.
/// </summary>
Detail = 2,
}
/// <summary>
/// Defines constants for controlling notification routing to plugins or native notification handlers.
/// </summary>
[Flags]
public enum NotificationRendering
{
// These need to be multiples of 2 as they're used via masking.
/// <summary>
/// Send notification to native visual popup notificaiton handler.
/// </summary>
NativeVisual = 1,
/// <summary>
/// Send notification to native speech notification handler.
/// </summary>
NativeVocal = 2,
/// <summary>
/// Send notification to all installed notifier plugins.
/// </summary>
PluginNotifier = 4,
/// <summary>
/// Send notification to all available handlers.
/// </summary>
All = (NativeVisual | NativeVocal | PluginNotifier)
}
/// <summary>
/// Flags indicating current state of journal monitoring.
/// </summary>
[Flags]
public enum LogMonitorState : uint
{
// These need to be multiples of 2 as they're used via masking.
Idle = 0, // Monitoring is stopped
Realtime = 1, // Real-time monitoring is active
Batch = 2, // Performing a batch read of history
PreRead = 4 // Currently pre-reading current system context (a batch read)
/// <summary>
/// Monitoring is stopped.
/// </summary>
Idle = 0,
/// <summary>
/// Real-time monitoring is active.
/// </summary>
Realtime = 1,
/// <summary>
/// Batch read of historical journals is in progress.
/// </summary>
Batch = 2,
/// <summary>
/// Batch read of recent journals is in progress to establish current player state.
/// </summary>
PreRead = 4
}
/// <summary>

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Observatory.Framework.Files.Converters
{
class MutableStringDoubleConverter : JsonConverter<object>
{
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
return reader.GetString();
else
return reader.GetDouble();
}
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
throw new NotImplementedException();
}
}
}

View File

@ -3,6 +3,5 @@
public class CrewLaunchFighter : CrewMemberJoins
{
public ulong ID { get; init; }
public bool Telepresence { get; init; }
}
}

View File

@ -1,7 +1,5 @@
namespace Observatory.Framework.Files.Journal
{
public class CrewMemberQuits : CrewMemberJoins
{
public bool Telepresence { get; init; }
}
{ }
}

View File

@ -3,6 +3,5 @@
public class CrewMemberRoleChange : CrewMemberJoins
{
public string Role { get; init; }
public bool Telepresence { get; init; }
}
}

View File

@ -1,7 +1,5 @@
namespace Observatory.Framework.Files.Journal
{
public class QuitACrew : JoinACrew
{
public bool Telepresence { get; init; }
}
{ }
}

View File

@ -6,11 +6,32 @@ namespace Observatory.Framework.Files.ParameterTypes
{
public string Label { get; init; }
public double Value { get; init; }
[JsonConverter(typeof(Converters.MutableStringDoubleConverter))]
public object Value
{
get
{
if (!string.IsNullOrEmpty(ValueString))
return ValueString;
else
return ValueNumeric;
}
init
{
if (value.GetType() == typeof(string))
ValueString = value.ToString();
else
ValueNumeric = (double)value;
}
}
public double OriginalValue { get; init; }
[JsonConverter(typeof(Converters.IntBoolConverter))]
public bool LessIsGood { get; init; }
private double ValueNumeric;
private string ValueString;
}
}

View File

@ -135,7 +135,82 @@
</member>
<member name="F:Observatory.Framework.NotificationArgs.Rendering">
<summary>
Specifies the desired renderings of the notification.
Specifies the desired renderings of the notification. Defaults to <see cref="F:Observatory.Framework.NotificationRendering.All"/>.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationArgs.Suppression">
<summary>
Specifies if some part of the notification should be suppressed. Not supported by all notifiers. Defaults to <see cref="F:Observatory.Framework.NotificationSuppression.None"/>.
</summary>
</member>
<member name="T:Observatory.Framework.NotificationSuppression">
<summary>
Defines constants for suppression of title or detail announcement in a notification.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationSuppression.None">
<summary>
No suppression.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationSuppression.Title">
<summary>
Suppress title.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationSuppression.Detail">
<summary>
Suppress detail.
</summary>
</member>
<member name="T:Observatory.Framework.NotificationRendering">
<summary>
Defines constants for controlling notification routing to plugins or native notification handlers.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationRendering.NativeVisual">
<summary>
Send notification to native visual popup notificaiton handler.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationRendering.NativeVocal">
<summary>
Send notification to native speech notification handler.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationRendering.PluginNotifier">
<summary>
Send notification to all installed notifier plugins.
</summary>
</member>
<member name="F:Observatory.Framework.NotificationRendering.All">
<summary>
Send notification to all available handlers.
</summary>
</member>
<member name="T:Observatory.Framework.LogMonitorState">
<summary>
Flags indicating current state of journal monitoring.
</summary>
</member>
<member name="F:Observatory.Framework.LogMonitorState.Idle">
<summary>
Monitoring is stopped.
</summary>
</member>
<member name="F:Observatory.Framework.LogMonitorState.Realtime">
<summary>
Real-time monitoring is active.
</summary>
</member>
<member name="F:Observatory.Framework.LogMonitorState.Batch">
<summary>
Batch read of historical journals is in progress.
</summary>
</member>
<member name="F:Observatory.Framework.LogMonitorState.PreRead">
<summary>
Batch read of recent journals is in progress to establish current player state.
</summary>
</member>
<member name="T:Observatory.Framework.LogMonitorStateChangedEventArgs">

View File

@ -43,6 +43,15 @@ namespace Observatory.Herald
// to make perceived volume more in line with value set.
this.volume = ((byte)System.Math.Floor(System.Math.Pow(volume / 100.0, 2.0) * 100));
Debug.WriteLine("Attempting to de-dupe notification titles against '{0}': '{1}'",
notification.Title.Trim().ToLower(),
String.Join(',', notifications.Select(n => n.Title.Trim().ToLower())));
if (notifications.Where(n => n.Title.Trim().ToLower() == notification.Title.Trim().ToLower()).Any())
{
// Suppress title.
notification.Suppression |= NotificationSuppression.Title;
}
notifications.Enqueue(notification);
if (!processing)
@ -59,7 +68,7 @@ namespace Observatory.Herald
private void ProcessQueue()
{
Thread.Sleep(200); // Allow time for other notifications to arrive.
NotificationArgs notification = null;
try
{
@ -69,24 +78,20 @@ namespace Observatory.Herald
notification = notifications.Dequeue();
Debug.WriteLine("Processing notification: {0} - {1}", notification.Title, notification.Detail);
Task<string>[] audioRequestTasks = new Task<string>[2];
List<Task<string>> audioRequestTasks = new();
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
if (!notification.Suppression.HasFlag(NotificationSuppression.Title))
{
audioRequestTasks[0] = RetrieveAudioToFile(notification.Title);
}
else
{
audioRequestTasks[0] = RetrieveAudioSsmlToFile(notification.TitleSsml);
audioRequestTasks.Add(string.IsNullOrWhiteSpace(notification.TitleSsml)
? RetrieveAudioToFile(notification.Title)
: RetrieveAudioSsmlToFile(notification.TitleSsml));
}
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
if (!notification.Suppression.HasFlag(NotificationSuppression.Detail))
{
audioRequestTasks[1] = RetrieveAudioToFile(notification.Detail);
}
else
{
audioRequestTasks[1] = RetrieveAudioSsmlToFile(notification.DetailSsml);
audioRequestTasks.Add(string.IsNullOrWhiteSpace(notification.DetailSsml)
? RetrieveAudioToFile(notification.Detail)
: RetrieveAudioSsmlToFile(notification.DetailSsml));
}
PlayAudioRequestsSequentially(audioRequestTasks);
@ -105,7 +110,7 @@ namespace Observatory.Herald
private async Task<string> RetrieveAudioToFile(string text)
{
return await RetrieveAudioSsmlToFile($"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{text}</voice></speak>");
return await RetrieveAudioSsmlToFile($"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{System.Security.SecurityElement.Escape(text)}</voice></speak>");
}
private async Task<string> RetrieveAudioSsmlToFile(string ssml)
@ -113,7 +118,7 @@ namespace Observatory.Herald
return await speechManager.GetAudioFileFromSsml(ssml, voice, style, rate);
}
private void PlayAudioRequestsSequentially(Task<string>[] requestTasks)
private void PlayAudioRequestsSequentially(List<Task<string>> requestTasks)
{
foreach (var request in requestTasks)
{
@ -131,7 +136,12 @@ namespace Observatory.Herald
while (audioPlayer.Playing)
Thread.Sleep(50);
// Explicit stop to ensure device is ready for next file.
// ...hopefully.
audioPlayer.Stop(true).Wait();
}
speechManager.CommitCache();
}
}
}

View File

@ -13,7 +13,7 @@ namespace NetCoreAudio.Interfaces
Task Play(string fileName);
Task Pause();
Task Resume();
Task Stop();
Task Stop(bool force);
Task SetVolume(byte percent);
}
}

View File

@ -71,9 +71,9 @@ namespace NetCoreAudio
/// Stops any current playback and clears the buffer. Sets Playing and Paused flags to false.
/// </summary>
/// <returns></returns>
public async Task Stop()
public async Task Stop(bool force = false)
{
await _internalPlayer.Stop();
await _internalPlayer.Stop(force);
}
private void OnPlaybackFinished(object sender, EventArgs e)

View File

@ -56,7 +56,7 @@ namespace NetCoreAudio.Players
return Task.CompletedTask;
}
public Task Stop()
public Task Stop(bool force = false)
{
if (_process != null)
{

View File

@ -76,15 +76,15 @@ namespace NetCoreAudio.Players
return Task.CompletedTask;
}
public Task Stop()
public Task Stop(bool force = false)
{
if (Playing)
if (Playing || force)
{
ExecuteMciCommand($"Stop {_fileName}");
Playing = false;
Paused = false;
_playbackTimer.Stop();
_playStopwatch.Stop();
_playbackTimer?.Stop();
_playStopwatch?.Stop();
}
return Task.CompletedTask;
}

View File

@ -19,7 +19,7 @@ namespace Observatory.Herald {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class ObservatoryAPI {

View File

@ -9,6 +9,7 @@ using System.Text.Json;
using System.Xml;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Collections.Concurrent;
namespace Observatory.Herald
{
@ -19,7 +20,8 @@ namespace Observatory.Herald
private string ApiEndpoint;
private DirectoryInfo cacheLocation;
private int cacheSize;
private Action<Exception, String> ErrorLogger;
private Action<Exception, string> ErrorLogger;
private ConcurrentDictionary<string, CacheData> cacheIndex;
internal SpeechRequestManager(
HeraldSettings settings, HttpClient httpClient, string cacheFolder, Action<Exception, String> errorLogger)
@ -29,6 +31,7 @@ namespace Observatory.Herald
this.httpClient = httpClient;
cacheSize = Math.Max(settings.CacheSize, 1);
cacheLocation = new DirectoryInfo(cacheFolder);
ReadCache();
ErrorLogger = errorLogger;
if (!Directory.Exists(cacheLocation.FullName))
@ -103,26 +106,27 @@ namespace Observatory.Herald
XmlNamespaceManager ssmlNs = new(ssmlDoc.NameTable);
ssmlNs.AddNamespace("ssml", ssmlNamespace);
ssmlNs.AddNamespace("mstts", "http://www.w3.org/2001/mstts");
ssmlNs.AddNamespace("emo", "http://www.w3.org/2009/10/emotionml");
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
if (!string.IsNullOrWhiteSpace(styleName))
{
var expressAsNode = ssmlDoc.CreateElement("express-as", "http://www.w3.org/2001/mstts");
expressAsNode.SetAttribute("style", styleName);
expressAsNode.InnerXml = voiceNode.InnerXml;
voiceNode.InnerXml = expressAsNode.OuterXml;
}
if (!string.IsNullOrWhiteSpace(rate))
{
var prosodyNode = ssmlDoc.CreateElement("prosody", ssmlNamespace);
var prosodyNode = ssmlDoc.CreateElement("ssml", "prosody", ssmlNamespace);
prosodyNode.SetAttribute("rate", rate);
prosodyNode.InnerXml = voiceNode.InnerXml;
voiceNode.InnerXml = prosodyNode.OuterXml;
}
if (!string.IsNullOrWhiteSpace(styleName))
{
var expressAsNode = ssmlDoc.CreateElement("mstts", "express-as", "http://www.w3.org/2001/mstts");
expressAsNode.SetAttribute("style", styleName);
expressAsNode.InnerXml = voiceNode.InnerXml;
voiceNode.InnerXml = expressAsNode.OuterXml;
}
return ssmlDoc.OuterXml;
}
@ -229,10 +233,8 @@ namespace Observatory.Herald
return demonym;
}
private async void UpdateAndPruneCache(FileInfo currentFile)
private void ReadCache()
{
Dictionary<string, CacheData> cacheIndex;
string cacheIndexFile = cacheLocation + "CacheIndex.json";
if (File.Exists(cacheIndexFile))
@ -240,7 +242,7 @@ namespace Observatory.Herald
var indexFileContent = File.ReadAllText(cacheIndexFile);
try
{
cacheIndex = JsonSerializer.Deserialize<Dictionary<string, CacheData>>(indexFileContent);
cacheIndex = JsonSerializer.Deserialize<ConcurrentDictionary<string, CacheData>>(indexFileContent);
}
catch (Exception ex)
{
@ -258,9 +260,13 @@ namespace Observatory.Herald
var cacheFiles = cacheLocation.GetFiles("*.mp3");
foreach (var file in cacheFiles.Where(file => !cacheIndex.ContainsKey(file.Name)))
{
cacheIndex.Add(file.Name, new(file.CreationTime, 0));
cacheIndex.TryAdd(file.Name, new(file.CreationTime, 0));
};
}
private void UpdateAndPruneCache(FileInfo currentFile)
{
var cacheFiles = cacheLocation.GetFiles("*.mp3");
if (cacheIndex.ContainsKey(currentFile.Name))
{
cacheIndex[currentFile.Name] = new(
@ -270,13 +276,15 @@ namespace Observatory.Herald
}
else
{
cacheIndex.Add(currentFile.Name, new(DateTime.UtcNow, 1));
cacheIndex.TryAdd(currentFile.Name, new(DateTime.UtcNow, 1));
}
var currentCacheSize = cacheFiles.Sum(f => f.Length);
while (currentCacheSize > cacheSize * 1024 * 1024)
{
var indexedCacheSize = cacheFiles
.Where(f => cacheIndex.ContainsKey(f.Name))
.Sum(f => f.Length);
while (indexedCacheSize > cacheSize * 1024 * 1024)
{
var staleFile = (from file in cacheIndex
orderby file.Value.HitCount, file.Value.Created
select file.Key).First();
@ -284,16 +292,19 @@ namespace Observatory.Herald
if (staleFile == currentFile.Name)
break;
currentCacheSize -= new FileInfo(cacheLocation + staleFile).Length;
File.Delete(cacheLocation + staleFile);
cacheIndex.Remove(staleFile);
cacheIndex.TryRemove(staleFile, out _);
}
}
internal async void CommitCache()
{
string cacheIndexFile = cacheLocation + "CacheIndex.json";
// Race conditions between title and detail speech make a collision here possible.
// Wait for file to become writable, but return control to call site while we wait.
System.Diagnostics.Stopwatch stopwatch = new();
stopwatch.Start();
// Race condition isn't a concern anymore, but should check this anyway to be safe.
// (Maybe someone is poking at the file with notepad?)
while (!IsFileWritable(cacheIndexFile) && stopwatch.ElapsedMilliseconds < 1000)
await Task.Factory.StartNew(() => System.Threading.Thread.Sleep(100));
@ -325,7 +336,7 @@ namespace Observatory.Herald
}
}
public class CacheData
private class CacheData
{
public CacheData(DateTime Created, int HitCount)
{