[Final] TaskBoard
This commit is contained in:
@@ -6,15 +6,30 @@ 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)
|
||||
{
|
||||
@@ -45,6 +60,39 @@ namespace Project.Tasks.Editor
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -70,6 +118,8 @@ namespace Project.Tasks.Editor
|
||||
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))
|
||||
{
|
||||
@@ -86,43 +136,44 @@ namespace Project.Tasks.Editor
|
||||
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 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))
|
||||
{
|
||||
SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask);
|
||||
SaveTaskFile(updatedTask.AbsoluteFilePath, updatedTask);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -134,6 +185,295 @@ namespace Project.Tasks.Editor
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -190,6 +530,7 @@ namespace Project.Tasks.Editor
|
||||
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)
|
||||
@@ -207,6 +548,7 @@ namespace Project.Tasks.Editor
|
||||
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
|
||||
task.AbsoluteFilePath = absolutePath;
|
||||
task.FileExists = true;
|
||||
task.DetailsLoaded = false;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -234,47 +576,9 @@ namespace Project.Tasks.Editor
|
||||
|
||||
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;
|
||||
string normalized;
|
||||
int totalMinutes;
|
||||
return TryNormalizeExecutionTime(value, out normalized, out totalMinutes);
|
||||
}
|
||||
|
||||
public static string GetProjectRoot()
|
||||
@@ -368,15 +672,18 @@ namespace Project.Tasks.Editor
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasOwnerColumn = columns.Length >= 8;
|
||||
|
||||
var task = new TaskRecord
|
||||
{
|
||||
Id = columns[0],
|
||||
Status = columns[1],
|
||||
Status = NormalizeStatus(columns[1]),
|
||||
Priority = NormalizePriority(columns[2]),
|
||||
Area = columns[3],
|
||||
ExecutionTime = columns[4],
|
||||
RelativeFilePath = StripTicks(columns[5]),
|
||||
Summary = columns[6],
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -384,6 +691,7 @@ namespace Project.Tasks.Editor
|
||||
{
|
||||
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)
|
||||
@@ -391,7 +699,8 @@ namespace Project.Tasks.Editor
|
||||
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
|
||||
}
|
||||
|
||||
PopulateTaskFromFile(task);
|
||||
int estimatedMinutes;
|
||||
task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1;
|
||||
ValidateTask(task);
|
||||
data.Tasks.Add(task);
|
||||
}
|
||||
@@ -399,11 +708,23 @@ namespace Project.Tasks.Editor
|
||||
|
||||
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 = task.Id;
|
||||
task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -504,6 +825,9 @@ namespace Project.Tasks.Editor
|
||||
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))
|
||||
@@ -518,7 +842,10 @@ namespace Project.Tasks.Editor
|
||||
}
|
||||
break;
|
||||
case "owner":
|
||||
task.Owner = value;
|
||||
if (!string.IsNullOrWhiteSpace(value) && !string.Equals(task.Owner, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
task.ValidationMessages.Add("Owner в task-файле не совпадает с реестром.");
|
||||
}
|
||||
break;
|
||||
case "created":
|
||||
task.Created = value;
|
||||
@@ -527,7 +854,8 @@ namespace Project.Tasks.Editor
|
||||
task.Updated = value;
|
||||
break;
|
||||
case "execution_time":
|
||||
if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase))
|
||||
string normalizedExecutionTime = NormalizeExecutionTime(value);
|
||||
if (!string.IsNullOrEmpty(normalizedExecutionTime) && !string.Equals(task.ExecutionTime, normalizedExecutionTime, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром.");
|
||||
}
|
||||
@@ -537,6 +865,7 @@ namespace Project.Tasks.Editor
|
||||
|
||||
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);
|
||||
@@ -548,10 +877,16 @@ namespace Project.Tasks.Editor
|
||||
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))
|
||||
{
|
||||
@@ -571,7 +906,7 @@ namespace Project.Tasks.Editor
|
||||
File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void SaveTaskFrontMatter(string taskPath, TaskRecord task)
|
||||
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);
|
||||
@@ -583,6 +918,7 @@ namespace Project.Tasks.Editor
|
||||
"---",
|
||||
"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),
|
||||
@@ -614,6 +950,8 @@ namespace Project.Tasks.Editor
|
||||
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);
|
||||
@@ -621,7 +959,69 @@ namespace Project.Tasks.Editor
|
||||
UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today);
|
||||
UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime);
|
||||
|
||||
File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||||
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)
|
||||
@@ -644,14 +1044,27 @@ namespace Project.Tasks.Editor
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |",
|
||||
"| {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.Summary ?? string.Empty).Replace("|", "/"));
|
||||
(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)
|
||||
@@ -718,5 +1131,234 @@ namespace Project.Tasks.Editor
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user