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(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(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 { "---", "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 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 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; } } }