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 UnityEditor; using UnityEngine; namespace Project.Tasks.Editor { internal static class TaskBoardService { private static readonly string[] EditableSectionOrder = { "Why", "Expected Outcome", "Current Context", "Acceptance Criteria", "Verification", "Risks / Open Questions", "Human Decisions Needed", "Decision Log", "Handoff Notes", }; 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); private static readonly Regex ExecutionTokenRegex = new Regex(@"(?i)(\d+)\s*([wdhm])", RegexOptions.Compiled); 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 string NormalizeStatus(string value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } switch (value.Trim()) { case "proposal": case "backlog": case "BackLog": return "BackLog"; case "ready": case "todo": case "ToDo": return "ToDo"; case "in_progress": case "inprogress": case "InProgress": return "InProgress"; case "blocked": case "review": case "Review": return "Review"; case "done": case "Done": return "Done"; 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")); data.OwnersConfigPath = "Assets/Editor/Tasks/TaskBoardOwners.asset"; LoadOwnerPresets(data); 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); data.Tasks.Sort(CompareTaskIds); return data; } public static void LoadTaskDetails(TaskRecord task) { if (task == null || task.DetailsLoaded) { return; } task.DetailsLoaded = true; if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath) || !File.Exists(task.AbsoluteFilePath)) { if (string.IsNullOrEmpty(task.Title)) { task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id); } return; } PopulateTaskFromFile(task); ValidateTask(task); } public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error) { error = null; try { CanonicalizeTask(updatedTask); SaveIndexRow(data.IndexPath, updatedTask); if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath)) { SaveTaskFile(updatedTask.AbsoluteFilePath, updatedTask); } return true; } catch (Exception exception) { error = exception.Message; return false; } } public static bool DeleteTask(TaskBoardData data, TaskRecord task, out string error) { error = null; try { if (data == null) { throw new InvalidOperationException("Данные task board не загружены."); } if (task == null) { throw new InvalidOperationException("Задача не выбрана."); } DeleteIndexRow(data.IndexPath, task); if (task.FileExists && !string.IsNullOrWhiteSpace(task.AbsoluteFilePath) && File.Exists(task.AbsoluteFilePath)) { File.Delete(task.AbsoluteFilePath); } return true; } catch (Exception exception) { error = exception.Message; return false; } } public static bool TryParseExecutionTimeToMinutes(string value, out int minutes) { minutes = 0; string normalized; if (!TryNormalizeExecutionTime(value, out normalized, out minutes)) { return false; } return true; } public static string NormalizeExecutionTime(string value) { string normalized; int minutes; return TryNormalizeExecutionTime(value, out normalized, out minutes) ? normalized : (string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim()); } public static bool TryNormalizeExecutionTime(string value, out string normalized, out int totalMinutes) { normalized = string.Empty; totalMinutes = 0; if (string.IsNullOrWhiteSpace(value)) { return false; } string input = value.Trim(); MatchCollection matches = ExecutionTokenRegex.Matches(input); if (matches.Count == 0) { return false; } string leftover = ExecutionTokenRegex.Replace(input, string.Empty); leftover = Regex.Replace(leftover, @"\s+", string.Empty); if (!string.IsNullOrEmpty(leftover)) { return false; } int weeks = 0; int days = 0; int hours = 0; int minutes = 0; foreach (Match match in matches) { int amount = ParseNumber(match.Groups[1].Value); string unit = match.Groups[2].Value.ToLowerInvariant(); switch (unit) { case "w": weeks += amount; break; case "d": days += amount; break; case "h": hours += amount; break; case "m": minutes += amount; break; default: return false; } } totalMinutes = (weeks * TaskBoardConstants.MinutesPerWeek) + (days * TaskBoardConstants.MinutesPerDay) + (hours * TaskBoardConstants.MinutesPerHour) + minutes; if (totalMinutes <= 0 || totalMinutes % 30 != 0) { return false; } normalized = BuildCanonicalExecutionTime(totalMinutes); return true; } public static string FormatMinutesForDisplay(int totalMinutes) { if (totalMinutes < 0) { return "-"; } int remaining = totalMinutes; int days = remaining / TaskBoardConstants.MinutesPerDay; remaining %= TaskBoardConstants.MinutesPerDay; int hours = remaining / TaskBoardConstants.MinutesPerHour; remaining %= TaskBoardConstants.MinutesPerHour; int minutes = remaining; var parts = new List(); if (days > 0) { parts.Add(days + "d"); } if (hours > 0) { parts.Add(hours + "h"); } if (minutes > 0 || parts.Count == 0) { parts.Add(minutes + "m"); } return string.Join(" ", parts) + " (" + totalMinutes + "m)"; } public static string FormatExecutionTimeForDisplay(string value) { string normalized; int totalMinutes; if (!TryNormalizeExecutionTime(value, out normalized, out totalMinutes)) { return string.IsNullOrWhiteSpace(value) ? "-" : value; } Match match = JiraTimeRegex.Match(normalized); var parts = new List(); if (match.Groups[1].Success) { parts.Add(match.Groups[1].Value + "w"); } if (match.Groups[2].Success) { parts.Add(match.Groups[2].Value + "d"); } if (match.Groups[3].Success) { parts.Add(match.Groups[3].Value + "h"); } if (match.Groups[4].Success) { parts.Add(match.Groups[4].Value + "m"); } if (parts.Count == 0) { parts.Add("0m"); } return string.Join(" ", parts) + " (" + totalMinutes + "m)"; } public static int SumEstimatedMinutes(IEnumerable tasks) { int total = 0; foreach (TaskRecord task in tasks) { if (task != null && task.EstimatedMinutes >= 0) { total += task.EstimatedMinutes; } } return total; } public static string[] BuildOwnerOptions(TaskBoardData data, IEnumerable tasks) { var values = new HashSet(StringComparer.OrdinalIgnoreCase) { "unassigned" }; if (data != null) { foreach (string owner in data.OwnerPresets) { if (!string.IsNullOrWhiteSpace(owner)) { values.Add(owner.Trim()); } } } if (tasks != null) { foreach (TaskRecord task in tasks) { if (task != null && !string.IsNullOrWhiteSpace(task.Owner)) { values.Add(task.Owner.Trim()); } } } return values.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray(); } public static bool CanonicalizeTask(TaskRecord task) { if (task == null) { return false; } bool changed = false; string normalizedStatus = NormalizeStatus(task.Status); if (!string.Equals(task.Status, normalizedStatus, StringComparison.Ordinal)) { task.Status = normalizedStatus; changed = true; } string normalizedPriority = NormalizePriority(task.Priority); if (!string.Equals(task.Priority, normalizedPriority, StringComparison.Ordinal)) { task.Priority = normalizedPriority; changed = true; } string normalizedOwner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim(); if (!string.Equals(task.Owner, normalizedOwner, StringComparison.Ordinal)) { task.Owner = normalizedOwner; changed = true; } if (string.IsNullOrWhiteSpace(task.Area)) { task.Area = "-"; changed = true; } string normalizedExecutionTime; int totalMinutes; if (TryNormalizeExecutionTime(task.ExecutionTime, out normalizedExecutionTime, out totalMinutes)) { if (!string.Equals(task.ExecutionTime, normalizedExecutionTime, StringComparison.Ordinal)) { task.ExecutionTime = normalizedExecutionTime; changed = true; } task.EstimatedMinutes = totalMinutes; } else { task.EstimatedMinutes = -1; } return changed; } 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("summary: Короткое описание задачи", "summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.IndexSummary) ? title : task.IndexSummary)) .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; task.DetailsLoaded = false; 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) { string normalized; int totalMinutes; return TryNormalizeExecutionTime(value, out normalized, out totalMinutes); } 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; } bool hasOwnerColumn = columns.Length >= 8; var task = new TaskRecord { Id = columns[0], Status = NormalizeStatus(columns[1]), Priority = NormalizePriority(columns[2]), Area = columns[3], Owner = hasOwnerColumn ? columns[4] : "unassigned", ExecutionTime = NormalizeExecutionTime(hasOwnerColumn ? columns[5] : columns[4]), RelativeFilePath = StripTicks(hasOwnerColumn ? columns[6] : columns[5]), IndexSummary = hasOwnerColumn ? columns[7] : columns[6], IndexLineNumber = i, }; if (!string.IsNullOrEmpty(task.RelativeFilePath)) { task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath)); task.FileExists = File.Exists(task.AbsoluteFilePath); task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id); } if (!task.FileExists) { task.ValidationMessages.Add("Task-файл не найден по пути из реестра."); } int estimatedMinutes; task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1; ValidateTask(task); data.Tasks.Add(task); } } private static void PopulateTaskFromFile(TaskRecord task) { task.TaskSummary = string.Empty; task.Header = string.Empty; task.Why = string.Empty; task.ExpectedOutcome = string.Empty; task.CurrentContext = string.Empty; task.AcceptanceCriteria = string.Empty; task.Verification = string.Empty; task.Risks = string.Empty; task.HumanDecisions = string.Empty; task.DecisionLog = string.Empty; task.HandoffNotes = string.Empty; if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath)) { if (string.IsNullOrEmpty(task.Title)) { task.Title = DeriveTitleFromPath(task.RelativeFilePath, 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 "summary": task.TaskSummary = 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": if (!string.IsNullOrWhiteSpace(value) && !string.Equals(task.Owner, value, StringComparison.OrdinalIgnoreCase)) { task.ValidationMessages.Add("Owner в task-файле не совпадает с реестром."); } break; case "created": task.Created = value; break; case "updated": task.Updated = value; break; case "execution_time": string normalizedExecutionTime = NormalizeExecutionTime(value); if (!string.IsNullOrEmpty(normalizedExecutionTime) && !string.Equals(task.ExecutionTime, normalizedExecutionTime, StringComparison.OrdinalIgnoreCase)) { task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром."); } break; } } private static void ValidateTask(TaskRecord task) { task.Status = NormalizeStatus(task.Status); 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); } task.ExecutionTime = NormalizeExecutionTime(task.ExecutionTime); if (!IsValidExecutionTime(task.ExecutionTime)) { task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам."); } else { int estimatedMinutes; task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1; } 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 SaveTaskFile(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), "summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.TaskSummary) ? task.IndexSummary : task.TaskSummary), "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, "title", string.IsNullOrEmpty(task.Title) ? task.Id : task.Title); UpsertFrontMatterField(lines, ref frontMatterEnd, "summary", EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.TaskSummary) ? task.IndexSummary : task.TaskSummary)); 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); int bodyStart = frontMatterEnd + 1; while (bodyStart < lines.Count && string.IsNullOrWhiteSpace(lines[bodyStart])) { bodyStart++; } var sections = ParseBodySections(lines, bodyStart, task); sections.ValuesByHeading["Why"] = task.Why ?? string.Empty; sections.ValuesByHeading["Expected Outcome"] = task.ExpectedOutcome ?? string.Empty; sections.ValuesByHeading["Current Context"] = task.CurrentContext ?? string.Empty; sections.ValuesByHeading["Acceptance Criteria"] = task.AcceptanceCriteria ?? string.Empty; sections.ValuesByHeading["Verification"] = task.Verification ?? string.Empty; sections.ValuesByHeading["Risks / Open Questions"] = task.Risks ?? string.Empty; sections.ValuesByHeading["Human Decisions Needed"] = task.HumanDecisions ?? string.Empty; sections.ValuesByHeading["Decision Log"] = task.DecisionLog ?? string.Empty; sections.ValuesByHeading["Handoff Notes"] = task.HandoffNotes ?? string.Empty; string header = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Id + " - " + task.Title; var rebuilt = new List(); rebuilt.AddRange(lines.Take(frontMatterEnd + 1)); rebuilt.Add(string.Empty); rebuilt.Add("# " + header); rebuilt.Add(string.Empty); foreach (string heading in sections.OrderedHeadings) { if (!sections.ValuesByHeading.ContainsKey(heading)) { continue; } rebuilt.Add("## " + heading); rebuilt.Add(string.Empty); AddSectionContent(rebuilt, sections.ValuesByHeading[heading]); rebuilt.Add(string.Empty); } foreach (string heading in EditableSectionOrder) { if (sections.OrderedHeadings.Contains(heading)) { continue; } string content; if (!sections.ValuesByHeading.TryGetValue(heading, out content) || string.IsNullOrWhiteSpace(content)) { continue; } rebuilt.Add("## " + heading); rebuilt.Add(string.Empty); AddSectionContent(rebuilt, content); rebuilt.Add(string.Empty); } while (rebuilt.Count > 0 && string.IsNullOrWhiteSpace(rebuilt[rebuilt.Count - 1])) { rebuilt.RemoveAt(rebuilt.Count - 1); } File.WriteAllText(taskPath, string.Join("\n", rebuilt), new UTF8Encoding(false)); task.Updated = today; } 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}` | {7} |", task.Id, task.Status, task.Priority, task.Area, string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner, task.ExecutionTime, NormalizePath(task.RelativeFilePath), (task.IndexSummary ?? string.Empty).Replace("|", "/")); } private static void DeleteIndexRow(string indexPath, TaskRecord task) { string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8); if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length) { throw new InvalidOperationException("Не удалось найти строку задачи в Index.md."); } var updated = lines.Where((line, index) => index != task.IndexLineNumber).ToArray(); File.WriteAllText(indexPath, string.Join("\n", updated), new UTF8Encoding(false)); } 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; } private static void LoadOwnerPresets(TaskBoardData data) { if (data == null) { return; } TaskBoardOwnersConfig config = EnsureOwnersConfig(data.OwnersConfigPath); data.OwnerPresets.Clear(); if (config == null) { data.Warnings.Add("Не удалось загрузить owners config."); return; } foreach (string owner in config.owners) { if (!string.IsNullOrWhiteSpace(owner)) { data.OwnerPresets.Add(owner.Trim()); } } if (!data.OwnerPresets.Any(owner => string.Equals(owner, "unassigned", StringComparison.OrdinalIgnoreCase))) { data.OwnerPresets.Insert(0, "unassigned"); } } private static TaskBoardOwnersConfig EnsureOwnersConfig(string assetPath) { if (string.IsNullOrWhiteSpace(assetPath)) { return null; } TaskBoardOwnersConfig config = AssetDatabase.LoadAssetAtPath(assetPath); if (config != null) { return config; } string directory = Path.GetDirectoryName(assetPath); if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory)) { EnsureAssetFolder(directory); } config = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(config, assetPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return config; } private static void EnsureAssetFolder(string assetFolder) { string normalized = NormalizePath(assetFolder); string[] parts = normalized.Split('/'); string current = parts[0]; for (int i = 1; i < parts.Length; i++) { string next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) { AssetDatabase.CreateFolder(current, parts[i]); } current = next; } } private static int ParseNumber(string value) { int number; return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : 0; } private static string BuildCanonicalExecutionTime(int totalMinutes) { int remaining = totalMinutes; int weeks = remaining / TaskBoardConstants.MinutesPerWeek; remaining %= TaskBoardConstants.MinutesPerWeek; int days = remaining / TaskBoardConstants.MinutesPerDay; remaining %= TaskBoardConstants.MinutesPerDay; int hours = remaining / TaskBoardConstants.MinutesPerHour; remaining %= TaskBoardConstants.MinutesPerHour; int minutes = remaining; var builder = new StringBuilder(); if (weeks > 0) { builder.Append(weeks).Append('w'); } if (days > 0) { builder.Append(days).Append('d'); } if (hours > 0) { builder.Append(hours).Append('h'); } if (minutes > 0 || builder.Length == 0) { builder.Append(minutes).Append('m'); } return builder.ToString(); } private static string DeriveTitleFromPath(string relativeFilePath, string id) { if (string.IsNullOrWhiteSpace(relativeFilePath)) { return id; } string fileName = Path.GetFileNameWithoutExtension(relativeFilePath) ?? id; string prefix = id + "-"; if (fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { fileName = fileName.Substring(prefix.Length); } fileName = fileName.Replace('-', ' ').Trim(); return string.IsNullOrWhiteSpace(fileName) ? id : fileName; } private static string EscapeFrontMatterValue(string value) { return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace("\r", " ").Replace("\n", " ").Trim(); } private static ParsedTaskBody ParseBodySections(List lines, int bodyStart, TaskRecord task) { var parsed = new ParsedTaskBody(); string currentHeading = null; for (int i = bodyStart; i < lines.Count; i++) { string line = lines[i]; Match headingMatch = HeadingRegex.Match(line); if (headingMatch.Success) { currentHeading = headingMatch.Groups[1].Value.Trim(); if (!parsed.OrderedHeadings.Contains(currentHeading)) { parsed.OrderedHeadings.Add(currentHeading); } if (!parsed.ValuesByHeading.ContainsKey(currentHeading)) { parsed.ValuesByHeading[currentHeading] = string.Empty; } continue; } if (H1Regex.IsMatch(line) || string.IsNullOrEmpty(currentHeading)) { continue; } if (parsed.ValuesByHeading[currentHeading].Length > 0) { parsed.ValuesByHeading[currentHeading] += "\n"; } parsed.ValuesByHeading[currentHeading] += line; } foreach (string heading in EditableSectionOrder) { if (!parsed.ValuesByHeading.ContainsKey(heading)) { parsed.ValuesByHeading[heading] = GetEditableSectionValue(task, heading); } } return parsed; } private static void AddSectionContent(List lines, string content) { if (string.IsNullOrEmpty(content)) { return; } lines.AddRange(content.Replace("\r\n", "\n").Split('\n')); } private static string GetEditableSectionValue(TaskRecord task, string heading) { switch (heading) { case "Why": return task.Why ?? string.Empty; case "Expected Outcome": return task.ExpectedOutcome ?? string.Empty; case "Current Context": return task.CurrentContext ?? string.Empty; case "Acceptance Criteria": return task.AcceptanceCriteria ?? string.Empty; case "Verification": return task.Verification ?? string.Empty; case "Risks / Open Questions": return task.Risks ?? string.Empty; case "Human Decisions Needed": return task.HumanDecisions ?? string.Empty; case "Decision Log": return task.DecisionLog ?? string.Empty; case "Handoff Notes": return task.HandoffNotes ?? string.Empty; default: return string.Empty; } } private sealed class ParsedTaskBody { public readonly List OrderedHeadings = new List(); public readonly Dictionary ValuesByHeading = new Dictionary(StringComparer.OrdinalIgnoreCase); } } }