Files
TheDeclineOfWarriors/Assets/Editor/Tasks/TaskBoardService.cs
T

723 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
namespace Project.Tasks.Editor
{
internal static class TaskBoardService
{
private static readonly Regex HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled);
private static readonly Regex H1Regex = new Regex(@"^#\s+(.+)$", RegexOptions.Compiled);
private static readonly Regex JiraTimeRegex = new Regex(@"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static string NormalizePriority(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
switch (value.Trim())
{
case "lowest":
case "Lowest":
return "Lowest";
case "low":
case "Low":
return "Low";
case "medium":
case "Medium":
return "Medium";
case "high":
case "High":
return "High";
case "highest":
case "Highest":
return "Highest";
default:
return value.Trim();
}
}
public static int GetPrioritySortOrder(string value)
{
switch (NormalizePriority(value))
{
case "Highest":
return 0;
case "High":
return 1;
case "Medium":
return 2;
case "Low":
return 3;
case "Lowest":
return 4;
default:
return int.MaxValue;
}
}
public static TaskBoardData Load()
{
var data = new TaskBoardData();
data.ProjectRoot = GetProjectRoot();
data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks"));
data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md"));
if (!Directory.Exists(data.TasksDirectory))
{
data.Warnings.Add("Каталог docs/tasks не найден.");
return data;
}
if (!File.Exists(data.IndexPath))
{
data.Warnings.Add("Файл docs/tasks/Index.md не найден.");
return data;
}
string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8);
ParseIndex(data, lines);
string[] taskFiles = Directory.GetFiles(data.TasksDirectory, "TASK-*.md", SearchOption.TopDirectoryOnly);
var indexedIds = new HashSet<string>(data.Tasks.Select(task => task.Id), StringComparer.OrdinalIgnoreCase);
foreach (string taskPath in taskFiles)
{
string taskId = Path.GetFileNameWithoutExtension(taskPath);
int dashIndex = taskId.IndexOf('-');
if (dashIndex >= 0)
{
int secondDash = taskId.IndexOf('-', dashIndex + 1);
if (secondDash > 0)
{
taskId = taskId.Substring(0, secondDash);
}
}
if (!indexedIds.Contains(taskId))
{
data.Warnings.Add("Task-файл без записи в реестре: " + NormalizePath(GetRelativePath(data.ProjectRoot, taskPath)));
}
}
data.Tasks.Sort(CompareTaskIds);
return data;
}
public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error)
{
error = null;
try
{
SaveIndexRow(data.IndexPath, updatedTask);
if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath))
{
SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask);
}
return true;
}
catch (Exception exception)
{
error = exception.Message;
return false;
}
}
public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error)
{
error = null;
try
{
if (data == null)
{
throw new InvalidOperationException("Данные task board не загружены.");
}
if (task == null)
{
throw new InvalidOperationException("Задача не выбрана.");
}
if (string.IsNullOrWhiteSpace(task.RelativeFilePath))
{
throw new InvalidOperationException("В Index.md не указан путь к task-файлу.");
}
string relativePath = NormalizePath(task.RelativeFilePath);
if (!relativePath.StartsWith("docs/tasks/", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Task-файл можно создавать только внутри docs/tasks.");
}
string absolutePath = NormalizePath(Path.Combine(data.ProjectRoot, relativePath));
if (File.Exists(absolutePath))
{
throw new InvalidOperationException("Task-файл уже существует.");
}
string templatePath = NormalizePath(Path.Combine(data.TasksDirectory, "_template.md"));
if (!File.Exists(templatePath))
{
throw new InvalidOperationException("Шаблон docs/tasks/_template.md не найден.");
}
string template = File.ReadAllText(templatePath, Encoding.UTF8);
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
string title = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Title.Trim();
string owner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim();
string created = string.IsNullOrWhiteSpace(task.Created) ? today : task.Created.Trim();
string area = string.IsNullOrWhiteSpace(task.Area) ? "docs" : task.Area.Trim();
string priority = NormalizePriority(task.Priority);
if (string.IsNullOrWhiteSpace(priority))
{
priority = "Medium";
}
string executionTime = string.IsNullOrWhiteSpace(task.ExecutionTime) ? "1d" : task.ExecutionTime.Trim();
string content = template
.Replace("id: TASK-XXXX", "id: " + task.Id)
.Replace("title: Короткий заголовок", "title: " + title)
.Replace("priority: Medium", "priority: " + priority)
.Replace("area: docs", "area: " + area)
.Replace("owner: unassigned", "owner: " + owner)
.Replace("created: YYYY-MM-DD", "created: " + created)
.Replace("updated: YYYY-MM-DD", "updated: " + today)
.Replace("execution_time: 1d6h30m", "execution_time: " + executionTime)
.Replace("# TASK-XXXX - Короткий заголовок", "# " + task.Id + " - " + title);
string directory = Path.GetDirectoryName(absolutePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
task.AbsoluteFilePath = absolutePath;
task.FileExists = true;
return true;
}
catch (Exception exception)
{
error = exception.Message;
return false;
}
}
public static void OpenInDefaultApp(string absolutePath)
{
if (string.IsNullOrEmpty(absolutePath) || !File.Exists(absolutePath))
{
return;
}
var info = new ProcessStartInfo
{
FileName = absolutePath,
UseShellExecute = true,
};
Process.Start(info);
}
public static bool IsValidExecutionTime(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
Match match = JiraTimeRegex.Match(value.Trim());
if (!match.Success)
{
return false;
}
bool hasAnyGroup = false;
for (int i = 1; i < match.Groups.Count; i++)
{
if (match.Groups[i].Success)
{
hasAnyGroup = true;
break;
}
}
if (!hasAnyGroup)
{
return false;
}
if (match.Groups[4].Success)
{
int minutes;
if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes))
{
return false;
}
if (minutes % 30 != 0)
{
return false;
}
}
return true;
}
public static string GetProjectRoot()
{
DirectoryInfo parent = Directory.GetParent(Application.dataPath);
return parent != null ? NormalizePath(parent.FullName) : NormalizePath(Application.dataPath);
}
public static string NormalizePath(string path)
{
return string.IsNullOrEmpty(path) ? string.Empty : path.Replace('\\', '/');
}
public static string GetRelativePath(string rootPath, string fullPath)
{
Uri rootUri = new Uri(AppendDirectorySeparator(rootPath));
Uri fullUri = new Uri(fullPath);
return Uri.UnescapeDataString(rootUri.MakeRelativeUri(fullUri).ToString()).Replace('\\', '/');
}
private static string AppendDirectorySeparator(string path)
{
if (string.IsNullOrEmpty(path))
{
return string.Empty;
}
if (path[path.Length - 1] == Path.DirectorySeparatorChar || path[path.Length - 1] == Path.AltDirectorySeparatorChar)
{
return path;
}
return path + Path.DirectorySeparatorChar;
}
private static void ParseIndex(TaskBoardData data, string[] lines)
{
int registryIndex = Array.FindIndex(lines, line => string.Equals(line.Trim(), "## Task Registry", StringComparison.Ordinal));
if (registryIndex < 0)
{
data.Warnings.Add("В Index.md не найдена секция '## Task Registry'.");
return;
}
int headerIndex = -1;
int separatorIndex = -1;
for (int i = registryIndex + 1; i < lines.Length; i++)
{
string trimmed = lines[i].Trim();
if (trimmed.StartsWith("|", StringComparison.Ordinal))
{
if (headerIndex < 0)
{
headerIndex = i;
}
else
{
separatorIndex = i;
break;
}
}
else if (!string.IsNullOrWhiteSpace(trimmed))
{
break;
}
}
if (headerIndex < 0 || separatorIndex < 0)
{
data.Warnings.Add("В Index.md не удалось прочитать таблицу реестра задач.");
return;
}
for (int i = separatorIndex + 1; i < lines.Length; i++)
{
string line = lines[i];
string trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
break;
}
if (!trimmed.StartsWith("|", StringComparison.Ordinal))
{
break;
}
string[] columns = SplitMarkdownRow(trimmed);
if (columns.Length < 7)
{
continue;
}
var task = new TaskRecord
{
Id = columns[0],
Status = columns[1],
Priority = NormalizePriority(columns[2]),
Area = columns[3],
ExecutionTime = columns[4],
RelativeFilePath = StripTicks(columns[5]),
Summary = columns[6],
IndexLineNumber = i,
};
if (!string.IsNullOrEmpty(task.RelativeFilePath))
{
task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath));
task.FileExists = File.Exists(task.AbsoluteFilePath);
}
if (!task.FileExists)
{
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
}
PopulateTaskFromFile(task);
ValidateTask(task);
data.Tasks.Add(task);
}
}
private static void PopulateTaskFromFile(TaskRecord task)
{
if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath))
{
if (string.IsNullOrEmpty(task.Title))
{
task.Title = task.Id;
}
return;
}
string[] lines = File.ReadAllLines(task.AbsoluteFilePath, Encoding.UTF8);
int bodyStart = 0;
if (lines.Length > 0 && string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
{
for (int i = 1; i < lines.Length; i++)
{
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
{
bodyStart = i + 1;
break;
}
string line = lines[i];
int separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
string key = line.Substring(0, separator).Trim();
string value = line.Substring(separator + 1).Trim();
ApplyFrontMatterField(task, key, value);
}
}
var sections = new Dictionary<string, StringBuilder>(StringComparer.OrdinalIgnoreCase);
string currentSection = null;
for (int i = bodyStart; i < lines.Length; i++)
{
string line = lines[i];
Match h1Match = H1Regex.Match(line);
if (h1Match.Success && string.IsNullOrEmpty(task.Header))
{
task.Header = h1Match.Groups[1].Value.Trim();
if (string.IsNullOrEmpty(task.Title))
{
task.Title = ExtractTitleFromHeader(task.Header, task.Id);
}
continue;
}
Match headingMatch = HeadingRegex.Match(line);
if (headingMatch.Success)
{
currentSection = headingMatch.Groups[1].Value.Trim();
if (!sections.ContainsKey(currentSection))
{
sections[currentSection] = new StringBuilder();
}
continue;
}
if (string.IsNullOrEmpty(currentSection))
{
continue;
}
if (sections[currentSection].Length > 0)
{
sections[currentSection].AppendLine();
}
sections[currentSection].Append(line);
}
task.Why = GetSection(sections, "Why");
task.ExpectedOutcome = GetSection(sections, "Expected Outcome");
task.CurrentContext = GetSection(sections, "Current Context");
task.AcceptanceCriteria = GetSection(sections, "Acceptance Criteria");
task.Verification = GetSection(sections, "Verification");
task.Risks = GetSection(sections, "Risks / Open Questions");
task.HumanDecisions = GetSection(sections, "Human Decisions Needed");
task.DecisionLog = GetSection(sections, "Decision Log");
task.HandoffNotes = GetSection(sections, "Handoff Notes");
if (string.IsNullOrEmpty(task.Title))
{
task.Title = task.Id;
}
}
private static void ApplyFrontMatterField(TaskRecord task, string key, string value)
{
switch (key)
{
case "id":
if (!string.Equals(task.Id, value, StringComparison.OrdinalIgnoreCase))
{
task.ValidationMessages.Add("ID в task-файле не совпадает с записью в Index.md.");
}
break;
case "title":
task.Title = value;
break;
case "priority":
string normalizedPriority = NormalizePriority(value);
if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase))
{
task.ValidationMessages.Add("Priority в task-файле не совпадает с реестром.");
}
break;
case "area":
if (!string.IsNullOrEmpty(value) && !string.Equals(task.Area, value, StringComparison.OrdinalIgnoreCase))
{
task.ValidationMessages.Add("Area в task-файле не совпадает с реестром.");
}
break;
case "owner":
task.Owner = value;
break;
case "created":
task.Created = value;
break;
case "updated":
task.Updated = value;
break;
case "execution_time":
if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase))
{
task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром.");
}
break;
}
}
private static void ValidateTask(TaskRecord task)
{
if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0)
{
task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status);
}
task.Priority = NormalizePriority(task.Priority);
if (Array.IndexOf(TaskBoardConstants.Priorities, task.Priority) < 0)
{
task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority);
}
if (!IsValidExecutionTime(task.ExecutionTime))
{
task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам.");
}
if (string.IsNullOrEmpty(task.RelativeFilePath))
{
task.ValidationMessages.Add("В реестре не указан путь к task-файлу.");
}
}
private static void SaveIndexRow(string indexPath, TaskRecord task)
{
string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8);
if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length)
{
throw new InvalidOperationException("Не удалось найти строку задачи в Index.md.");
}
lines[task.IndexLineNumber] = FormatIndexRow(task);
File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false));
}
private static void SaveTaskFrontMatter(string taskPath, TaskRecord task)
{
var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList();
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
if (lines.Count == 0 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
{
var newLines = new List<string>
{
"---",
"id: " + task.Id,
"title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title),
"priority: " + task.Priority,
"area: " + task.Area,
"owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner),
"created: " + (string.IsNullOrEmpty(task.Created) ? today : task.Created),
"updated: " + today,
"execution_time: " + task.ExecutionTime,
"depends_on: []",
"canonical_docs: []",
"related_files: []",
"---",
string.Empty,
};
newLines.AddRange(lines);
lines = newLines;
}
int frontMatterEnd = -1;
for (int i = 1; i < lines.Count; i++)
{
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
{
frontMatterEnd = i;
break;
}
}
if (frontMatterEnd < 0)
{
throw new InvalidOperationException("Не удалось прочитать front matter task-файла.");
}
UpsertFrontMatterField(lines, ref frontMatterEnd, "priority", task.Priority);
UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area);
UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner);
UpsertFrontMatterField(lines, ref frontMatterEnd, "created", string.IsNullOrEmpty(task.Created) ? today : task.Created);
UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today);
UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime);
File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false));
}
private static void UpsertFrontMatterField(List<string> lines, ref int frontMatterEnd, string key, string value)
{
string prefix = key + ":";
for (int i = 1; i < frontMatterEnd; i++)
{
if (lines[i].StartsWith(prefix, StringComparison.Ordinal))
{
lines[i] = key + ": " + value;
return;
}
}
lines.Insert(frontMatterEnd, key + ": " + value);
frontMatterEnd++;
}
private static string FormatIndexRow(TaskRecord task)
{
return string.Format(
CultureInfo.InvariantCulture,
"| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |",
task.Id,
task.Status,
task.Priority,
task.Area,
task.ExecutionTime,
NormalizePath(task.RelativeFilePath),
(task.Summary ?? string.Empty).Replace("|", "/"));
}
private static string[] SplitMarkdownRow(string line)
{
string trimmed = line.Trim().Trim('|');
return trimmed.Split('|').Select(part => part.Trim()).ToArray();
}
private static string StripTicks(string value)
{
return string.IsNullOrEmpty(value) ? string.Empty : value.Trim().Trim('`');
}
private static string ExtractTitleFromHeader(string header, string id)
{
if (string.IsNullOrEmpty(header))
{
return id;
}
string prefix = id + " - ";
if (header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return header.Substring(prefix.Length).Trim();
}
return header.Trim();
}
private static string GetSection(Dictionary<string, StringBuilder> sections, string key)
{
StringBuilder value;
if (!sections.TryGetValue(key, out value))
{
return string.Empty;
}
return value.ToString().Trim();
}
private static int CompareTaskIds(TaskRecord left, TaskRecord right)
{
return CompareTaskIds(left != null ? left.Id : null, right != null ? right.Id : null);
}
private static int CompareTaskIds(string left, string right)
{
return ExtractTaskNumber(left).CompareTo(ExtractTaskNumber(right));
}
private static int ExtractTaskNumber(string id)
{
if (string.IsNullOrEmpty(id))
{
return int.MaxValue;
}
string[] parts = id.Split('-');
if (parts.Length < 2)
{
return int.MaxValue;
}
int number;
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue;
}
}
}