498 lines
21 KiB
C#
498 lines
21 KiB
C#
using AudioWallpaper.ActivityWatch;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace AudioWallpaper.ActivityWatch {
|
||
internal class ActivityAnalyzer {
|
||
private readonly ActivityAnalyzerConfig _config;
|
||
private readonly TimeZoneInfo _timeZone;
|
||
|
||
public ActivityAnalyzer(ActivityAnalyzerConfig config = null) {
|
||
_config = config ?? new ActivityAnalyzerConfig();
|
||
|
||
// 初始化时区
|
||
try {
|
||
_timeZone = TimeZoneInfo.FindSystemTimeZoneById(GetWindowsTimeZoneId(_config.Timezone));
|
||
} catch (TimeZoneNotFoundException) {
|
||
Console.WriteLine($"警告:未知时区 '{_config.Timezone}',使用默认时区 'China Standard Time'");
|
||
_timeZone = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
|
||
}
|
||
}
|
||
|
||
private string GetWindowsTimeZoneId(string timezone) {
|
||
// 映射一些常见的时区名称到Windows时区ID
|
||
var timeZoneMap = new Dictionary<string, string>
|
||
{
|
||
{ "Asia/Shanghai", "China Standard Time" },
|
||
{ "UTC", "UTC" },
|
||
{ "US/Eastern", "Eastern Standard Time" },
|
||
{ "US/Pacific", "Pacific Standard Time" },
|
||
{ "Europe/London", "GMT Standard Time" },
|
||
{ "Europe/Paris", "W. Europe Standard Time" }
|
||
};
|
||
|
||
return timeZoneMap.ContainsKey(timezone) ? timeZoneMap[timezone] : timezone;
|
||
}
|
||
|
||
private DateTime? ParseTimestamp(string timestampStr) {
|
||
if (string.IsNullOrEmpty(timestampStr))
|
||
return null;
|
||
|
||
try {
|
||
// 方法1:直接解析ISO格式
|
||
if (DateTime.TryParse(timestampStr, out DateTime dt)) {
|
||
// 转换到指定时区
|
||
if (dt.Kind == DateTimeKind.Utc) {
|
||
return TimeZoneInfo.ConvertTimeFromUtc(dt, _timeZone);
|
||
} else if (dt.Kind == DateTimeKind.Unspecified) {
|
||
return TimeZoneInfo.ConvertTime(dt, _timeZone);
|
||
}
|
||
return dt;
|
||
}
|
||
|
||
// 方法2:手动解析
|
||
string cleanTimestamp = timestampStr.Replace('T', ' ');
|
||
|
||
if (cleanTimestamp.Contains('+'))
|
||
cleanTimestamp = cleanTimestamp.Split('+')[0];
|
||
else if (cleanTimestamp.Contains('Z'))
|
||
cleanTimestamp = cleanTimestamp.Replace("Z", "");
|
||
|
||
// 移除毫秒部分
|
||
cleanTimestamp = Regex.Replace(cleanTimestamp, @"\.\d+", "");
|
||
|
||
if (DateTime.TryParseExact(cleanTimestamp, "yyyy-MM-dd HH:mm:ss",
|
||
CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedDt)) {
|
||
return TimeZoneInfo.ConvertTime(parsedDt, _timeZone);
|
||
}
|
||
|
||
return null;
|
||
} catch (Exception ex) {
|
||
Console.WriteLine($"警告:无法解析时间戳 '{timestampStr}': {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析从ActivityWatchClient获取的原始活动数据
|
||
/// </summary>
|
||
/// <param name="rawData">ActivityWatchClient.GetEventsAsync()返回的数据</param>
|
||
/// <returns>解析后的活动条目列表</returns>
|
||
public List<ActivityEntry> ParseActivityData(List<Dictionary<string, object>> rawData) {
|
||
var activities = new List<ActivityEntry>();
|
||
|
||
if (rawData == null || !rawData.Any())
|
||
return activities;
|
||
|
||
foreach (var entry in rawData) {
|
||
var processed = ProcessDictionaryEntry(entry);
|
||
if (processed != null)
|
||
activities.AddRange(processed);
|
||
}
|
||
|
||
return activities.OrderBy(a => a.Timestamp).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从JSON字符串解析活动数据(兼容原有功能)
|
||
/// </summary>
|
||
/// <param name="jsonString">JSON格式的活动数据</param>
|
||
/// <returns>解析后的活动条目列表</returns>
|
||
public List<ActivityEntry> ParseActivityDataFromJson(string jsonString) {
|
||
var activities = new List<ActivityEntry>();
|
||
|
||
if (string.IsNullOrEmpty(jsonString))
|
||
return activities;
|
||
|
||
try {
|
||
// 尝试解析为JSON数组
|
||
var jsonArray = JsonSerializer.Deserialize<List<JsonElement>>(jsonString);
|
||
foreach (var entry in jsonArray) {
|
||
var processed = ProcessEntry(entry);
|
||
if (processed != null)
|
||
activities.AddRange(processed);
|
||
}
|
||
} catch (JsonException) {
|
||
// 逐行解析
|
||
foreach (var line in jsonString.Split('\n')) {
|
||
var trimmedLine = line.Trim();
|
||
if (string.IsNullOrEmpty(trimmedLine))
|
||
continue;
|
||
|
||
try {
|
||
var entry = JsonSerializer.Deserialize<JsonElement>(trimmedLine);
|
||
var processed = ProcessEntry(entry);
|
||
if (processed != null)
|
||
activities.AddRange(processed);
|
||
} catch (JsonException) {
|
||
// 忽略无法解析的行
|
||
}
|
||
}
|
||
}
|
||
|
||
return activities.OrderBy(a => a.Timestamp).ToList();
|
||
}
|
||
|
||
private List<ActivityEntry> ProcessEntry(JsonElement entry) {
|
||
try {
|
||
var data = entry.TryGetProperty("data", out var dataElement) ? dataElement : entry;
|
||
|
||
if (!data.TryGetProperty("app", out var appElement) ||
|
||
!data.TryGetProperty("title", out var titleElement))
|
||
return null;
|
||
|
||
var app = appElement.GetString();
|
||
var title = titleElement.GetString();
|
||
|
||
if (string.IsNullOrEmpty(app) || string.IsNullOrEmpty(title))
|
||
return null;
|
||
|
||
var duration = entry.TryGetProperty("duration", out var durationElement)
|
||
? durationElement.GetDouble() : 0.0;
|
||
|
||
if (duration < _config.MinDuration ||
|
||
_config.ExcludedApps.Contains(app) ||
|
||
_config.ExcludedTitles.Any(keyword => title.Contains(keyword)))
|
||
return null;
|
||
|
||
var timestampStr = entry.TryGetProperty("timestamp", out var timestampElement)
|
||
? timestampElement.GetString() : "";
|
||
|
||
var parsedTimestamp = ParseTimestamp(timestampStr);
|
||
if (!parsedTimestamp.HasValue)
|
||
return null;
|
||
|
||
return new List<ActivityEntry>
|
||
{
|
||
new ActivityEntry
|
||
{
|
||
Timestamp = parsedTimestamp.Value,
|
||
Duration = duration,
|
||
App = app,
|
||
Title = title,
|
||
RawTitle = title
|
||
}
|
||
};
|
||
} catch (Exception) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private List<ActivityEntry> ProcessDictionaryEntry(Dictionary<string, object> entry) {
|
||
try {
|
||
Dictionary<string, object> data;
|
||
|
||
if (entry.ContainsKey("data")) {
|
||
if (entry["data"] is JsonElement je && je.ValueKind == JsonValueKind.Object) {
|
||
// ✅ 将 JsonElement 转为 Dictionary<string, object>
|
||
data = JsonSerializer.Deserialize<Dictionary<string, object>>(je.GetRawText());
|
||
} else if (entry["data"] is Dictionary<string, object> dict) {
|
||
data = dict;
|
||
} else {
|
||
return null;
|
||
}
|
||
} else {
|
||
data = entry;
|
||
}
|
||
|
||
if (!data.ContainsKey("app") || !data.ContainsKey("title"))
|
||
return null;
|
||
|
||
var app = data["app"].ToString();
|
||
var title = data["title"].ToString();
|
||
|
||
if (string.IsNullOrEmpty(app) || string.IsNullOrEmpty(title))
|
||
return null;
|
||
|
||
var duration = entry.ContainsKey("duration") ? GetValue<double>(entry["duration"]) : 0.0;
|
||
|
||
if (duration < _config.MinDuration ||
|
||
_config.ExcludedApps.Contains(app) ||
|
||
_config.ExcludedTitles.Any(keyword => title.Contains(keyword)))
|
||
return null;
|
||
|
||
var timestampStr = entry.ContainsKey("timestamp") ? entry["timestamp"].ToString() : "";
|
||
var parsedTimestamp = ParseTimestamp(timestampStr);
|
||
if (!parsedTimestamp.HasValue)
|
||
return null;
|
||
|
||
return new List<ActivityEntry>
|
||
{
|
||
new ActivityEntry
|
||
{
|
||
Timestamp = parsedTimestamp.Value,
|
||
Duration = duration,
|
||
App = app,
|
||
Title = title,
|
||
RawTitle = title
|
||
}
|
||
};
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
private T GetValue<T>(object value, T defaultValue = default) {
|
||
try {
|
||
if (value == null || value is DBNull)
|
||
return defaultValue;
|
||
|
||
// 处理 JsonElement 特殊情况
|
||
if (value is JsonElement je) {
|
||
if (typeof(T) == typeof(string)) return (T)(object)je.ToString();
|
||
if (typeof(T) == typeof(int) && je.TryGetInt32(out var i)) return (T)(object)i;
|
||
if (typeof(T) == typeof(long) && je.TryGetInt64(out var l)) return (T)(object)l;
|
||
if (typeof(T) == typeof(double) && je.TryGetDouble(out var d)) return (T)(object)d;
|
||
if (typeof(T) == typeof(float) && je.TryGetSingle(out var f)) return (T)(object)f;
|
||
if (typeof(T) == typeof(DateTime) && je.TryGetDateTime(out var dt)) return (T)(object)dt;
|
||
|
||
// fallback: 尝试反序列化整个对象
|
||
return JsonSerializer.Deserialize<T>(je.GetRawText());
|
||
}
|
||
|
||
// 如果已经是目标类型,直接返回
|
||
if (value is T tVal)
|
||
return tVal;
|
||
|
||
// 尝试 Convert.ChangeType
|
||
return (T)Convert.ChangeType(value, typeof(T));
|
||
} catch {
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
private List<ActivityEntry> SplitActivityAcrossSlices(ActivityEntry activity, int sliceDurationMinutes) {
|
||
var startTime = activity.Timestamp;
|
||
var duration = activity.Duration;
|
||
var endTime = startTime.AddSeconds(duration);
|
||
|
||
var sliceInterval = TimeSpan.FromMinutes(sliceDurationMinutes);
|
||
|
||
// 计算开始时间片的边界
|
||
var startSlice = new DateTime(startTime.Year, startTime.Month, startTime.Day,
|
||
startTime.Hour, (startTime.Minute / sliceDurationMinutes) * sliceDurationMinutes, 0);
|
||
|
||
// 如果活动完全在一个时间片内,直接返回
|
||
var nextSliceBoundary = startSlice.Add(sliceInterval);
|
||
if (endTime <= nextSliceBoundary)
|
||
return new List<ActivityEntry> { activity };
|
||
|
||
// 将活动分割到多个时间片
|
||
var splitActivities = new List<ActivityEntry>();
|
||
var currentSliceStart = startSlice;
|
||
var remainingDuration = duration;
|
||
|
||
while (remainingDuration > 0 && currentSliceStart < endTime) {
|
||
var currentSliceEnd = currentSliceStart.Add(sliceInterval);
|
||
|
||
// 计算当前时间片中的活动开始时间
|
||
var activityStartInSlice = startTime > currentSliceStart ? startTime : currentSliceStart;
|
||
var activityEndInSlice = endTime < currentSliceEnd ? endTime : currentSliceEnd;
|
||
|
||
// 计算在当前时间片中的持续时间
|
||
var durationInSlice = (activityEndInSlice - activityStartInSlice).TotalSeconds;
|
||
|
||
if (durationInSlice > 0) {
|
||
splitActivities.Add(new ActivityEntry {
|
||
Timestamp = activityStartInSlice,
|
||
Duration = durationInSlice,
|
||
App = activity.App,
|
||
Title = activity.Title,
|
||
RawTitle = activity.RawTitle
|
||
});
|
||
}
|
||
|
||
currentSliceStart = currentSliceEnd;
|
||
remainingDuration -= durationInSlice;
|
||
}
|
||
|
||
return splitActivities;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 分析活动数据并生成时间片结果
|
||
/// </summary>
|
||
/// <param name="activities">活动条目列表</param>
|
||
/// <returns>时间片分析结果</returns>
|
||
public List<TimeSliceResult> AnalyzeTimeSlices(List<ActivityEntry> activities) {
|
||
if (!activities.Any())
|
||
return new List<TimeSliceResult>();
|
||
|
||
var startTime = activities.First().Timestamp;
|
||
var endTime = activities.Last().Timestamp;
|
||
var interval = _config.TimeSliceMinutes;
|
||
|
||
var sliceDict = new Dictionary<DateTime, TimeSliceData>();
|
||
|
||
// 分配活动到时间片
|
||
foreach (var activity in activities) {
|
||
var splitActivities = SplitActivityAcrossSlices(activity, interval);
|
||
|
||
foreach (var splitActivity in splitActivities) {
|
||
var sliceKey = new DateTime(splitActivity.Timestamp.Year, splitActivity.Timestamp.Month,
|
||
splitActivity.Timestamp.Day, splitActivity.Timestamp.Hour,
|
||
(splitActivity.Timestamp.Minute / interval) * interval, 0);
|
||
|
||
if (!sliceDict.ContainsKey(sliceKey))
|
||
sliceDict[sliceKey] = new TimeSliceData();
|
||
|
||
var sliceData = sliceDict[sliceKey];
|
||
sliceData.ActiveSeconds += splitActivity.Duration;
|
||
|
||
if (!sliceData.AppUsage.ContainsKey(splitActivity.App))
|
||
sliceData.AppUsage[splitActivity.App] = 0;
|
||
sliceData.AppUsage[splitActivity.App] += splitActivity.Duration;
|
||
|
||
sliceData.WindowTitles.Add(splitActivity.RawTitle);
|
||
sliceData.Events.Add(splitActivity);
|
||
|
||
// 关键词分析
|
||
var titleLower = splitActivity.Title.ToLower();
|
||
if (_config.WorkKeywords.Any(kw => titleLower.Contains(kw.ToLower())))
|
||
sliceData.KeywordsMatched++;
|
||
if (_config.DistractionKeywords.Any(kw => titleLower.Contains(kw.ToLower())))
|
||
sliceData.DistractionCount++;
|
||
}
|
||
}
|
||
|
||
// 计算切换次数和主导应用
|
||
foreach (var kvp in sliceDict) {
|
||
var data = kvp.Value;
|
||
|
||
// 计算应用切换次数
|
||
if (data.Events.Count > 1) {
|
||
var prevApp = data.Events[0].App;
|
||
for (int i = 1; i < data.Events.Count; i++) {
|
||
if (data.Events[i].App != prevApp)
|
||
data.SwitchCount++;
|
||
prevApp = data.Events[i].App;
|
||
}
|
||
}
|
||
|
||
// 确定主导应用
|
||
if (data.AppUsage.Any()) {
|
||
data.DominantProcess = data.AppUsage.OrderByDescending(x => x.Value).First().Key;
|
||
}
|
||
}
|
||
|
||
// 构建最终输出
|
||
var results = new List<TimeSliceResult>();
|
||
var current = new DateTime(startTime.Year, startTime.Month, startTime.Day,
|
||
startTime.Hour, (startTime.Minute / interval) * interval, 0);
|
||
|
||
while (current <= endTime) {
|
||
if (sliceDict.ContainsKey(current)) {
|
||
var data = sliceDict[current];
|
||
|
||
// 计算专注分数
|
||
var maxSeconds = interval * 60;
|
||
var timeUtilization = Math.Min(1.0, data.ActiveSeconds / maxSeconds);
|
||
var switchPenalty = Math.Min(1.0, data.SwitchCount / 10.0);
|
||
var keywordBoost = Math.Min(0.3, data.KeywordsMatched * 0.1);
|
||
var distractionPenalty = Math.Min(0.5, data.DistractionCount * 0.2);
|
||
|
||
var focusScore = (timeUtilization * 0.7 + keywordBoost) * (1 - switchPenalty) - distractionPenalty;
|
||
focusScore = Math.Max(0.0, Math.Min(1.0, focusScore));
|
||
|
||
// 根据阈值确定专注状态
|
||
var focusState = "无活动";
|
||
if (data.ActiveSeconds > 0) {
|
||
var sortedThresholds = _config.FocusThresholds.OrderByDescending(x => x.Value);
|
||
foreach (var threshold in sortedThresholds) {
|
||
if (focusScore >= threshold.Value) {
|
||
focusState = threshold.Key;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
var result = new TimeSliceResult {
|
||
TimeSlice = current.ToString("yyyy-MM-dd HH:mm"),
|
||
Timezone = _config.Timezone,
|
||
DominantProcess = data.DominantProcess ?? "none",
|
||
ActiveSeconds = Math.Round(data.ActiveSeconds, 1),
|
||
SwitchCount = data.SwitchCount,
|
||
KeywordsMatched = data.KeywordsMatched,
|
||
DistractionEvents = data.DistractionCount,
|
||
WindowTitles = data.WindowTitles.ToList(),
|
||
FocusScore = Math.Round(focusScore, 2),
|
||
FocusState = focusState,
|
||
ProductivityScore = Math.Round(focusScore * timeUtilization * 100, 1)
|
||
};
|
||
|
||
if (_config.OutputVerbose) {
|
||
result.AppUtilization = data.AppUsage.ToDictionary(
|
||
kvp => kvp.Key,
|
||
kvp => Math.Round(kvp.Value, 1)
|
||
);
|
||
}
|
||
|
||
results.Add(result);
|
||
} else {
|
||
// 无活动的空时间片
|
||
results.Add(new TimeSliceResult {
|
||
TimeSlice = current.ToString("yyyy-MM-dd HH:mm"),
|
||
Timezone = _config.Timezone,
|
||
DominantProcess = "none",
|
||
ActiveSeconds = 0,
|
||
SwitchCount = 0,
|
||
KeywordsMatched = 0,
|
||
DistractionEvents = 0,
|
||
WindowTitles = new List<string>(),
|
||
FocusScore = 0.0,
|
||
FocusState = "无活动",
|
||
ProductivityScore = 0.0
|
||
});
|
||
}
|
||
|
||
current = current.AddMinutes(interval);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 完整的分析流程:从ActivityWatchClient数据到时间片结果
|
||
/// </summary>
|
||
/// <param name="rawData">ActivityWatchClient.GetEventsAsync()返回的数据</param>
|
||
/// <returns>时间片分析结果</returns>
|
||
public List<TimeSliceResult> AnalyzeFromRawData(List<Dictionary<string, object>> rawData) {
|
||
var activities = ParseActivityData(rawData);
|
||
return AnalyzeTimeSlices(activities);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将结果保存为JSON文件
|
||
/// </summary>
|
||
/// <param name="results">分析结果</param>
|
||
/// <param name="filePath">输出文件路径</param>
|
||
public async Task SaveResultsToFileAsync(List<TimeSliceResult> results, string filePath) {
|
||
var options = new JsonSerializerOptions {
|
||
WriteIndented = true,
|
||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||
};
|
||
var jsonString = JsonSerializer.Serialize(results, options);
|
||
await File.WriteAllTextAsync(filePath, jsonString);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从配置文件加载配置
|
||
/// </summary>
|
||
/// <param name="configFilePath">配置文件路径</param>
|
||
/// <returns>配置对象</returns>
|
||
public static async Task<ActivityAnalyzerConfig> LoadConfigFromFileAsync(string configFilePath) {
|
||
try {
|
||
var configJson = await File.ReadAllTextAsync(configFilePath);
|
||
return JsonSerializer.Deserialize<ActivityAnalyzerConfig>(configJson);
|
||
} catch (Exception) {
|
||
return new ActivityAnalyzerConfig();
|
||
}
|
||
}
|
||
}
|
||
}
|