[Final] TaskBoard
This commit is contained in:
@@ -5,7 +5,13 @@ namespace Project.Tasks.Editor
|
|||||||
{
|
{
|
||||||
internal static class TaskBoardConstants
|
internal static class TaskBoardConstants
|
||||||
{
|
{
|
||||||
public static readonly string[] Statuses = { "proposal", "ready", "in_progress", "blocked", "done" };
|
public const int MinutesPerHour = 60;
|
||||||
|
public const int HoursPerDay = 8;
|
||||||
|
public const int MinutesPerDay = HoursPerDay * MinutesPerHour;
|
||||||
|
public const int WorkDaysPerWeek = 5;
|
||||||
|
public const int MinutesPerWeek = WorkDaysPerWeek * MinutesPerDay;
|
||||||
|
|
||||||
|
public static readonly string[] Statuses = { "BackLog", "ToDo", "InProgress", "Review", "Done" };
|
||||||
public static readonly string[] Priorities = { "Lowest", "Low", "Medium", "High", "Highest" };
|
public static readonly string[] Priorities = { "Lowest", "Low", "Medium", "High", "Highest" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +29,9 @@ namespace Project.Tasks.Editor
|
|||||||
public string ExecutionTime;
|
public string ExecutionTime;
|
||||||
public string RelativeFilePath;
|
public string RelativeFilePath;
|
||||||
public string AbsoluteFilePath;
|
public string AbsoluteFilePath;
|
||||||
public string Summary;
|
public string IndexSummary;
|
||||||
|
public string TaskSummary;
|
||||||
|
public int EstimatedMinutes = -1;
|
||||||
public string Header;
|
public string Header;
|
||||||
public string Why;
|
public string Why;
|
||||||
public string ExpectedOutcome;
|
public string ExpectedOutcome;
|
||||||
@@ -35,6 +43,7 @@ namespace Project.Tasks.Editor
|
|||||||
public string DecisionLog;
|
public string DecisionLog;
|
||||||
public string HandoffNotes;
|
public string HandoffNotes;
|
||||||
public bool FileExists;
|
public bool FileExists;
|
||||||
|
public bool DetailsLoaded;
|
||||||
public int IndexLineNumber = -1;
|
public int IndexLineNumber = -1;
|
||||||
public readonly List<string> ValidationMessages = new List<string>();
|
public readonly List<string> ValidationMessages = new List<string>();
|
||||||
}
|
}
|
||||||
@@ -45,7 +54,9 @@ namespace Project.Tasks.Editor
|
|||||||
public string ProjectRoot;
|
public string ProjectRoot;
|
||||||
public string TasksDirectory;
|
public string TasksDirectory;
|
||||||
public string IndexPath;
|
public string IndexPath;
|
||||||
|
public string OwnersConfigPath;
|
||||||
public readonly List<TaskRecord> Tasks = new List<TaskRecord>();
|
public readonly List<TaskRecord> Tasks = new List<TaskRecord>();
|
||||||
public readonly List<string> Warnings = new List<string>();
|
public readonly List<string> Warnings = new List<string>();
|
||||||
|
public readonly List<string> OwnerPresets = new List<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: acfa62607cc78b8499e371559154647f, type: 3}
|
||||||
|
m_Name: TaskBoardOwners
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp-Editor::Project.Tasks.Editor.TaskBoardOwnersConfig
|
||||||
|
owners:
|
||||||
|
- unassigned
|
||||||
|
- pretty_kotik
|
||||||
|
- gitenax
|
||||||
|
- abysscion
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 84c93775036fa9242b5e5e4397f96d04
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Project.Tasks.Editor
|
||||||
|
{
|
||||||
|
internal sealed class TaskBoardOwnersConfig : ScriptableObject
|
||||||
|
{
|
||||||
|
public List<string> owners = new List<string> { "unassigned" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: acfa62607cc78b8499e371559154647f
|
||||||
@@ -6,15 +6,30 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using UnityEditor;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Project.Tasks.Editor
|
namespace Project.Tasks.Editor
|
||||||
{
|
{
|
||||||
internal static class TaskBoardService
|
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 HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled);
|
||||||
private static readonly Regex H1Regex = 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 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)
|
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)
|
public static int GetPrioritySortOrder(string value)
|
||||||
{
|
{
|
||||||
switch (NormalizePriority(value))
|
switch (NormalizePriority(value))
|
||||||
@@ -70,6 +118,8 @@ namespace Project.Tasks.Editor
|
|||||||
data.ProjectRoot = GetProjectRoot();
|
data.ProjectRoot = GetProjectRoot();
|
||||||
data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks"));
|
data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks"));
|
||||||
data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md"));
|
data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md"));
|
||||||
|
data.OwnersConfigPath = "Assets/Editor/Tasks/TaskBoardOwners.asset";
|
||||||
|
LoadOwnerPresets(data);
|
||||||
|
|
||||||
if (!Directory.Exists(data.TasksDirectory))
|
if (!Directory.Exists(data.TasksDirectory))
|
||||||
{
|
{
|
||||||
@@ -86,43 +136,44 @@ namespace Project.Tasks.Editor
|
|||||||
string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8);
|
string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8);
|
||||||
ParseIndex(data, lines);
|
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);
|
data.Tasks.Sort(CompareTaskIds);
|
||||||
return data;
|
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)
|
public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error)
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
CanonicalizeTask(updatedTask);
|
||||||
SaveIndexRow(data.IndexPath, updatedTask);
|
SaveIndexRow(data.IndexPath, updatedTask);
|
||||||
|
|
||||||
if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath))
|
if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath))
|
||||||
{
|
{
|
||||||
SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask);
|
SaveTaskFile(updatedTask.AbsoluteFilePath, updatedTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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)
|
public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error)
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
@@ -190,6 +530,7 @@ namespace Project.Tasks.Editor
|
|||||||
string content = template
|
string content = template
|
||||||
.Replace("id: TASK-XXXX", "id: " + task.Id)
|
.Replace("id: TASK-XXXX", "id: " + task.Id)
|
||||||
.Replace("title: Короткий заголовок", "title: " + title)
|
.Replace("title: Короткий заголовок", "title: " + title)
|
||||||
|
.Replace("summary: Короткое описание задачи", "summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.IndexSummary) ? title : task.IndexSummary))
|
||||||
.Replace("priority: Medium", "priority: " + priority)
|
.Replace("priority: Medium", "priority: " + priority)
|
||||||
.Replace("area: docs", "area: " + area)
|
.Replace("area: docs", "area: " + area)
|
||||||
.Replace("owner: unassigned", "owner: " + owner)
|
.Replace("owner: unassigned", "owner: " + owner)
|
||||||
@@ -207,6 +548,7 @@ namespace Project.Tasks.Editor
|
|||||||
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
|
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
|
||||||
task.AbsoluteFilePath = absolutePath;
|
task.AbsoluteFilePath = absolutePath;
|
||||||
task.FileExists = true;
|
task.FileExists = true;
|
||||||
|
task.DetailsLoaded = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -234,47 +576,9 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
public static bool IsValidExecutionTime(string value)
|
public static bool IsValidExecutionTime(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
string normalized;
|
||||||
{
|
int totalMinutes;
|
||||||
return false;
|
return TryNormalizeExecutionTime(value, out normalized, out totalMinutes);
|
||||||
}
|
|
||||||
|
|
||||||
Match match = JiraTimeRegex.Match(value.Trim());
|
|
||||||
if (!match.Success)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasAnyGroup = false;
|
|
||||||
for (int i = 1; i < match.Groups.Count; i++)
|
|
||||||
{
|
|
||||||
if (match.Groups[i].Success)
|
|
||||||
{
|
|
||||||
hasAnyGroup = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAnyGroup)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.Groups[4].Success)
|
|
||||||
{
|
|
||||||
int minutes;
|
|
||||||
if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minutes % 30 != 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetProjectRoot()
|
public static string GetProjectRoot()
|
||||||
@@ -368,15 +672,18 @@ namespace Project.Tasks.Editor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasOwnerColumn = columns.Length >= 8;
|
||||||
|
|
||||||
var task = new TaskRecord
|
var task = new TaskRecord
|
||||||
{
|
{
|
||||||
Id = columns[0],
|
Id = columns[0],
|
||||||
Status = columns[1],
|
Status = NormalizeStatus(columns[1]),
|
||||||
Priority = NormalizePriority(columns[2]),
|
Priority = NormalizePriority(columns[2]),
|
||||||
Area = columns[3],
|
Area = columns[3],
|
||||||
ExecutionTime = columns[4],
|
Owner = hasOwnerColumn ? columns[4] : "unassigned",
|
||||||
RelativeFilePath = StripTicks(columns[5]),
|
ExecutionTime = NormalizeExecutionTime(hasOwnerColumn ? columns[5] : columns[4]),
|
||||||
Summary = columns[6],
|
RelativeFilePath = StripTicks(hasOwnerColumn ? columns[6] : columns[5]),
|
||||||
|
IndexSummary = hasOwnerColumn ? columns[7] : columns[6],
|
||||||
IndexLineNumber = i,
|
IndexLineNumber = i,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -384,6 +691,7 @@ namespace Project.Tasks.Editor
|
|||||||
{
|
{
|
||||||
task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath));
|
task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath));
|
||||||
task.FileExists = File.Exists(task.AbsoluteFilePath);
|
task.FileExists = File.Exists(task.AbsoluteFilePath);
|
||||||
|
task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.FileExists)
|
if (!task.FileExists)
|
||||||
@@ -391,7 +699,8 @@ namespace Project.Tasks.Editor
|
|||||||
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
|
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
|
||||||
}
|
}
|
||||||
|
|
||||||
PopulateTaskFromFile(task);
|
int estimatedMinutes;
|
||||||
|
task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1;
|
||||||
ValidateTask(task);
|
ValidateTask(task);
|
||||||
data.Tasks.Add(task);
|
data.Tasks.Add(task);
|
||||||
}
|
}
|
||||||
@@ -399,11 +708,23 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private static void PopulateTaskFromFile(TaskRecord 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 (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(task.Title))
|
if (string.IsNullOrEmpty(task.Title))
|
||||||
{
|
{
|
||||||
task.Title = task.Id;
|
task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -504,6 +825,9 @@ namespace Project.Tasks.Editor
|
|||||||
case "title":
|
case "title":
|
||||||
task.Title = value;
|
task.Title = value;
|
||||||
break;
|
break;
|
||||||
|
case "summary":
|
||||||
|
task.TaskSummary = value;
|
||||||
|
break;
|
||||||
case "priority":
|
case "priority":
|
||||||
string normalizedPriority = NormalizePriority(value);
|
string normalizedPriority = NormalizePriority(value);
|
||||||
if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -518,7 +842,10 @@ namespace Project.Tasks.Editor
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "owner":
|
case "owner":
|
||||||
task.Owner = value;
|
if (!string.IsNullOrWhiteSpace(value) && !string.Equals(task.Owner, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
task.ValidationMessages.Add("Owner в task-файле не совпадает с реестром.");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "created":
|
case "created":
|
||||||
task.Created = value;
|
task.Created = value;
|
||||||
@@ -527,7 +854,8 @@ namespace Project.Tasks.Editor
|
|||||||
task.Updated = value;
|
task.Updated = value;
|
||||||
break;
|
break;
|
||||||
case "execution_time":
|
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-файле не совпадает с реестром.");
|
task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром.");
|
||||||
}
|
}
|
||||||
@@ -537,6 +865,7 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private static void ValidateTask(TaskRecord task)
|
private static void ValidateTask(TaskRecord task)
|
||||||
{
|
{
|
||||||
|
task.Status = NormalizeStatus(task.Status);
|
||||||
if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0)
|
if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0)
|
||||||
{
|
{
|
||||||
task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status);
|
task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status);
|
||||||
@@ -548,10 +877,16 @@ namespace Project.Tasks.Editor
|
|||||||
task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority);
|
task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task.ExecutionTime = NormalizeExecutionTime(task.ExecutionTime);
|
||||||
if (!IsValidExecutionTime(task.ExecutionTime))
|
if (!IsValidExecutionTime(task.ExecutionTime))
|
||||||
{
|
{
|
||||||
task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам.");
|
task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам.");
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int estimatedMinutes;
|
||||||
|
task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(task.RelativeFilePath))
|
if (string.IsNullOrEmpty(task.RelativeFilePath))
|
||||||
{
|
{
|
||||||
@@ -571,7 +906,7 @@ namespace Project.Tasks.Editor
|
|||||||
File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false));
|
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();
|
var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList();
|
||||||
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
@@ -583,6 +918,7 @@ namespace Project.Tasks.Editor
|
|||||||
"---",
|
"---",
|
||||||
"id: " + task.Id,
|
"id: " + task.Id,
|
||||||
"title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title),
|
"title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title),
|
||||||
|
"summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.TaskSummary) ? task.IndexSummary : task.TaskSummary),
|
||||||
"priority: " + task.Priority,
|
"priority: " + task.Priority,
|
||||||
"area: " + task.Area,
|
"area: " + task.Area,
|
||||||
"owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner),
|
"owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner),
|
||||||
@@ -614,6 +950,8 @@ namespace Project.Tasks.Editor
|
|||||||
throw new InvalidOperationException("Не удалось прочитать front matter task-файла.");
|
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, "priority", task.Priority);
|
||||||
UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area);
|
UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area);
|
||||||
UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner);
|
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, "updated", today);
|
||||||
UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime);
|
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)
|
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(
|
return string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |",
|
"| {0} | {1} | {2} | {3} | {4} | {5} | `{6}` | {7} |",
|
||||||
task.Id,
|
task.Id,
|
||||||
task.Status,
|
task.Status,
|
||||||
task.Priority,
|
task.Priority,
|
||||||
task.Area,
|
task.Area,
|
||||||
|
string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner,
|
||||||
task.ExecutionTime,
|
task.ExecutionTime,
|
||||||
NormalizePath(task.RelativeFilePath),
|
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)
|
private static string[] SplitMarkdownRow(string line)
|
||||||
@@ -718,5 +1131,234 @@ namespace Project.Tasks.Editor
|
|||||||
int number;
|
int number;
|
||||||
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,28 @@ namespace Project.Tasks.Editor
|
|||||||
internal sealed class TaskBoardWindow : EditorWindow
|
internal sealed class TaskBoardWindow : EditorWindow
|
||||||
{
|
{
|
||||||
private const float ColumnWidth = 290f;
|
private const float ColumnWidth = 290f;
|
||||||
private const float DetailsWidth = 420f;
|
private const float DefaultDetailsWidth = 420f;
|
||||||
|
private const float MinDetailsWidth = 320f;
|
||||||
|
private const float MinBoardWidth = 420f;
|
||||||
|
private const float SplitterWidth = 5f;
|
||||||
|
private const string CustomOwnerOption = "<Custom>";
|
||||||
|
private const string DetailsWidthPrefsKey = "Project.Tasks.Editor.TaskBoardWindow.DetailsWidth";
|
||||||
|
private const double DeleteConfirmTimeoutSeconds = 3.0;
|
||||||
|
|
||||||
private TaskBoardData data;
|
private TaskBoardData data;
|
||||||
private Vector2 boardScroll;
|
private Vector2 boardScroll;
|
||||||
private Vector2 detailsScroll;
|
private Vector2 detailsScroll;
|
||||||
private string searchText = string.Empty;
|
private string searchText = string.Empty;
|
||||||
private string areaFilter = "All";
|
private string sortOption = "Priority Desc";
|
||||||
private string priorityFilter = "All";
|
private bool filtersExpanded = true;
|
||||||
private bool showDone = true;
|
|
||||||
|
private readonly HashSet<string> selectedStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> selectedPriorities = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> selectedAreas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> selectedOwners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> selectedFileStates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> selectedWarningStates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private TaskRecord selectedTask;
|
private TaskRecord selectedTask;
|
||||||
private string saveMessage = string.Empty;
|
private string saveMessage = string.Empty;
|
||||||
private MessageType saveMessageType = MessageType.Info;
|
private MessageType saveMessageType = MessageType.Info;
|
||||||
@@ -25,12 +38,28 @@ namespace Project.Tasks.Editor
|
|||||||
private string editStatus;
|
private string editStatus;
|
||||||
private string editPriority;
|
private string editPriority;
|
||||||
private string editOwner;
|
private string editOwner;
|
||||||
|
private bool editOwnerIsCustom;
|
||||||
private string editExecutionTime;
|
private string editExecutionTime;
|
||||||
private string editSummary;
|
private string editIndexSummary;
|
||||||
|
private string editTaskSummary;
|
||||||
|
private string editWhy;
|
||||||
|
private string editExpectedOutcome;
|
||||||
|
private string editCurrentContext;
|
||||||
|
private string editAcceptanceCriteria;
|
||||||
|
private string editVerification;
|
||||||
|
private string editRisks;
|
||||||
|
private string editHumanDecisions;
|
||||||
|
private string editDecisionLog;
|
||||||
|
private string editHandoffNotes;
|
||||||
|
|
||||||
private GUIStyle readOnlyWrappedTextArea;
|
private GUIStyle readOnlyWrappedTextArea;
|
||||||
private GUIStyle priorityBadgeStyle;
|
private GUIStyle priorityBadgeStyle;
|
||||||
private TaskRecord pressedTask;
|
private TaskRecord pressedTask;
|
||||||
private Vector2 pressedMousePosition;
|
private Vector2 pressedMousePosition;
|
||||||
|
private float detailsWidth = DefaultDetailsWidth;
|
||||||
|
private bool isResizingDetails;
|
||||||
|
private string pendingDeleteTaskId;
|
||||||
|
private double pendingDeleteUntil;
|
||||||
|
|
||||||
[MenuItem("Tools/Tasks/Kanban Board")]
|
[MenuItem("Tools/Tasks/Kanban Board")]
|
||||||
public static void Open()
|
public static void Open()
|
||||||
@@ -40,11 +69,18 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
|
detailsWidth = EditorPrefs.GetFloat(DetailsWidthPrefsKey, DefaultDetailsWidth);
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnGUI()
|
private void OnGUI()
|
||||||
{
|
{
|
||||||
|
ResetDeleteConfirmationIfExpired();
|
||||||
DrawToolbar();
|
DrawToolbar();
|
||||||
|
|
||||||
if (data == null)
|
if (data == null)
|
||||||
@@ -63,9 +99,19 @@ namespace Project.Tasks.Editor
|
|||||||
EditorGUILayout.HelpBox(saveMessage, saveMessageType);
|
EditorGUILayout.HelpBox(saveMessage, saveMessageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DrawSummaryBar();
|
||||||
|
DrawFilterPanel();
|
||||||
|
|
||||||
|
float clampedDetailsWidth = Mathf.Clamp(detailsWidth, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth));
|
||||||
|
if (!Mathf.Approximately(clampedDetailsWidth, detailsWidth))
|
||||||
|
{
|
||||||
|
detailsWidth = clampedDetailsWidth;
|
||||||
|
}
|
||||||
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
EditorGUILayout.BeginHorizontal();
|
||||||
DrawBoard();
|
DrawBoard(position.width - detailsWidth - SplitterWidth - 24f);
|
||||||
DrawDetails();
|
DrawDetailsSplitter();
|
||||||
|
DrawDetails(detailsWidth);
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +121,7 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f)))
|
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f)))
|
||||||
{
|
{
|
||||||
Reload();
|
Reload(selectedTask != null ? selectedTask.Id : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f)))
|
if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f)))
|
||||||
@@ -84,52 +130,123 @@ namespace Project.Tasks.Editor
|
|||||||
TaskBoardService.OpenInDefaultApp(indexPath);
|
TaskBoardService.OpenInDefaultApp(indexPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
GUILayout.Space(8f);
|
if (GUILayout.Button("Owners Config", EditorStyles.toolbarButton, GUILayout.Width(95f)))
|
||||||
GUILayout.Label("Search", GUILayout.Width(45f));
|
{
|
||||||
searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(150f));
|
OpenOwnersConfig();
|
||||||
|
}
|
||||||
|
|
||||||
GUILayout.Space(8f);
|
GUILayout.Space(8f);
|
||||||
areaFilter = DrawFilterPopup("Area", areaFilter, BuildAreaOptions(), 120f);
|
GUILayout.Label("Search", GUILayout.Width(45f));
|
||||||
priorityFilter = DrawFilterPopup("Priority", priorityFilter, BuildPriorityOptions(), 110f);
|
searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(180f));
|
||||||
showDone = GUILayout.Toggle(showDone, "Show Done", EditorStyles.toolbarButton, GUILayout.Width(85f));
|
|
||||||
|
|
||||||
GUILayout.FlexibleSpace();
|
GUILayout.FlexibleSpace();
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string DrawFilterPopup(string label, string currentValue, string[] options, float width)
|
private void DrawSummaryBar()
|
||||||
{
|
{
|
||||||
GUILayout.Label(label, GUILayout.Width(36f));
|
if (data == null)
|
||||||
int currentIndex = Array.IndexOf(options, currentValue);
|
|
||||||
if (currentIndex < 0)
|
|
||||||
{
|
{
|
||||||
currentIndex = 0;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int nextIndex = EditorGUILayout.Popup(currentIndex, options, EditorStyles.toolbarPopup, GUILayout.Width(width));
|
List<TaskRecord> filteredTasks = GetFilteredTasks();
|
||||||
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
int totalMinutes = TaskBoardService.SumEstimatedMinutes(filteredTasks);
|
||||||
|
int warnings = filteredTasks.Count(task => task.ValidationMessages.Count > 0);
|
||||||
|
int missingFiles = filteredTasks.Count(task => !task.FileExists);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginVertical("box");
|
||||||
|
EditorGUILayout.LabelField(
|
||||||
|
"Visible Tasks: " + filteredTasks.Count + " / " + data.Tasks.Count
|
||||||
|
+ " | Visible Time: " + TaskBoardService.FormatMinutesForDisplay(totalMinutes)
|
||||||
|
+ " | Warnings: " + warnings
|
||||||
|
+ " | Missing Files: " + missingFiles,
|
||||||
|
EditorStyles.wordWrappedMiniLabel);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Label("Sort", GUILayout.Width(32f));
|
||||||
|
sortOption = DrawPopup(sortOption, BuildSortOptions(), 160f);
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawBoard()
|
private void DrawFilterPanel()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginVertical("box");
|
||||||
|
filtersExpanded = EditorGUILayout.Foldout(filtersExpanded, "Filters", true);
|
||||||
|
if (filtersExpanded)
|
||||||
|
{
|
||||||
|
DrawSelectionGroup("Statuses", TaskBoardConstants.Statuses, selectedStatuses);
|
||||||
|
DrawSelectionGroup("Priorities", TaskBoardConstants.Priorities, selectedPriorities);
|
||||||
|
DrawSelectionGroup("Areas", BuildAreaOptions(), selectedAreas);
|
||||||
|
DrawSelectionGroup("Owners", BuildOwnerFilterOptions(), selectedOwners);
|
||||||
|
DrawSelectionGroup("Files", new[] { "Existing", "Missing" }, selectedFileStates);
|
||||||
|
DrawSelectionGroup("Warnings", new[] { "Warnings", "Clean" }, selectedWarningStates);
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSelectionGroup(string label, string[] options, HashSet<string> selectedValues)
|
||||||
|
{
|
||||||
|
if (options == null || options.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.BeginVertical("box");
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
|
||||||
|
if (GUILayout.Button("All", GUILayout.Width(45f)))
|
||||||
|
{
|
||||||
|
SetAll(selectedValues, options, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GUILayout.Button("Nothing", GUILayout.Width(65f)))
|
||||||
|
{
|
||||||
|
selectedValues.Clear();
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
for (int i = 0; i < options.Length; i++)
|
||||||
|
{
|
||||||
|
string option = options[i];
|
||||||
|
bool current = selectedValues.Contains(option);
|
||||||
|
bool next = GUILayout.Toggle(current, option, "Button");
|
||||||
|
if (next != current)
|
||||||
|
{
|
||||||
|
if (next)
|
||||||
|
{
|
||||||
|
selectedValues.Add(option);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selectedValues.Remove(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i + 1) % 4 == 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawBoard(float width)
|
||||||
{
|
{
|
||||||
List<TaskRecord> filteredTasks = GetFilteredTasks();
|
List<TaskRecord> filteredTasks = GetFilteredTasks();
|
||||||
|
|
||||||
EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true));
|
EditorGUILayout.BeginVertical(GUILayout.Width(Mathf.Max(MinBoardWidth, width)), GUILayout.ExpandHeight(true));
|
||||||
boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
|
boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
|
||||||
EditorGUILayout.BeginHorizontal();
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
|
||||||
foreach (string status in TaskBoardConstants.Statuses)
|
foreach (string status in TaskBoardConstants.Statuses)
|
||||||
{
|
{
|
||||||
if (!showDone && string.Equals(status, "done", StringComparison.Ordinal))
|
DrawColumn(status, filteredTasks.Where(task => string.Equals(task.Status, status, StringComparison.OrdinalIgnoreCase)).ToList());
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
DrawColumn(status, filteredTasks
|
|
||||||
.Where(task => string.Equals(task.Status, status, StringComparison.Ordinal))
|
|
||||||
.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority))
|
|
||||||
.ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
@@ -140,8 +257,9 @@ namespace Project.Tasks.Editor
|
|||||||
private void DrawColumn(string status, List<TaskRecord> tasks)
|
private void DrawColumn(string status, List<TaskRecord> tasks)
|
||||||
{
|
{
|
||||||
EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true));
|
EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true));
|
||||||
EditorGUILayout.LabelField(ObjectNames.NicifyVariableName(status), EditorStyles.boldLabel);
|
EditorGUILayout.LabelField(status, EditorStyles.boldLabel);
|
||||||
EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel);
|
EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel);
|
||||||
|
EditorGUILayout.LabelField(TaskBoardService.FormatMinutesForDisplay(TaskBoardService.SumEstimatedMinutes(tasks)), EditorStyles.miniLabel);
|
||||||
EditorGUILayout.Space(6f);
|
EditorGUILayout.Space(6f);
|
||||||
|
|
||||||
Rect headerRect = GUILayoutUtility.GetLastRect();
|
Rect headerRect = GUILayoutUtility.GetLastRect();
|
||||||
@@ -183,14 +301,14 @@ namespace Project.Tasks.Editor
|
|||||||
Rect firstRect = GUILayoutUtility.GetLastRect();
|
Rect firstRect = GUILayoutUtility.GetLastRect();
|
||||||
|
|
||||||
EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel);
|
EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel);
|
||||||
if (!string.IsNullOrEmpty(task.Summary))
|
if (!string.IsNullOrEmpty(task.IndexSummary))
|
||||||
{
|
{
|
||||||
EditorGUILayout.LabelField(task.Summary, EditorStyles.wordWrappedMiniLabel);
|
EditorGUILayout.LabelField(task.IndexSummary, EditorStyles.wordWrappedMiniLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorGUILayout.Space(4f);
|
EditorGUILayout.Space(4f);
|
||||||
EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel);
|
EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel);
|
||||||
EditorGUILayout.LabelField("Time: " + SafeValue(task.ExecutionTime), EditorStyles.miniLabel);
|
EditorGUILayout.LabelField("Time: " + GetFormattedTime(task), EditorStyles.miniLabel);
|
||||||
|
|
||||||
if (task.ValidationMessages.Count > 0)
|
if (task.ValidationMessages.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -209,9 +327,9 @@ namespace Project.Tasks.Editor
|
|||||||
HandleCardDrag(task, cardRect);
|
HandleCardDrag(task, cardRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawDetails()
|
private void DrawDetails(float width)
|
||||||
{
|
{
|
||||||
EditorGUILayout.BeginVertical("box", GUILayout.Width(DetailsWidth), GUILayout.ExpandHeight(true));
|
EditorGUILayout.BeginVertical("box", GUILayout.Width(width), GUILayout.ExpandHeight(true));
|
||||||
EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel);
|
EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel);
|
||||||
|
|
||||||
if (selectedTask == null)
|
if (selectedTask == null)
|
||||||
@@ -231,6 +349,11 @@ namespace Project.Tasks.Editor
|
|||||||
EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning);
|
EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedTask.FileExists)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("Task-файл отсутствует. Поля из task-файла недоступны до создания файла по шаблону.", MessageType.Info);
|
||||||
|
}
|
||||||
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
EditorGUILayout.BeginHorizontal();
|
||||||
using (new EditorGUI.DisabledScope(!selectedTask.FileExists))
|
using (new EditorGUI.DisabledScope(!selectedTask.FileExists))
|
||||||
{
|
{
|
||||||
@@ -254,6 +377,9 @@ namespace Project.Tasks.Editor
|
|||||||
}
|
}
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
EditorGUILayout.Space(4f);
|
||||||
|
DrawDeleteButton();
|
||||||
|
|
||||||
EditorGUILayout.Space(8f);
|
EditorGUILayout.Space(8f);
|
||||||
DrawEditableFields();
|
DrawEditableFields();
|
||||||
EditorGUILayout.Space(12f);
|
EditorGUILayout.Space(12f);
|
||||||
@@ -262,21 +388,23 @@ namespace Project.Tasks.Editor
|
|||||||
EditorGUILayout.LabelField("Priority", GUILayout.Width(80f));
|
EditorGUILayout.LabelField("Priority", GUILayout.Width(80f));
|
||||||
DrawPriorityBadge(selectedTask.Priority);
|
DrawPriorityBadge(selectedTask.Priority);
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
|
DrawReadOnlyField("Estimate", GetFormattedTime(selectedTask));
|
||||||
DrawReadOnlyField("File", selectedTask.RelativeFilePath);
|
DrawReadOnlyField("File", selectedTask.RelativeFilePath);
|
||||||
DrawReadOnlyField("Created", selectedTask.Created);
|
DrawReadOnlyField("Created", selectedTask.Created);
|
||||||
DrawReadOnlyField("Updated", selectedTask.Updated);
|
DrawReadOnlyField("Updated", selectedTask.Updated);
|
||||||
DrawReadOnlyField("Area", selectedTask.Area);
|
DrawReadOnlyField("Area", selectedTask.Area);
|
||||||
|
|
||||||
EditorGUILayout.Space(10f);
|
EditorGUILayout.Space(10f);
|
||||||
DrawSection("Why", selectedTask.Why);
|
EditorGUILayout.LabelField("Task File Sections", EditorStyles.boldLabel);
|
||||||
DrawSection("Expected Outcome", selectedTask.ExpectedOutcome);
|
DrawEditableSection("Why", ref editWhy, selectedTask.FileExists);
|
||||||
DrawSection("Current Context", selectedTask.CurrentContext);
|
DrawEditableSection("Expected Outcome", ref editExpectedOutcome, selectedTask.FileExists);
|
||||||
DrawSection("Acceptance Criteria", selectedTask.AcceptanceCriteria);
|
DrawEditableSection("Current Context", ref editCurrentContext, selectedTask.FileExists);
|
||||||
DrawSection("Verification", selectedTask.Verification);
|
DrawEditableSection("Acceptance Criteria", ref editAcceptanceCriteria, selectedTask.FileExists);
|
||||||
DrawSection("Risks / Open Questions", selectedTask.Risks);
|
DrawEditableSection("Verification", ref editVerification, selectedTask.FileExists);
|
||||||
DrawSection("Human Decisions Needed", selectedTask.HumanDecisions);
|
DrawEditableSection("Risks / Open Questions", ref editRisks, selectedTask.FileExists);
|
||||||
DrawSection("Decision Log", selectedTask.DecisionLog);
|
DrawEditableSection("Human Decisions Needed", ref editHumanDecisions, selectedTask.FileExists);
|
||||||
DrawSection("Handoff Notes", selectedTask.HandoffNotes);
|
DrawEditableSection("Decision Log", ref editDecisionLog, selectedTask.FileExists);
|
||||||
|
DrawEditableSection("Handoff Notes", ref editHandoffNotes, selectedTask.FileExists);
|
||||||
|
|
||||||
EditorGUILayout.EndScrollView();
|
EditorGUILayout.EndScrollView();
|
||||||
EditorGUILayout.EndVertical();
|
EditorGUILayout.EndVertical();
|
||||||
@@ -284,19 +412,36 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private void DrawEditableFields()
|
private void DrawEditableFields()
|
||||||
{
|
{
|
||||||
EditorGUILayout.LabelField("Quick Edit", EditorStyles.boldLabel);
|
EditorGUILayout.LabelField("Index Fields", EditorStyles.boldLabel);
|
||||||
|
|
||||||
editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses);
|
editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses);
|
||||||
editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities);
|
editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities);
|
||||||
editOwner = EditorGUILayout.TextField("Owner", editOwner ?? string.Empty);
|
DrawOwnerField();
|
||||||
editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty);
|
editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty);
|
||||||
|
|
||||||
EditorGUILayout.LabelField("Summary");
|
string normalizedExecutionTime;
|
||||||
editSummary = EditorGUILayout.TextArea(editSummary ?? string.Empty, GUILayout.MinHeight(60f));
|
int estimatedMinutes;
|
||||||
|
if (TaskBoardService.TryNormalizeExecutionTime(editExecutionTime, out normalizedExecutionTime, out estimatedMinutes))
|
||||||
|
{
|
||||||
|
editExecutionTime = normalizedExecutionTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField("Estimate", TaskBoardService.IsValidExecutionTime(editExecutionTime)
|
||||||
|
? TaskBoardService.FormatExecutionTimeForDisplay(editExecutionTime)
|
||||||
|
: "-");
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField("Index Summary");
|
||||||
|
editIndexSummary = EditorGUILayout.TextArea(editIndexSummary ?? string.Empty, GUILayout.MinHeight(60f));
|
||||||
|
|
||||||
|
EditorGUILayout.Space(8f);
|
||||||
|
EditorGUILayout.LabelField("Task File Fields", EditorStyles.boldLabel);
|
||||||
|
using (new EditorGUI.DisabledScope(!selectedTask.FileExists))
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField("Task Summary");
|
||||||
|
editTaskSummary = EditorGUILayout.TextArea(editTaskSummary ?? string.Empty, GUILayout.MinHeight(60f));
|
||||||
|
}
|
||||||
|
|
||||||
bool hasChanges = HasChanges();
|
bool hasChanges = HasChanges();
|
||||||
bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime);
|
bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime);
|
||||||
|
|
||||||
if (!executionTimeValid)
|
if (!executionTimeValid)
|
||||||
{
|
{
|
||||||
EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning);
|
EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning);
|
||||||
@@ -311,6 +456,31 @@ namespace Project.Tasks.Editor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawDeleteButton()
|
||||||
|
{
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPending = string.Equals(pendingDeleteTaskId, selectedTask.Id, StringComparison.OrdinalIgnoreCase) && EditorApplication.timeSinceStartup <= pendingDeleteUntil;
|
||||||
|
Color previous = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = isPending ? new Color(0.85f, 0.3f, 0.3f) : new Color(0.7f, 0.25f, 0.25f);
|
||||||
|
if (GUILayout.Button(isPending ? "Sure?" : "Delete Task"))
|
||||||
|
{
|
||||||
|
if (isPending)
|
||||||
|
{
|
||||||
|
DeleteSelectedTask();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pendingDeleteTaskId = selectedTask.Id;
|
||||||
|
pendingDeleteUntil = EditorApplication.timeSinceStartup + DeleteConfirmTimeoutSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GUI.backgroundColor = previous;
|
||||||
|
}
|
||||||
|
|
||||||
private string DrawStringPopup(string label, string currentValue, string[] options)
|
private string DrawStringPopup(string label, string currentValue, string[] options)
|
||||||
{
|
{
|
||||||
int index = Array.IndexOf(options, currentValue);
|
int index = Array.IndexOf(options, currentValue);
|
||||||
@@ -323,22 +493,29 @@ namespace Project.Tasks.Editor
|
|||||||
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string DrawPopup(string currentValue, string[] options, float width)
|
||||||
|
{
|
||||||
|
int index = Array.IndexOf(options, currentValue);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextIndex = EditorGUILayout.Popup(index, options, EditorStyles.toolbarPopup, GUILayout.Width(width));
|
||||||
|
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawReadOnlyField(string label, string value)
|
private void DrawReadOnlyField(string label, string value)
|
||||||
{
|
{
|
||||||
EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value);
|
EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawSection(string title, string content)
|
private void DrawEditableSection(string title, ref string content, bool enabled)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.LabelField(title, EditorStyles.boldLabel);
|
EditorGUILayout.LabelField(title, EditorStyles.boldLabel);
|
||||||
using (new EditorGUI.DisabledScope(true))
|
using (new EditorGUI.DisabledScope(!enabled))
|
||||||
{
|
{
|
||||||
EditorGUILayout.TextArea(content, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f));
|
content = EditorGUILayout.TextArea(enabled ? (content ?? string.Empty) : string.Empty, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f));
|
||||||
}
|
}
|
||||||
EditorGUILayout.Space(6f);
|
EditorGUILayout.Space(6f);
|
||||||
}
|
}
|
||||||
@@ -347,10 +524,7 @@ namespace Project.Tasks.Editor
|
|||||||
{
|
{
|
||||||
if (readOnlyWrappedTextArea == null)
|
if (readOnlyWrappedTextArea == null)
|
||||||
{
|
{
|
||||||
readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea)
|
readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea) { wordWrap = true };
|
||||||
{
|
|
||||||
wordWrap = true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return readOnlyWrappedTextArea;
|
return readOnlyWrappedTextArea;
|
||||||
@@ -380,6 +554,32 @@ namespace Project.Tasks.Editor
|
|||||||
GUI.Label(rect, priority, style);
|
GUI.Label(rect, priority, style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawOwnerField()
|
||||||
|
{
|
||||||
|
string[] presetOptions = BuildOwnerPresetOptions();
|
||||||
|
int currentIndex = GetOwnerPopupIndex(presetOptions);
|
||||||
|
int nextIndex = EditorGUILayout.Popup("Owner", currentIndex, presetOptions);
|
||||||
|
|
||||||
|
if (nextIndex >= 0 && nextIndex < presetOptions.Length)
|
||||||
|
{
|
||||||
|
string selectedValue = presetOptions[nextIndex];
|
||||||
|
if (string.Equals(selectedValue, CustomOwnerOption, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
editOwnerIsCustom = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
editOwnerIsCustom = false;
|
||||||
|
editOwner = selectedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editOwnerIsCustom)
|
||||||
|
{
|
||||||
|
editOwner = EditorGUILayout.TextField("Custom Owner", editOwner ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleCardDrag(TaskRecord task, Rect cardRect)
|
private void HandleCardDrag(TaskRecord task, Rect cardRect)
|
||||||
{
|
{
|
||||||
Event current = Event.current;
|
Event current = Event.current;
|
||||||
@@ -473,19 +673,32 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
saveMessage = task.FileExists
|
saveMessage = task.FileExists
|
||||||
? "Статус задачи обновлен перетаскиванием."
|
? "Статус задачи обновлен перетаскиванием."
|
||||||
: "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
|
: "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому поля task-файла не обновлялись.";
|
||||||
saveMessageType = MessageType.Info;
|
saveMessageType = MessageType.Info;
|
||||||
Reload(selectedId);
|
Reload(selectedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectTask(TaskRecord task)
|
private void SelectTask(TaskRecord task)
|
||||||
{
|
{
|
||||||
|
ResetDeleteConfirmation();
|
||||||
selectedTask = task;
|
selectedTask = task;
|
||||||
editStatus = task.Status;
|
TaskBoardService.LoadTaskDetails(selectedTask);
|
||||||
editPriority = task.Priority;
|
editStatus = selectedTask.Status;
|
||||||
editOwner = task.Owner;
|
editPriority = selectedTask.Priority;
|
||||||
editExecutionTime = task.ExecutionTime;
|
editOwner = selectedTask.Owner;
|
||||||
editSummary = task.Summary;
|
editOwnerIsCustom = data != null && !data.OwnerPresets.Any(owner => string.Equals(owner, selectedTask.Owner, StringComparison.OrdinalIgnoreCase));
|
||||||
|
editExecutionTime = TaskBoardService.NormalizeExecutionTime(selectedTask.ExecutionTime);
|
||||||
|
editIndexSummary = selectedTask.IndexSummary;
|
||||||
|
editTaskSummary = selectedTask.TaskSummary;
|
||||||
|
editWhy = selectedTask.Why;
|
||||||
|
editExpectedOutcome = selectedTask.ExpectedOutcome;
|
||||||
|
editCurrentContext = selectedTask.CurrentContext;
|
||||||
|
editAcceptanceCriteria = selectedTask.AcceptanceCriteria;
|
||||||
|
editVerification = selectedTask.Verification;
|
||||||
|
editRisks = selectedTask.Risks;
|
||||||
|
editHumanDecisions = selectedTask.HumanDecisions;
|
||||||
|
editDecisionLog = selectedTask.DecisionLog;
|
||||||
|
editHandoffNotes = selectedTask.HandoffNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveSelectedTask()
|
private void SaveSelectedTask()
|
||||||
@@ -496,11 +709,24 @@ namespace Project.Tasks.Editor
|
|||||||
}
|
}
|
||||||
|
|
||||||
string selectedId = selectedTask.Id;
|
string selectedId = selectedTask.Id;
|
||||||
selectedTask.Status = editStatus;
|
selectedTask.Status = TaskBoardService.NormalizeStatus(editStatus);
|
||||||
selectedTask.Priority = editPriority;
|
selectedTask.Priority = editPriority;
|
||||||
selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim();
|
selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim();
|
||||||
selectedTask.ExecutionTime = editExecutionTime.Trim();
|
selectedTask.ExecutionTime = TaskBoardService.NormalizeExecutionTime(editExecutionTime);
|
||||||
selectedTask.Summary = (editSummary ?? string.Empty).Trim();
|
selectedTask.IndexSummary = (editIndexSummary ?? string.Empty).Trim();
|
||||||
|
if (selectedTask.FileExists)
|
||||||
|
{
|
||||||
|
selectedTask.TaskSummary = (editTaskSummary ?? string.Empty).Trim();
|
||||||
|
selectedTask.Why = (editWhy ?? string.Empty).Trim();
|
||||||
|
selectedTask.ExpectedOutcome = (editExpectedOutcome ?? string.Empty).Trim();
|
||||||
|
selectedTask.CurrentContext = (editCurrentContext ?? string.Empty).Trim();
|
||||||
|
selectedTask.AcceptanceCriteria = (editAcceptanceCriteria ?? string.Empty).Trim();
|
||||||
|
selectedTask.Verification = (editVerification ?? string.Empty).Trim();
|
||||||
|
selectedTask.Risks = (editRisks ?? string.Empty).Trim();
|
||||||
|
selectedTask.HumanDecisions = (editHumanDecisions ?? string.Empty).Trim();
|
||||||
|
selectedTask.DecisionLog = (editDecisionLog ?? string.Empty).Trim();
|
||||||
|
selectedTask.HandoffNotes = (editHandoffNotes ?? string.Empty).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
string error;
|
string error;
|
||||||
if (!TaskBoardService.Save(data, selectedTask, out error))
|
if (!TaskBoardService.Save(data, selectedTask, out error))
|
||||||
@@ -512,7 +738,7 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
saveMessage = selectedTask.FileExists
|
saveMessage = selectedTask.FileExists
|
||||||
? "Изменения сохранены в Index.md и task-файл."
|
? "Изменения сохранены в Index.md и task-файл."
|
||||||
: "Изменения сохранены в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
|
: "Изменения сохранены в Index.md. Task-файл не найден, поэтому поля task-файла не обновлялись.";
|
||||||
saveMessageType = MessageType.Info;
|
saveMessageType = MessageType.Info;
|
||||||
Reload(selectedId);
|
Reload(selectedId);
|
||||||
}
|
}
|
||||||
@@ -538,6 +764,29 @@ namespace Project.Tasks.Editor
|
|||||||
Reload(selectedId);
|
Reload(selectedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DeleteSelectedTask()
|
||||||
|
{
|
||||||
|
if (selectedTask == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string deletedId = selectedTask.Id;
|
||||||
|
string error;
|
||||||
|
if (!TaskBoardService.DeleteTask(data, selectedTask, out error))
|
||||||
|
{
|
||||||
|
saveMessage = "Не удалось удалить задачу: " + error;
|
||||||
|
saveMessageType = MessageType.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetDeleteConfirmation();
|
||||||
|
saveMessage = "Задача " + deletedId + " удалена.";
|
||||||
|
saveMessageType = MessageType.Info;
|
||||||
|
selectedTask = null;
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
private bool HasChanges()
|
private bool HasChanges()
|
||||||
{
|
{
|
||||||
if (selectedTask == null)
|
if (selectedTask == null)
|
||||||
@@ -545,19 +794,39 @@ namespace Project.Tasks.Editor
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool taskSummaryChanged = selectedTask.FileExists && !string.Equals(selectedTask.TaskSummary ?? string.Empty, editTaskSummary ?? string.Empty, StringComparison.Ordinal);
|
||||||
|
bool taskBodyChanged = selectedTask.FileExists
|
||||||
|
&& (!string.Equals(selectedTask.Why ?? string.Empty, editWhy ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.ExpectedOutcome ?? string.Empty, editExpectedOutcome ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.CurrentContext ?? string.Empty, editCurrentContext ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.AcceptanceCriteria ?? string.Empty, editAcceptanceCriteria ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.Verification ?? string.Empty, editVerification ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.Risks ?? string.Empty, editRisks ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.HumanDecisions ?? string.Empty, editHumanDecisions ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.DecisionLog ?? string.Empty, editDecisionLog ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(selectedTask.HandoffNotes ?? string.Empty, editHandoffNotes ?? string.Empty, StringComparison.Ordinal));
|
||||||
return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal)
|
return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal)
|
||||||
|| !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal)
|
|| !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal)
|
||||||
|| !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal)
|
|| !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal)
|
||||||
|| !string.Equals(selectedTask.ExecutionTime ?? string.Empty, editExecutionTime ?? string.Empty, StringComparison.Ordinal)
|
|| !string.Equals(selectedTask.ExecutionTime ?? string.Empty, TaskBoardService.NormalizeExecutionTime(editExecutionTime), StringComparison.Ordinal)
|
||||||
|| !string.Equals(selectedTask.Summary ?? string.Empty, editSummary ?? string.Empty, StringComparison.Ordinal);
|
|| !string.Equals(selectedTask.IndexSummary ?? string.Empty, editIndexSummary ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|| taskSummaryChanged
|
||||||
|
|| taskBodyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Reload(string selectedId = null)
|
private void Reload(string selectedId = null)
|
||||||
{
|
{
|
||||||
data = TaskBoardService.Load();
|
data = TaskBoardService.Load();
|
||||||
|
SyncSelections(selectedStatuses, TaskBoardConstants.Statuses);
|
||||||
|
SyncSelections(selectedPriorities, TaskBoardConstants.Priorities);
|
||||||
|
SyncSelections(selectedAreas, BuildAreaOptions());
|
||||||
|
SyncSelections(selectedOwners, BuildOwnerFilterOptions());
|
||||||
|
SyncSelections(selectedFileStates, new[] { "Existing", "Missing" });
|
||||||
|
SyncSelections(selectedWarningStates, new[] { "Warnings", "Clean" });
|
||||||
|
|
||||||
if (data == null || data.Tasks.Count == 0)
|
if (data == null || data.Tasks.Count == 0)
|
||||||
{
|
{
|
||||||
|
ResetDeleteConfirmation();
|
||||||
selectedTask = null;
|
selectedTask = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -575,7 +844,23 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
if (nextSelection == null)
|
if (nextSelection == null)
|
||||||
{
|
{
|
||||||
nextSelection = data.Tasks[0];
|
selectedTask = null;
|
||||||
|
editStatus = string.Empty;
|
||||||
|
editPriority = string.Empty;
|
||||||
|
editOwner = string.Empty;
|
||||||
|
editExecutionTime = string.Empty;
|
||||||
|
editIndexSummary = string.Empty;
|
||||||
|
editTaskSummary = string.Empty;
|
||||||
|
editWhy = string.Empty;
|
||||||
|
editExpectedOutcome = string.Empty;
|
||||||
|
editCurrentContext = string.Empty;
|
||||||
|
editAcceptanceCriteria = string.Empty;
|
||||||
|
editVerification = string.Empty;
|
||||||
|
editRisks = string.Empty;
|
||||||
|
editHumanDecisions = string.Empty;
|
||||||
|
editDecisionLog = string.Empty;
|
||||||
|
editHandoffNotes = string.Empty;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectTask(nextSelection);
|
SelectTask(nextSelection);
|
||||||
@@ -583,7 +868,7 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private List<TaskRecord> GetFilteredTasks()
|
private List<TaskRecord> GetFilteredTasks()
|
||||||
{
|
{
|
||||||
IEnumerable<TaskRecord> query = data.Tasks;
|
IEnumerable<TaskRecord> query = data != null ? data.Tasks : Enumerable.Empty<TaskRecord>();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchText))
|
if (!string.IsNullOrWhiteSpace(searchText))
|
||||||
{
|
{
|
||||||
@@ -591,39 +876,187 @@ namespace Project.Tasks.Editor
|
|||||||
query = query.Where(task =>
|
query = query.Where(task =>
|
||||||
ContainsIgnoreCase(task.Id, search)
|
ContainsIgnoreCase(task.Id, search)
|
||||||
|| ContainsIgnoreCase(task.Title, search)
|
|| ContainsIgnoreCase(task.Title, search)
|
||||||
|| ContainsIgnoreCase(task.Summary, search)
|
|| ContainsIgnoreCase(task.IndexSummary, search)
|
||||||
|| ContainsIgnoreCase(task.Area, search)
|
|| ContainsIgnoreCase(task.Area, search)
|
||||||
|| ContainsIgnoreCase(task.Owner, search));
|
|| ContainsIgnoreCase(task.Owner, search));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(areaFilter, "All", StringComparison.Ordinal))
|
query = query.Where(task => selectedStatuses.Contains(task.Status));
|
||||||
{
|
query = query.Where(task => selectedPriorities.Contains(task.Priority));
|
||||||
query = query.Where(task => string.Equals(task.Area, areaFilter, StringComparison.OrdinalIgnoreCase));
|
query = query.Where(task => selectedAreas.Contains(string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area));
|
||||||
}
|
query = query.Where(task => selectedOwners.Contains(string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner));
|
||||||
|
query = query.Where(task => selectedFileStates.Contains(task.FileExists ? "Existing" : "Missing"));
|
||||||
|
query = query.Where(task => selectedWarningStates.Contains(task.ValidationMessages.Count > 0 ? "Warnings" : "Clean"));
|
||||||
|
|
||||||
if (!string.Equals(priorityFilter, "All", StringComparison.Ordinal))
|
return ApplySort(query).ToList();
|
||||||
{
|
|
||||||
query = query.Where(task => string.Equals(task.Priority, priorityFilter, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string[] BuildAreaOptions()
|
private string[] BuildAreaOptions()
|
||||||
{
|
{
|
||||||
if (data == null || data.Tasks.Count == 0)
|
if (data == null)
|
||||||
{
|
{
|
||||||
return new[] { "All" };
|
return Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> options = new List<string> { "All" };
|
return data.Tasks
|
||||||
options.AddRange(data.Tasks.Select(task => task.Area).Where(area => !string.IsNullOrWhiteSpace(area)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(area => area, StringComparer.OrdinalIgnoreCase));
|
.Select(task => string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area)
|
||||||
return options.ToArray();
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string[] BuildPriorityOptions()
|
private string[] BuildOwnerFilterOptions()
|
||||||
{
|
{
|
||||||
return new[] { "All", "Lowest", "Low", "Medium", "High", "Highest" };
|
return TaskBoardService.BuildOwnerOptions(data, data != null ? data.Tasks : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] BuildOwnerPresetOptions()
|
||||||
|
{
|
||||||
|
var options = new List<string>();
|
||||||
|
if (data != null)
|
||||||
|
{
|
||||||
|
options.AddRange(data.OwnerPresets.Where(owner => !string.IsNullOrWhiteSpace(owner)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(editOwner) && !options.Any(owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase)) && !editOwnerIsCustom)
|
||||||
|
{
|
||||||
|
options.Insert(0, editOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Add(CustomOwnerOption);
|
||||||
|
return options.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] BuildSortOptions()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
"Priority Desc",
|
||||||
|
"Priority Asc",
|
||||||
|
"Execution Time Desc",
|
||||||
|
"Execution Time Asc",
|
||||||
|
"ID Asc",
|
||||||
|
"ID Desc",
|
||||||
|
"Owner Asc",
|
||||||
|
"Title Asc",
|
||||||
|
"Area Asc",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<TaskRecord> ApplySort(IEnumerable<TaskRecord> tasks)
|
||||||
|
{
|
||||||
|
switch (sortOption)
|
||||||
|
{
|
||||||
|
case "Priority Asc":
|
||||||
|
return tasks.OrderByDescending(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Execution Time Desc":
|
||||||
|
return tasks.OrderByDescending(task => task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Execution Time Asc":
|
||||||
|
return tasks.OrderBy(task => task.EstimatedMinutes < 0 ? int.MaxValue : task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "ID Desc":
|
||||||
|
return tasks.OrderByDescending(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Owner Asc":
|
||||||
|
return tasks.OrderBy(task => task.Owner, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Title Asc":
|
||||||
|
return tasks.OrderBy(task => task.Title, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Area Asc":
|
||||||
|
return tasks.OrderBy(task => task.Area, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "ID Asc":
|
||||||
|
return tasks.OrderBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
case "Priority Desc":
|
||||||
|
default:
|
||||||
|
return tasks.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawDetailsSplitter()
|
||||||
|
{
|
||||||
|
Rect splitterRect = GUILayoutUtility.GetRect(SplitterWidth, 1f, GUILayout.Width(SplitterWidth), GUILayout.ExpandHeight(true));
|
||||||
|
EditorGUIUtility.AddCursorRect(splitterRect, MouseCursor.ResizeHorizontal);
|
||||||
|
EditorGUI.DrawRect(splitterRect, new Color(0.2f, 0.2f, 0.2f, 0.75f));
|
||||||
|
|
||||||
|
Event current = Event.current;
|
||||||
|
switch (current.type)
|
||||||
|
{
|
||||||
|
case EventType.MouseDown:
|
||||||
|
if (splitterRect.Contains(current.mousePosition))
|
||||||
|
{
|
||||||
|
isResizingDetails = true;
|
||||||
|
current.Use();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case EventType.MouseDrag:
|
||||||
|
if (isResizingDetails)
|
||||||
|
{
|
||||||
|
detailsWidth = Mathf.Clamp(position.width - current.mousePosition.x, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth));
|
||||||
|
Repaint();
|
||||||
|
current.Use();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case EventType.MouseUp:
|
||||||
|
if (isResizingDetails)
|
||||||
|
{
|
||||||
|
isResizingDetails = false;
|
||||||
|
EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth);
|
||||||
|
current.Use();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenOwnersConfig()
|
||||||
|
{
|
||||||
|
if (data == null || string.IsNullOrWhiteSpace(data.OwnersConfigPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(data.OwnersConfigPath);
|
||||||
|
if (asset != null)
|
||||||
|
{
|
||||||
|
Selection.activeObject = asset;
|
||||||
|
EditorGUIUtility.PingObject(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetOwnerPopupIndex(string[] presetOptions)
|
||||||
|
{
|
||||||
|
if (editOwnerIsCustom)
|
||||||
|
{
|
||||||
|
return Array.IndexOf(presetOptions, CustomOwnerOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
int index = Array.FindIndex(presetOptions, owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return index >= 0 ? index : Array.IndexOf(presetOptions, CustomOwnerOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFormattedTime(TaskRecord task)
|
||||||
|
{
|
||||||
|
return task == null ? "-" : TaskBoardService.FormatExecutionTimeForDisplay(task.ExecutionTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncSelections(HashSet<string> selectedValues, string[] availableOptions)
|
||||||
|
{
|
||||||
|
var availableSet = new HashSet<string>(availableOptions ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
selectedValues.RemoveWhere(value => !availableSet.Contains(value));
|
||||||
|
if (selectedValues.Count == 0)
|
||||||
|
{
|
||||||
|
SetAll(selectedValues, availableOptions, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetAll(HashSet<string> selectedValues, string[] values, bool enabled)
|
||||||
|
{
|
||||||
|
selectedValues.Clear();
|
||||||
|
if (!enabled || values == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string value in values)
|
||||||
|
{
|
||||||
|
selectedValues.Add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ContainsIgnoreCase(string source, string value)
|
private static bool ContainsIgnoreCase(string source, string value)
|
||||||
@@ -631,6 +1064,20 @@ namespace Project.Tasks.Editor
|
|||||||
return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetDeleteConfirmationIfExpired()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(pendingDeleteTaskId) && EditorApplication.timeSinceStartup > pendingDeleteUntil)
|
||||||
|
{
|
||||||
|
ResetDeleteConfirmation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDeleteConfirmation()
|
||||||
|
{
|
||||||
|
pendingDeleteTaskId = null;
|
||||||
|
pendingDeleteUntil = 0d;
|
||||||
|
}
|
||||||
|
|
||||||
private static string SafeValue(string value)
|
private static string SafeValue(string value)
|
||||||
{
|
{
|
||||||
return string.IsNullOrEmpty(value) ? "-" : value;
|
return string.IsNullOrEmpty(value) ? "-" : value;
|
||||||
@@ -638,17 +1085,17 @@ namespace Project.Tasks.Editor
|
|||||||
|
|
||||||
private static Color GetStatusColor(string status)
|
private static Color GetStatusColor(string status)
|
||||||
{
|
{
|
||||||
switch (status)
|
switch (TaskBoardService.NormalizeStatus(status))
|
||||||
{
|
{
|
||||||
case "proposal":
|
case "BackLog":
|
||||||
return new Color(0.94f, 0.94f, 0.94f);
|
return new Color(0.94f, 0.94f, 0.94f);
|
||||||
case "ready":
|
case "ToDo":
|
||||||
return new Color(0.85f, 0.96f, 0.85f);
|
return new Color(0.85f, 0.96f, 0.85f);
|
||||||
case "in_progress":
|
case "InProgress":
|
||||||
return new Color(0.84f, 0.91f, 1f);
|
return new Color(0.84f, 0.91f, 1f);
|
||||||
case "blocked":
|
case "Review":
|
||||||
return new Color(1f, 0.9f, 0.82f);
|
return new Color(1f, 0.9f, 0.82f);
|
||||||
case "done":
|
case "Done":
|
||||||
return new Color(0.88f, 0.88f, 0.88f);
|
return new Color(0.88f, 0.88f, 0.88f);
|
||||||
default:
|
default:
|
||||||
return Color.white;
|
return Color.white;
|
||||||
|
|||||||
+18
-15
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Эта папка хранит один файл на каждую отложенную или асинхронную единицу работы и единый реестр статусов, чтобы контекст реализации не терялся между чатами.
|
Эта папка хранит единый реестр статусов и шаблон, а сами task-файлы лежат в `docs/tasks/items`, чтобы корень `docs/tasks` не превращался в свалку файлов.
|
||||||
|
|
||||||
Файлы задач должны описывать работу достаточно ясно, чтобы будущий человек или AI-агент мог продолжить ее без восстановления исходного замысла по истории переписки.
|
Файлы задач должны описывать работу достаточно ясно, чтобы будущий человек или AI-агент мог продолжить ее без восстановления исходного замысла по истории переписки.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- используйте `docs/tasks/_template.md` для каждой новой задачи
|
- используйте `docs/tasks/_template.md` для каждой новой задачи
|
||||||
- храните одну задачу в одном файле
|
- храните одну задачу в одном файле
|
||||||
- храните все task-файлы плоско в `docs/tasks`, без подпапок по статусам
|
- храните task-файлы в `docs/tasks/items`, без подпапок по статусам
|
||||||
- не переименовывайте и не перемещайте файл задачи при смене статуса
|
- не переименовывайте и не перемещайте файл задачи при смене статуса
|
||||||
- статус задачи считается каноническим по записи в этом индексе
|
- статус задачи считается каноническим по записи в этом индексе
|
||||||
- предпочитайте ссылки на канонические документы вместо копирования больших фоновых разделов
|
- предпочитайте ссылки на канонические документы вместо копирования больших фоновых разделов
|
||||||
@@ -24,22 +24,25 @@
|
|||||||
## Supporting Docs
|
## Supporting Docs
|
||||||
|
|
||||||
- шаблон задачи: `docs/tasks/_template.md`
|
- шаблон задачи: `docs/tasks/_template.md`
|
||||||
|
- task-файлы: `docs/tasks/items/*.md`
|
||||||
|
|
||||||
|
Все отдельные task-файлы храните в `docs/tasks/items/`.
|
||||||
|
|
||||||
## Statuses
|
## Statuses
|
||||||
|
|
||||||
- `proposal` - идея существует, но объем или подход еще не готовы к исполнению
|
- `BackLog` - идея или задача существует, но еще не готова к активному исполнению
|
||||||
- `ready` - задачу можно брать в работу сейчас
|
- `ToDo` - задачу можно брать в работу сейчас
|
||||||
- `in_progress` - по задаче сейчас идет активная работа
|
- `InProgress` - по задаче сейчас идет активная работа
|
||||||
- `blocked` - задача ждет решения, зависимости или внешней предпосылки
|
- `Review` - задача ждет проверки, решения или следующего подтверждающего шага
|
||||||
- `done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл
|
- `Done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл
|
||||||
|
|
||||||
## Task Registry
|
## Task Registry
|
||||||
|
|
||||||
| ID | Status | Priority | Area | Execution Time | File | Summary |
|
| ID | Status | Priority | Area | Owner | Execution Time | File | Summary |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| TASK-0001 | done | Medium | docs | 1d | `docs/tasks/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. |
|
| TASK-0001 | Done | Medium | docs | unassigned | 1d | `docs/tasks/items/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. |
|
||||||
| TASK-0002 | done | Medium | docs | 1d6h | `docs/tasks/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. |
|
| TASK-0002 | Done | Highest | docs | unassigned | 1d6h | `docs/tasks/items/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. |
|
||||||
| TASK-0003 | in_progress | High | ci_cd | 2d | `docs/tasks/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. |
|
| TASK-0003 | InProgress | High | ci_cd | unassigned | 2d | `docs/tasks/items/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. |
|
||||||
| TASK-0004 | ready | Medium | product | 1d | `docs/tasks/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. |
|
| TASK-0004 | BackLog | Medium | product | unassigned | 1d | `docs/tasks/items/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. |
|
||||||
| TASK-0005 | blocked | Medium | product | 2d | `docs/tasks/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. |
|
| TASK-0005 | Review | Medium | product | unassigned | 2d | `docs/tasks/items/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. |
|
||||||
| TASK-0006 | ready | Low | docs | 1d | `docs/tasks/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. |
|
| TASK-0006 | ToDo | Low | docs | unassigned | 1d | `docs/tasks/items/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-XXXX
|
id: TASK-XXXX
|
||||||
title: Короткий заголовок
|
title: Короткий заголовок
|
||||||
|
summary: Короткое описание задачи
|
||||||
priority: Medium
|
priority: Medium
|
||||||
area: docs
|
area: docs
|
||||||
owner: unassigned
|
owner: unassigned
|
||||||
@@ -20,11 +21,11 @@ related_files: []
|
|||||||
|
|
||||||
Допустимые значения статуса:
|
Допустимые значения статуса:
|
||||||
|
|
||||||
- `proposal`
|
- `BackLog`
|
||||||
- `ready`
|
- `ToDo`
|
||||||
- `in_progress`
|
- `InProgress`
|
||||||
- `blocked`
|
- `Review`
|
||||||
- `done`
|
- `Done`
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ related_files: []
|
|||||||
- предпочитайте наименьшее безопасное изменение, которое оставляет после себя более понятную документацию и подтверждение проверки
|
- предпочитайте наименьшее безопасное изменение, которое оставляет после себя более понятную документацию и подтверждение проверки
|
||||||
- указывайте `execution_time` в формате Jira, например `1d6h30m`, и только с шагом в 30 минут
|
- указывайте `execution_time` в формате Jira, например `1d6h30m`, и только с шагом в 30 минут
|
||||||
- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest`
|
- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest`
|
||||||
- не переименовывайте и не перемещайте task-файл при смене статуса; обновляйте запись в `docs/tasks/Index.md`
|
- храните task-файл в `docs/tasks/items/` и не переименовывайте его при смене статуса; обновляйте запись в `docs/tasks/Index.md`
|
||||||
|
|
||||||
## If You Find Drift
|
## If You Find Drift
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user