From 2302c211e4ceed7e68697dec5d25df71f15d457e Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Mon, 17 Feb 2025 23:33:38 +0000 Subject: [PATCH] Add analytics and reporting functionality Introduced `AnalyticsService` for sending OS and activity data and `ReportService` for user feedback submissions. Added `ReportForm` UI to capture and send user reports. Updated necessary components and configurations to integrate these features. --- source/HI2UC/EntryPoint.cs | 10 ++ source/HI2UC/HI2UC.csproj | 6 ++ source/HI2UC/PluginForm.cs | 34 ++++-- source/HI2UC/ReportForm.Designer.cs | 73 +++++++++++++ source/HI2UC/ReportForm.cs | 22 ++++ source/HI2UC/ReportForm.resx | 120 +++++++++++++++++++++ source/HI2UC/Services/AnalyticsService.cs | 69 ++++++++++++ source/HI2UC/Services/JsonUtils.cs | 58 ++++++++++ source/HI2UC/Services/ReportService.cs | 122 ++++++++++++++++++++++ 9 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 source/HI2UC/ReportForm.Designer.cs create mode 100644 source/HI2UC/ReportForm.cs create mode 100644 source/HI2UC/ReportForm.resx create mode 100644 source/HI2UC/Services/AnalyticsService.cs create mode 100644 source/HI2UC/Services/JsonUtils.cs create mode 100644 source/HI2UC/Services/ReportService.cs diff --git a/source/HI2UC/EntryPoint.cs b/source/HI2UC/EntryPoint.cs index 39334bbf..ed718a07 100644 --- a/source/HI2UC/EntryPoint.cs +++ b/source/HI2UC/EntryPoint.cs @@ -4,6 +4,7 @@ using System.IO; using System.Reflection; using System.Windows.Forms; +using Nikse.SubtitleEdit.PluginLogic.Services; namespace Nikse.SubtitleEdit.PluginLogic; @@ -58,6 +59,15 @@ public string DoAction(Form parentForm, string srtText, double frame, string uiL // Load raws subtitle lines into Subtitle object srt.LoadSubtitle(sub, list, file); + // analytics + + var analyticsService = new AnalyticsService(); + _ = analyticsService.SendAsync(new Data() + { + OsVersion = Environment.OSVersion.ToString(), + LastActive = DateTimeOffset.Now + }).ConfigureAwait(false); + IPlugin hi2Uc = this; using (var form = new PluginForm(sub, hi2Uc.Name, hi2Uc.Description)) { diff --git a/source/HI2UC/HI2UC.csproj b/source/HI2UC/HI2UC.csproj index 1376d812..0f0b8cae 100644 --- a/source/HI2UC/HI2UC.csproj +++ b/source/HI2UC/HI2UC.csproj @@ -15,6 +15,9 @@ True Resources.resx + + Form + @@ -22,4 +25,7 @@ Resources.Designer.cs + + + \ No newline at end of file diff --git a/source/HI2UC/PluginForm.cs b/source/HI2UC/PluginForm.cs index 74f9418b..c726d6a2 100644 --- a/source/HI2UC/PluginForm.cs +++ b/source/HI2UC/PluginForm.cs @@ -9,6 +9,7 @@ using Nikse.SubtitleEdit.PluginLogic.Converters.Strategies; using Nikse.SubtitleEdit.PluginLogic.Extensions; using Nikse.SubtitleEdit.PluginLogic.Models; +using Nikse.SubtitleEdit.PluginLogic.Services; namespace Nikse.SubtitleEdit.PluginLogic; @@ -55,7 +56,7 @@ public PluginForm(Subtitle subtitle, string name, string description) uncheckAllToolStripMenuItem.Click += (_, _) => listViewFixes.UncheckAll(); invertCheckToolStripMenuItem.Click += (_, _) => listViewFixes.InvertCheck(); - linkLabel1.DoubleClick += LinkLabel1_DoubleClick; + linkLabel1.Click += LaunchReportWindow; // force layout // ReSharper disable once VirtualMemberCallInConstructor @@ -71,9 +72,29 @@ public PluginForm(Subtitle subtitle, string name, string description) pictureBoxDonate.Click += (_, _) => { Process.Start(StringUtils.DonateUrl); }; } - private void LinkLabel1_DoubleClick(object sender, EventArgs e) + private readonly ReportService _reportService = new(); + + private async void LaunchReportWindow(object sender, EventArgs e) { - Process.Start("https://github.com/SubtitleEdit/plugins/issues/new"); + using (var reportForm = new ReportForm(_reportService)) + { + if (reportForm.ShowDialog() == DialogResult.OK) + { + try + { + var report = new Report(reportForm.ReportMessage, _subtitle.ToText()); + if (await _reportService.ReportAsync(report).ConfigureAwait(false)) + { + MessageBox.Show("Thank you for your report!"); + } + } + catch (Exception exception) + { + MessageBox.Show(exception.Message); + } + } + } + // Process.Start("https://github.com/SubtitleEdit/plugins/issues/new"); } private void UpdateUiFromConfigs(HiConfigs configs) @@ -123,7 +144,7 @@ private void ApplyChanges() listViewFixes.BeginUpdate(); foreach (var listViewItem in listViewFixes.Items.CheckItems()) { - var record = (Record) listViewItem.Tag; + var record = (Record)listViewItem.Tag; record.Paragraph.Text = record.After; } @@ -208,7 +229,7 @@ private ICollection GetConverters() return commands; } - private IConverterStrategy GetStrategy() => (IConverterStrategy) comboBoxStyle.SelectedItem; + private IConverterStrategy GetStrategy() => (IConverterStrategy)comboBoxStyle.SelectedItem; public void LoadConfigurations() { @@ -232,7 +253,7 @@ public void LoadConfigurations() private void copyToolStripMenuItem_Click(object sender, EventArgs e) { - var text = ((Paragraph) listViewFixes.FocusedItem.Tag).ToString(); + var text = ((Paragraph)listViewFixes.FocusedItem.Tag).ToString(); Clipboard.SetText(text, TextDataFormat.UnicodeText); } @@ -249,6 +270,7 @@ private void deleteLineToolStripMenuItem_Click(object sender, EventArgs e) listViewFixes.Items.RemoveAt(index); } + listViewFixes.EndUpdate(); _subtitle.Renumber(); diff --git a/source/HI2UC/ReportForm.Designer.cs b/source/HI2UC/ReportForm.Designer.cs new file mode 100644 index 00000000..b50a2323 --- /dev/null +++ b/source/HI2UC/ReportForm.Designer.cs @@ -0,0 +1,73 @@ +using System.ComponentModel; + +namespace Nikse.SubtitleEdit.PluginLogic; + +partial class ReportForm +{ + /// + /// Required designer variable. + /// + private IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.buttonSubmit = new System.Windows.Forms.Button(); + this.textBox1 = new System.Windows.Forms.TextBox(); + this.SuspendLayout(); + // + // buttonSubmit + // + this.buttonSubmit.Location = new System.Drawing.Point(324, 173); + this.buttonSubmit.Name = "buttonSubmit"; + this.buttonSubmit.Size = new System.Drawing.Size(75, 23); + this.buttonSubmit.TabIndex = 0; + this.buttonSubmit.Text = "Submit"; + this.buttonSubmit.UseVisualStyleBackColor = true; + // + // textBox1 + // + this.textBox1.Location = new System.Drawing.Point(12, 12); + this.textBox1.Multiline = true; + this.textBox1.Name = "textBox1"; + this.textBox1.Size = new System.Drawing.Size(387, 150); + this.textBox1.TabIndex = 1; + // + // ReportForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(411, 208); + this.Controls.Add(this.textBox1); + this.Controls.Add(this.buttonSubmit); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; + this.Name = "ReportForm"; + this.Text = "Submit for analysis"; + this.ResumeLayout(false); + this.PerformLayout(); + } + + private System.Windows.Forms.Button buttonSubmit; + private System.Windows.Forms.TextBox textBox1; + + #endregion +} \ No newline at end of file diff --git a/source/HI2UC/ReportForm.cs b/source/HI2UC/ReportForm.cs new file mode 100644 index 00000000..3f356720 --- /dev/null +++ b/source/HI2UC/ReportForm.cs @@ -0,0 +1,22 @@ +using System.Windows.Forms; +using Nikse.SubtitleEdit.PluginLogic.Services; + +namespace Nikse.SubtitleEdit.PluginLogic; + +public partial class ReportForm : Form +{ + private readonly ReportService _reportService; + + public string ReportMessage { get; set; } + + public ReportForm(ReportService reportService) + { + InitializeComponent(); + StartPosition = FormStartPosition.CenterParent; + + _reportService = reportService; + buttonSubmit.DialogResult = DialogResult.OK; + + Closing += (sender, args) => ReportMessage = textBox1.Text; + } +} \ No newline at end of file diff --git a/source/HI2UC/ReportForm.resx b/source/HI2UC/ReportForm.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/source/HI2UC/ReportForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/source/HI2UC/Services/AnalyticsService.cs b/source/HI2UC/Services/AnalyticsService.cs new file mode 100644 index 00000000..5bde7a16 --- /dev/null +++ b/source/HI2UC/Services/AnalyticsService.cs @@ -0,0 +1,69 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.PluginLogic.Services; + +/// +/// A service for sending analytics data to a remote API endpoint. +/// +/// +/// The AnalyticsService is responsible for collecting and transmitting +/// analytics information such as operating system version and last active timestamp +/// as a JSON payload. +/// +public class AnalyticsService : IDisposable +{ + private readonly HttpClient _httpClient; + + public AnalyticsService() + { + _httpClient = new HttpClient() + { + BaseAddress = new Uri("https://subtitleedit.ivandrofly.com/api/analytics") + }; + } + + /// + /// Sends the provided analytics data asynchronously to the specified API endpoint. + /// + /// The data object containing analytics information to be sent. + /// A task that represents the asynchronous send operation. + public async Task SendAsync(Data data) + { + try + { + using (var jsonContent = new StringContent(data.ToString(), Encoding.UTF8, "application/json")) + { + _ = await _httpClient.PostAsync("/api/analytics", jsonContent) + .ConfigureAwait(false); + } + } + catch (Exception e) + { + // ignore + } + } + + public void Dispose() => _httpClient?.Dispose(); +} + +public class Data +{ + /// + /// Gets or sets the operating system version of the device or system sending analytics data. + /// + public string OsVersion { get; set; } + + /// + /// Gets or sets the timestamp of the last recorded activity. + /// + public DateTimeOffset LastActive { get; set; } + + public override string ToString() + { + // convert properties to json string + return $"{{\"osVersion\": \"{JsonUtils.EscapeJsonString(OsVersion)}\", \"lastActive\": \"{LastActive}\"}}"; + } +} \ No newline at end of file diff --git a/source/HI2UC/Services/JsonUtils.cs b/source/HI2UC/Services/JsonUtils.cs new file mode 100644 index 00000000..75367c8b --- /dev/null +++ b/source/HI2UC/Services/JsonUtils.cs @@ -0,0 +1,58 @@ +using System.Text; + +namespace Nikse.SubtitleEdit.PluginLogic.Services; + +public static class JsonUtils +{ + public static string EscapeJsonString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var stringBuilder = new StringBuilder(); + + foreach (var c in input) + { + switch (c) + { + case '\"': // Double quote + stringBuilder.Append("\\\""); + break; + case '\\': // Backslash + stringBuilder.Append("\\\\"); + break; + case '\b': // Backspace + stringBuilder.Append("\\b"); + break; + case '\f': // Form feed + stringBuilder.Append("\\f"); + break; + case '\n': // New line + stringBuilder.Append("\\n"); + break; + case '\r': // Carriage return + stringBuilder.Append("\\r"); + break; + case '\t': // Tab + stringBuilder.Append("\\t"); + break; + default: + // Ensure control characters (0x00-0x1F) are escaped as Unicode. + if (char.IsControl(c)) + { + stringBuilder.AppendFormat("\\u{0:X4}", (int)c); + } + else + { + stringBuilder.Append(c); + } + + break; + } + } + + return stringBuilder.ToString(); + } +} \ No newline at end of file diff --git a/source/HI2UC/Services/ReportService.cs b/source/HI2UC/Services/ReportService.cs new file mode 100644 index 00000000..6e09b4db --- /dev/null +++ b/source/HI2UC/Services/ReportService.cs @@ -0,0 +1,122 @@ +using System; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.PluginLogic.Services; + +public class ReportService : IDisposable +{ + private readonly HttpClient _httpClient; + + public ReportService() + { + _httpClient = new HttpClient() + { +#if DEBUG + BaseAddress = new Uri("http://127.0.0.1:5213/api/report") +#else + BaseAddress = new Uri("https://subtitleedit.ivandrofly.com/api/report") +#endif + }; + } + + public async Task ReportAsync(Report report) + { + try + { + using var content = new StringContent(report.ToString(), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(string.Empty, content); + response.EnsureSuccessStatusCode(); + return true; + } + catch (Exception e) + { + return false; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +/// +/// Represents a report containing a message, content, and associated metadata. +/// +public class Report +{ + public Report(string message, string content) + { + ThrowIfNullOrEmpty(message, nameof(message)); + ThrowIfNullOrEmpty(content, nameof(content)); + + Plugin = "HI2UC"; + + Message = message; + Content = content; + Hash = ComputeHash(content); + } + + /// + /// Throws an if the provided string is null or empty. + /// + /// The string value to check. + /// The name of the argument being validated, used in the exception message. + /// Thrown if the provided string is null or empty. + private void ThrowIfNullOrEmpty(string value, string name) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"'{name}' cannot be null or empty", name); + } + } + + /// + /// Computes the SHA-256 hash for a given string and returns it as a hexadecimal string. + /// + /// The input string for which the hash will be computed. + /// The hash of the given input string as a lowercase hexadecimal string. + private string ComputeHash(string fileName) + { + using (var sha256 = SHA256.Create()) + { + // Convert the fileName to a byte array + var fileNameBytes = Encoding.UTF8.GetBytes(fileName); + + // Compute the hash + var hashBytes = sha256.ComputeHash(fileNameBytes); + + // Convert the hash bytes to a hexadecimal string + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + } + + /// + /// Represents the message associated with the report. + /// + public string Message { get; } + + /// + /// Subtitile content. + /// + public string Content { get; } + + /// + /// Represents the computed SHA-256 hash of the report's content. + /// + public string Hash { get; } + + /// + /// Represents the identifier of the plugin associated with the report. + /// + public string Plugin { get; } + + public override string ToString() + { + // to json string + return $"{{\"message\": \"{Message}\", \"content\": \"{JsonUtils.EscapeJsonString(Content)}\", \"hash\": \"{Hash}\", \"plugin\": \"{Plugin}\"}}"; + } +} \ No newline at end of file