723 lines
26 KiB
C#
723 lines
26 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
using UnityEngine;
|
||
|
||
namespace Project.Tasks.Editor
|
||
{
|
||
internal static class TaskBoardService
|
||
{
|
||
private static readonly Regex HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled);
|
||
private static readonly Regex H1Regex = new Regex(@"^#\s+(.+)$", RegexOptions.Compiled);
|
||
private static readonly Regex JiraTimeRegex = new Regex(@"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||
|
||
public static string NormalizePriority(string value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
switch (value.Trim())
|
||
{
|
||
case "lowest":
|
||
case "Lowest":
|
||
return "Lowest";
|
||
case "low":
|
||
case "Low":
|
||
return "Low";
|
||
case "medium":
|
||
case "Medium":
|
||
return "Medium";
|
||
case "high":
|
||
case "High":
|
||
return "High";
|
||
case "highest":
|
||
case "Highest":
|
||
return "Highest";
|
||
default:
|
||
return value.Trim();
|
||
}
|
||
}
|
||
|
||
public static int GetPrioritySortOrder(string value)
|
||
{
|
||
switch (NormalizePriority(value))
|
||
{
|
||
case "Highest":
|
||
return 0;
|
||
case "High":
|
||
return 1;
|
||
case "Medium":
|
||
return 2;
|
||
case "Low":
|
||
return 3;
|
||
case "Lowest":
|
||
return 4;
|
||
default:
|
||
return int.MaxValue;
|
||
}
|
||
}
|
||
|
||
public static TaskBoardData Load()
|
||
{
|
||
var data = new TaskBoardData();
|
||
data.ProjectRoot = GetProjectRoot();
|
||
data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks"));
|
||
data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md"));
|
||
|
||
if (!Directory.Exists(data.TasksDirectory))
|
||
{
|
||
data.Warnings.Add("Каталог docs/tasks не найден.");
|
||
return data;
|
||
}
|
||
|
||
if (!File.Exists(data.IndexPath))
|
||
{
|
||
data.Warnings.Add("Файл docs/tasks/Index.md не найден.");
|
||
return data;
|
||
}
|
||
|
||
string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8);
|
||
ParseIndex(data, lines);
|
||
|
||
string[] taskFiles = Directory.GetFiles(data.TasksDirectory, "TASK-*.md", SearchOption.TopDirectoryOnly);
|
||
var indexedIds = new HashSet<string>(data.Tasks.Select(task => task.Id), StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (string taskPath in taskFiles)
|
||
{
|
||
string taskId = Path.GetFileNameWithoutExtension(taskPath);
|
||
int dashIndex = taskId.IndexOf('-');
|
||
if (dashIndex >= 0)
|
||
{
|
||
int secondDash = taskId.IndexOf('-', dashIndex + 1);
|
||
if (secondDash > 0)
|
||
{
|
||
taskId = taskId.Substring(0, secondDash);
|
||
}
|
||
}
|
||
|
||
if (!indexedIds.Contains(taskId))
|
||
{
|
||
data.Warnings.Add("Task-файл без записи в реестре: " + NormalizePath(GetRelativePath(data.ProjectRoot, taskPath)));
|
||
}
|
||
}
|
||
|
||
data.Tasks.Sort(CompareTaskIds);
|
||
return data;
|
||
}
|
||
|
||
public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error)
|
||
{
|
||
error = null;
|
||
|
||
try
|
||
{
|
||
SaveIndexRow(data.IndexPath, updatedTask);
|
||
|
||
if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath))
|
||
{
|
||
SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
error = exception.Message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error)
|
||
{
|
||
error = null;
|
||
|
||
try
|
||
{
|
||
if (data == null)
|
||
{
|
||
throw new InvalidOperationException("Данные task board не загружены.");
|
||
}
|
||
|
||
if (task == null)
|
||
{
|
||
throw new InvalidOperationException("Задача не выбрана.");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(task.RelativeFilePath))
|
||
{
|
||
throw new InvalidOperationException("В Index.md не указан путь к task-файлу.");
|
||
}
|
||
|
||
string relativePath = NormalizePath(task.RelativeFilePath);
|
||
if (!relativePath.StartsWith("docs/tasks/", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new InvalidOperationException("Task-файл можно создавать только внутри docs/tasks.");
|
||
}
|
||
|
||
string absolutePath = NormalizePath(Path.Combine(data.ProjectRoot, relativePath));
|
||
if (File.Exists(absolutePath))
|
||
{
|
||
throw new InvalidOperationException("Task-файл уже существует.");
|
||
}
|
||
|
||
string templatePath = NormalizePath(Path.Combine(data.TasksDirectory, "_template.md"));
|
||
if (!File.Exists(templatePath))
|
||
{
|
||
throw new InvalidOperationException("Шаблон docs/tasks/_template.md не найден.");
|
||
}
|
||
|
||
string template = File.ReadAllText(templatePath, Encoding.UTF8);
|
||
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||
string title = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Title.Trim();
|
||
string owner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim();
|
||
string created = string.IsNullOrWhiteSpace(task.Created) ? today : task.Created.Trim();
|
||
string area = string.IsNullOrWhiteSpace(task.Area) ? "docs" : task.Area.Trim();
|
||
string priority = NormalizePriority(task.Priority);
|
||
if (string.IsNullOrWhiteSpace(priority))
|
||
{
|
||
priority = "Medium";
|
||
}
|
||
|
||
string executionTime = string.IsNullOrWhiteSpace(task.ExecutionTime) ? "1d" : task.ExecutionTime.Trim();
|
||
|
||
string content = template
|
||
.Replace("id: TASK-XXXX", "id: " + task.Id)
|
||
.Replace("title: Короткий заголовок", "title: " + title)
|
||
.Replace("priority: Medium", "priority: " + priority)
|
||
.Replace("area: docs", "area: " + area)
|
||
.Replace("owner: unassigned", "owner: " + owner)
|
||
.Replace("created: YYYY-MM-DD", "created: " + created)
|
||
.Replace("updated: YYYY-MM-DD", "updated: " + today)
|
||
.Replace("execution_time: 1d6h30m", "execution_time: " + executionTime)
|
||
.Replace("# TASK-XXXX - Короткий заголовок", "# " + task.Id + " - " + title);
|
||
|
||
string directory = Path.GetDirectoryName(absolutePath);
|
||
if (!string.IsNullOrEmpty(directory))
|
||
{
|
||
Directory.CreateDirectory(directory);
|
||
}
|
||
|
||
File.WriteAllText(absolutePath, content, new UTF8Encoding(false));
|
||
task.AbsoluteFilePath = absolutePath;
|
||
task.FileExists = true;
|
||
return true;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
error = exception.Message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public static void OpenInDefaultApp(string absolutePath)
|
||
{
|
||
if (string.IsNullOrEmpty(absolutePath) || !File.Exists(absolutePath))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var info = new ProcessStartInfo
|
||
{
|
||
FileName = absolutePath,
|
||
UseShellExecute = true,
|
||
};
|
||
|
||
Process.Start(info);
|
||
}
|
||
|
||
public static bool IsValidExecutionTime(string value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
Match match = JiraTimeRegex.Match(value.Trim());
|
||
if (!match.Success)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
bool hasAnyGroup = false;
|
||
for (int i = 1; i < match.Groups.Count; i++)
|
||
{
|
||
if (match.Groups[i].Success)
|
||
{
|
||
hasAnyGroup = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasAnyGroup)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (match.Groups[4].Success)
|
||
{
|
||
int minutes;
|
||
if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (minutes % 30 != 0)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public static string GetProjectRoot()
|
||
{
|
||
DirectoryInfo parent = Directory.GetParent(Application.dataPath);
|
||
return parent != null ? NormalizePath(parent.FullName) : NormalizePath(Application.dataPath);
|
||
}
|
||
|
||
public static string NormalizePath(string path)
|
||
{
|
||
return string.IsNullOrEmpty(path) ? string.Empty : path.Replace('\\', '/');
|
||
}
|
||
|
||
public static string GetRelativePath(string rootPath, string fullPath)
|
||
{
|
||
Uri rootUri = new Uri(AppendDirectorySeparator(rootPath));
|
||
Uri fullUri = new Uri(fullPath);
|
||
return Uri.UnescapeDataString(rootUri.MakeRelativeUri(fullUri).ToString()).Replace('\\', '/');
|
||
}
|
||
|
||
private static string AppendDirectorySeparator(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
if (path[path.Length - 1] == Path.DirectorySeparatorChar || path[path.Length - 1] == Path.AltDirectorySeparatorChar)
|
||
{
|
||
return path;
|
||
}
|
||
|
||
return path + Path.DirectorySeparatorChar;
|
||
}
|
||
|
||
private static void ParseIndex(TaskBoardData data, string[] lines)
|
||
{
|
||
int registryIndex = Array.FindIndex(lines, line => string.Equals(line.Trim(), "## Task Registry", StringComparison.Ordinal));
|
||
if (registryIndex < 0)
|
||
{
|
||
data.Warnings.Add("В Index.md не найдена секция '## Task Registry'.");
|
||
return;
|
||
}
|
||
|
||
int headerIndex = -1;
|
||
int separatorIndex = -1;
|
||
for (int i = registryIndex + 1; i < lines.Length; i++)
|
||
{
|
||
string trimmed = lines[i].Trim();
|
||
if (trimmed.StartsWith("|", StringComparison.Ordinal))
|
||
{
|
||
if (headerIndex < 0)
|
||
{
|
||
headerIndex = i;
|
||
}
|
||
else
|
||
{
|
||
separatorIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(trimmed))
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (headerIndex < 0 || separatorIndex < 0)
|
||
{
|
||
data.Warnings.Add("В Index.md не удалось прочитать таблицу реестра задач.");
|
||
return;
|
||
}
|
||
|
||
for (int i = separatorIndex + 1; i < lines.Length; i++)
|
||
{
|
||
string line = lines[i];
|
||
string trimmed = line.Trim();
|
||
if (string.IsNullOrWhiteSpace(trimmed))
|
||
{
|
||
break;
|
||
}
|
||
|
||
if (!trimmed.StartsWith("|", StringComparison.Ordinal))
|
||
{
|
||
break;
|
||
}
|
||
|
||
string[] columns = SplitMarkdownRow(trimmed);
|
||
if (columns.Length < 7)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var task = new TaskRecord
|
||
{
|
||
Id = columns[0],
|
||
Status = columns[1],
|
||
Priority = NormalizePriority(columns[2]),
|
||
Area = columns[3],
|
||
ExecutionTime = columns[4],
|
||
RelativeFilePath = StripTicks(columns[5]),
|
||
Summary = columns[6],
|
||
IndexLineNumber = i,
|
||
};
|
||
|
||
if (!string.IsNullOrEmpty(task.RelativeFilePath))
|
||
{
|
||
task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath));
|
||
task.FileExists = File.Exists(task.AbsoluteFilePath);
|
||
}
|
||
|
||
if (!task.FileExists)
|
||
{
|
||
task.ValidationMessages.Add("Task-файл не найден по пути из реестра.");
|
||
}
|
||
|
||
PopulateTaskFromFile(task);
|
||
ValidateTask(task);
|
||
data.Tasks.Add(task);
|
||
}
|
||
}
|
||
|
||
private static void PopulateTaskFromFile(TaskRecord task)
|
||
{
|
||
if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath))
|
||
{
|
||
if (string.IsNullOrEmpty(task.Title))
|
||
{
|
||
task.Title = task.Id;
|
||
}
|
||
return;
|
||
}
|
||
|
||
string[] lines = File.ReadAllLines(task.AbsoluteFilePath, Encoding.UTF8);
|
||
int bodyStart = 0;
|
||
|
||
if (lines.Length > 0 && string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
|
||
{
|
||
for (int i = 1; i < lines.Length; i++)
|
||
{
|
||
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
|
||
{
|
||
bodyStart = i + 1;
|
||
break;
|
||
}
|
||
|
||
string line = lines[i];
|
||
int separator = line.IndexOf(':');
|
||
if (separator <= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string key = line.Substring(0, separator).Trim();
|
||
string value = line.Substring(separator + 1).Trim();
|
||
ApplyFrontMatterField(task, key, value);
|
||
}
|
||
}
|
||
|
||
var sections = new Dictionary<string, StringBuilder>(StringComparer.OrdinalIgnoreCase);
|
||
string currentSection = null;
|
||
|
||
for (int i = bodyStart; i < lines.Length; i++)
|
||
{
|
||
string line = lines[i];
|
||
Match h1Match = H1Regex.Match(line);
|
||
if (h1Match.Success && string.IsNullOrEmpty(task.Header))
|
||
{
|
||
task.Header = h1Match.Groups[1].Value.Trim();
|
||
if (string.IsNullOrEmpty(task.Title))
|
||
{
|
||
task.Title = ExtractTitleFromHeader(task.Header, task.Id);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
Match headingMatch = HeadingRegex.Match(line);
|
||
if (headingMatch.Success)
|
||
{
|
||
currentSection = headingMatch.Groups[1].Value.Trim();
|
||
if (!sections.ContainsKey(currentSection))
|
||
{
|
||
sections[currentSection] = new StringBuilder();
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(currentSection))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (sections[currentSection].Length > 0)
|
||
{
|
||
sections[currentSection].AppendLine();
|
||
}
|
||
|
||
sections[currentSection].Append(line);
|
||
}
|
||
|
||
task.Why = GetSection(sections, "Why");
|
||
task.ExpectedOutcome = GetSection(sections, "Expected Outcome");
|
||
task.CurrentContext = GetSection(sections, "Current Context");
|
||
task.AcceptanceCriteria = GetSection(sections, "Acceptance Criteria");
|
||
task.Verification = GetSection(sections, "Verification");
|
||
task.Risks = GetSection(sections, "Risks / Open Questions");
|
||
task.HumanDecisions = GetSection(sections, "Human Decisions Needed");
|
||
task.DecisionLog = GetSection(sections, "Decision Log");
|
||
task.HandoffNotes = GetSection(sections, "Handoff Notes");
|
||
|
||
if (string.IsNullOrEmpty(task.Title))
|
||
{
|
||
task.Title = task.Id;
|
||
}
|
||
}
|
||
|
||
private static void ApplyFrontMatterField(TaskRecord task, string key, string value)
|
||
{
|
||
switch (key)
|
||
{
|
||
case "id":
|
||
if (!string.Equals(task.Id, value, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
task.ValidationMessages.Add("ID в task-файле не совпадает с записью в Index.md.");
|
||
}
|
||
break;
|
||
case "title":
|
||
task.Title = value;
|
||
break;
|
||
case "priority":
|
||
string normalizedPriority = NormalizePriority(value);
|
||
if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
task.ValidationMessages.Add("Priority в task-файле не совпадает с реестром.");
|
||
}
|
||
break;
|
||
case "area":
|
||
if (!string.IsNullOrEmpty(value) && !string.Equals(task.Area, value, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
task.ValidationMessages.Add("Area в task-файле не совпадает с реестром.");
|
||
}
|
||
break;
|
||
case "owner":
|
||
task.Owner = value;
|
||
break;
|
||
case "created":
|
||
task.Created = value;
|
||
break;
|
||
case "updated":
|
||
task.Updated = value;
|
||
break;
|
||
case "execution_time":
|
||
if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром.");
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
private static void ValidateTask(TaskRecord task)
|
||
{
|
||
if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0)
|
||
{
|
||
task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status);
|
||
}
|
||
|
||
task.Priority = NormalizePriority(task.Priority);
|
||
if (Array.IndexOf(TaskBoardConstants.Priorities, task.Priority) < 0)
|
||
{
|
||
task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority);
|
||
}
|
||
|
||
if (!IsValidExecutionTime(task.ExecutionTime))
|
||
{
|
||
task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам.");
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(task.RelativeFilePath))
|
||
{
|
||
task.ValidationMessages.Add("В реестре не указан путь к task-файлу.");
|
||
}
|
||
}
|
||
|
||
private static void SaveIndexRow(string indexPath, TaskRecord task)
|
||
{
|
||
string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8);
|
||
if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length)
|
||
{
|
||
throw new InvalidOperationException("Не удалось найти строку задачи в Index.md.");
|
||
}
|
||
|
||
lines[task.IndexLineNumber] = FormatIndexRow(task);
|
||
File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||
}
|
||
|
||
private static void SaveTaskFrontMatter(string taskPath, TaskRecord task)
|
||
{
|
||
var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList();
|
||
string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||
|
||
if (lines.Count == 0 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
|
||
{
|
||
var newLines = new List<string>
|
||
{
|
||
"---",
|
||
"id: " + task.Id,
|
||
"title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title),
|
||
"priority: " + task.Priority,
|
||
"area: " + task.Area,
|
||
"owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner),
|
||
"created: " + (string.IsNullOrEmpty(task.Created) ? today : task.Created),
|
||
"updated: " + today,
|
||
"execution_time: " + task.ExecutionTime,
|
||
"depends_on: []",
|
||
"canonical_docs: []",
|
||
"related_files: []",
|
||
"---",
|
||
string.Empty,
|
||
};
|
||
newLines.AddRange(lines);
|
||
lines = newLines;
|
||
}
|
||
|
||
int frontMatterEnd = -1;
|
||
for (int i = 1; i < lines.Count; i++)
|
||
{
|
||
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
|
||
{
|
||
frontMatterEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (frontMatterEnd < 0)
|
||
{
|
||
throw new InvalidOperationException("Не удалось прочитать front matter task-файла.");
|
||
}
|
||
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "priority", task.Priority);
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area);
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner);
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "created", string.IsNullOrEmpty(task.Created) ? today : task.Created);
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today);
|
||
UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime);
|
||
|
||
File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false));
|
||
}
|
||
|
||
private static void UpsertFrontMatterField(List<string> lines, ref int frontMatterEnd, string key, string value)
|
||
{
|
||
string prefix = key + ":";
|
||
for (int i = 1; i < frontMatterEnd; i++)
|
||
{
|
||
if (lines[i].StartsWith(prefix, StringComparison.Ordinal))
|
||
{
|
||
lines[i] = key + ": " + value;
|
||
return;
|
||
}
|
||
}
|
||
|
||
lines.Insert(frontMatterEnd, key + ": " + value);
|
||
frontMatterEnd++;
|
||
}
|
||
|
||
private static string FormatIndexRow(TaskRecord task)
|
||
{
|
||
return string.Format(
|
||
CultureInfo.InvariantCulture,
|
||
"| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |",
|
||
task.Id,
|
||
task.Status,
|
||
task.Priority,
|
||
task.Area,
|
||
task.ExecutionTime,
|
||
NormalizePath(task.RelativeFilePath),
|
||
(task.Summary ?? string.Empty).Replace("|", "/"));
|
||
}
|
||
|
||
private static string[] SplitMarkdownRow(string line)
|
||
{
|
||
string trimmed = line.Trim().Trim('|');
|
||
return trimmed.Split('|').Select(part => part.Trim()).ToArray();
|
||
}
|
||
|
||
private static string StripTicks(string value)
|
||
{
|
||
return string.IsNullOrEmpty(value) ? string.Empty : value.Trim().Trim('`');
|
||
}
|
||
|
||
private static string ExtractTitleFromHeader(string header, string id)
|
||
{
|
||
if (string.IsNullOrEmpty(header))
|
||
{
|
||
return id;
|
||
}
|
||
|
||
string prefix = id + " - ";
|
||
if (header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return header.Substring(prefix.Length).Trim();
|
||
}
|
||
|
||
return header.Trim();
|
||
}
|
||
|
||
private static string GetSection(Dictionary<string, StringBuilder> sections, string key)
|
||
{
|
||
StringBuilder value;
|
||
if (!sections.TryGetValue(key, out value))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
return value.ToString().Trim();
|
||
}
|
||
|
||
private static int CompareTaskIds(TaskRecord left, TaskRecord right)
|
||
{
|
||
return CompareTaskIds(left != null ? left.Id : null, right != null ? right.Id : null);
|
||
}
|
||
|
||
private static int CompareTaskIds(string left, string right)
|
||
{
|
||
return ExtractTaskNumber(left).CompareTo(ExtractTaskNumber(right));
|
||
}
|
||
|
||
private static int ExtractTaskNumber(string id)
|
||
{
|
||
if (string.IsNullOrEmpty(id))
|
||
{
|
||
return int.MaxValue;
|
||
}
|
||
|
||
string[] parts = id.Split('-');
|
||
if (parts.Length < 2)
|
||
{
|
||
return int.MaxValue;
|
||
}
|
||
|
||
int number;
|
||
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue;
|
||
}
|
||
}
|
||
}
|