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

1365 lines
49 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 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/Features/Tasks/Editor/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<string>();
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<string>();
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<TaskRecord> 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<TaskRecord> tasks)
{
var values = new HashSet<string>(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<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 "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<string>
{
"---",
"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<string>();
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<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}` | {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<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;
}
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<TaskBoardOwnersConfig>(assetPath);
if (config != null)
{
return config;
}
string directory = Path.GetDirectoryName(assetPath);
if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory))
{
EnsureAssetFolder(directory);
}
config = ScriptableObject.CreateInstance<TaskBoardOwnersConfig>();
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<string> 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<string> 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<string> OrderedHeadings = new List<string>();
public readonly Dictionary<string, string> ValuesByHeading = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
}