From fa9ae8ed6f6bc8416a5a41fd3170364e1588a1eb Mon Sep 17 00:00:00 2001 From: Dedicraf Date: Sat, 19 Sep 2026 14:17:35 +0300 Subject: [PATCH] Initial import of ExcelMatcher --- .gitignore | 3 + App.cs | 22 + Assets/app.ico | Bin 0 -> 370070 bytes ExcelMatcher.csproj | 32 ++ ExcelService.cs | 970 +++++++++++++++++++++++++++++++ MainWindow.cs | 1324 +++++++++++++++++++++++++++++++++++++++++++ Program.cs | 15 + 7 files changed, 2366 insertions(+) create mode 100644 .gitignore create mode 100644 App.cs create mode 100644 Assets/app.ico create mode 100644 ExcelMatcher.csproj create mode 100644 ExcelService.cs create mode 100644 MainWindow.cs create mode 100644 Program.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7538de6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +publish/ diff --git a/App.cs b/App.cs new file mode 100644 index 0000000..7276ab9 --- /dev/null +++ b/App.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Themes.Fluent; + +namespace ExcelMatcher; + +public class App : Application +{ + public override void Initialize() + { + Styles.Add(new FluentTheme()); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Assets/app.ico b/Assets/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..1d3d1a7d7047f8c61310a2a1c5f507f721bfd5bb GIT binary patch literal 370070 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 literal 0 HcmV?d00001 diff --git a/ExcelMatcher.csproj b/ExcelMatcher.csproj new file mode 100644 index 0000000..f16588e --- /dev/null +++ b/ExcelMatcher.csproj @@ -0,0 +1,32 @@ + + + + WinExe + Exe + net8.0 + enable + enable + true + Сопоставлятор3000 + Сопоставлятор 3000 + Сопоставлятор 3000 + Dedicraf + © 2026 Dedicraf · Специально для Юрия Киселёва + Assets/app.ico + + + + + + + + + + + + + + + + + diff --git a/ExcelService.cs b/ExcelService.cs new file mode 100644 index 0000000..6fb4152 --- /dev/null +++ b/ExcelService.cs @@ -0,0 +1,970 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Threading; +using ClosedXML.Excel; +using NPOI.HSSF.UserModel; +using NPOI.SS.UserModel; +using NPOI.SS.Util; + +namespace ExcelMatcher; + +public sealed class TableData +{ + public string FilePath { get; init; } = ""; + public string SheetName { get; init; } = ""; + public List Columns { get; init; } = new(); + public List Rows { get; init; } = new(); + public int RowCount => Rows.Count; +} + +public static class ExcelReader +{ + public static List LoadWorkbook(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext is ".xls" or ".xlt" ? LoadXls(path) : LoadXlsx(path); + } + + private static List LoadXlsx(string path) + { + var result = new List(); + using var wb = new XLWorkbook(path); + foreach (var ws in wb.Worksheets) + { + result.Add(LoadSheet(path, ws)); + } + return result; + } + + private static List LoadXls(string path) + { + var result = new List(); + using var fs = File.OpenRead(path); + var wb = new HSSFWorkbook(fs); + var fmt = new DataFormatter(); + for (int i = 0; i < wb.NumberOfSheets; i++) + { + var ws = wb.GetSheetAt(i); + if (ws is null) continue; + result.Add(LoadXlsSheet(path, ws, fmt)); + } + return result; + } + + private static string CellText(ISheet ws, int r, int c, DataFormatter fmt) + { + var cell = ws.GetRow(r)?.GetCell(c); + return cell is null ? "" : (fmt.FormatCellValue(cell) ?? ""); + } + + private static TableData LoadXlsSheet(string path, ISheet ws, DataFormatter fmt) + { + int firstRow = -1, lastRow = -1, firstCol = -1, lastCol = -1; + + for (int r = 0; r <= ws.LastRowNum; r++) + { + var row = ws.GetRow(r); + if (row is null) continue; + int minCol = -1, maxCol = -1; + foreach (var cell in row.Cells) + { + if (string.IsNullOrWhiteSpace(fmt.FormatCellValue(cell))) continue; + int c = cell.ColumnIndex; + if (minCol == -1 || c < minCol) minCol = c; + if (maxCol == -1 || c > maxCol) maxCol = c; + } + if (minCol == -1) continue; + if (firstRow == -1) firstRow = r; + lastRow = r; + if (firstCol == -1 || minCol < firstCol) firstCol = minCol; + if (lastCol == -1 || maxCol > lastCol) lastCol = maxCol; + } + + if (firstRow == -1) + return new TableData { FilePath = path, SheetName = ws.SheetName }; + + int colCount = lastCol - firstCol + 1; + var cols = new List(); + for (int c = 0; c < colCount; c++) + { + var header = CellText(ws, firstRow, firstCol + c, fmt); + cols.Add(string.IsNullOrEmpty(header) ? $"Колонка {c + 1}" : header); + } + + var rows = new List(); + for (int r = firstRow + 1; r <= lastRow; r++) + { + var row = new string[colCount]; + bool any = false; + for (int c = 0; c < colCount; c++) + { + var cell = CellText(ws, r, firstCol + c, fmt); + row[c] = cell; + if (!string.IsNullOrWhiteSpace(cell)) any = true; + } + if (any) rows.Add(row); + } + + return new TableData + { + FilePath = path, + SheetName = ws.SheetName, + Columns = cols, + Rows = rows + }; + } + + private static TableData LoadSheet(string path, IXLWorksheet ws) + { + var cols = new List(); + var rows = new List(); + + var firstUsed = ws.FirstCellUsed(); + var lastUsed = ws.LastCellUsed(); + if (firstUsed == null || lastUsed == null) + return new TableData { FilePath = path, SheetName = ws.Name }; + + int firstRow = firstUsed.Address.RowNumber; + int lastRow = lastUsed.Address.RowNumber; + int firstCol = firstUsed.Address.ColumnNumber; + int lastCol = lastUsed.Address.ColumnNumber; + int colCount = lastCol - firstCol + 1; + + for (int c = 0; c < colCount; c++) + { + var header = ws.Cell(firstRow, firstCol + c).GetString().Trim(); + cols.Add(string.IsNullOrEmpty(header) ? $"Колонка {c + 1}" : header); + } + + for (int r = firstRow + 1; r <= lastRow; r++) + { + var row = new string[colCount]; + bool any = false; + for (int c = 0; c < colCount; c++) + { + var cell = ws.Cell(r, firstCol + c).GetString(); + row[c] = cell; + if (!string.IsNullOrWhiteSpace(cell)) any = true; + } + if (any) rows.Add(row); + } + + return new TableData + { + FilePath = path, + SheetName = ws.Name, + Columns = cols, + Rows = rows + }; + } +} + +public enum MatchMode +{ + KeepMatches, + KeepOnlyMain, + KeepOnlySecondary, + KeepBoth +} + +public enum MatchKind +{ + Exact, + Partial, + Fuzzy +} + +public sealed class MatchOptions +{ + public bool[] MainUsed = Array.Empty(); + public bool[] SecondaryUsed = Array.Empty(); + public bool IgnoreCaseAndSpaces = true; + public bool ExtendedNormalize = false; + public MatchKind Kind = MatchKind.Exact; + public int FuzzyThreshold = 85; + public bool UseOr = false; + public IReadOnlyDictionary? Synonyms; + public CancellationToken Token = default; + + public MatchOptions Clone() => (MatchOptions)MemberwiseClone(); +} + +public sealed class MatchPair +{ + public string[] RowA { get; init; } = Array.Empty(); + public string[] RowB { get; init; } = Array.Empty(); + public string Type { get; init; } = ""; + public double Score { get; init; } + public int BCount { get; init; } +} + +public sealed class MatchResult +{ + public List Matches { get; init; } = new(); + public List OnlyMain { get; init; } = new(); + public List OnlySecondary { get; init; } = new(); + public List Pairs { get; init; } = new(); +} + +public sealed class MergeOutput +{ + public List Columns { get; init; } = new(); + public List Rows { get; init; } = new(); +} + +public static class TableMatcher +{ + private static List KeyIndexes(bool[] used) + { + return used.Select((u, i) => (u, i)).Where(x => x.u).Select(x => x.i).ToList(); + } + + public static double Similarity(string a, string b) + { + if (a == b) return 1; + if (a.Length == 0) return 0; + if (b.Length == 0) return 0; + int dist = Levenshtein(a, b); + int maxLen = Math.Max(a.Length, b.Length); + return 1.0 - (double)dist / maxLen; + } + + private static int Levenshtein(string a, string b) + { + var prev = new int[b.Length + 1]; + var curr = new int[b.Length + 1]; + for (int j = 0; j <= b.Length; j++) prev[j] = j; + for (int i = 1; i <= a.Length; i++) + { + curr[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); + } + (prev, curr) = (curr, prev); + } + return prev[b.Length]; + } + + private static string NormalizeBase(string s, IReadOnlyDictionary? synonyms) + { + var t = s; + if (synonyms is not null) + { + foreach (var kv in synonyms) + t = t.Replace(kv.Key, kv.Value, StringComparison.OrdinalIgnoreCase); + } + return t.Replace(" ", "").Replace("\t", "").ToLowerInvariant().Replace('ё', 'e'); + } + + private static string NormalizeCore(string s) + { + var sb = new StringBuilder(s.Length); + foreach (char ch in s.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) sb.Append(ch); + } + var clean = sb.ToString().Replace('ё', 'е'); + foreach (var p in new[] { "ооо", "зао", "оао", "тоо", "пао" }) + { + if (clean.StartsWith(p, StringComparison.Ordinal) && clean.Length > p.Length + 1) + { + clean = clean.Substring(p.Length); + break; + } + } + return clean; + } + + private static string NormValue(string s, bool ignore, MatchOptions o) + { + if (!ignore) return s; + return o.ExtendedNormalize ? NormalizeCore(s) : NormalizeBase(s, o.Synonyms); + } + + private static string[] RowParts(string[] row, List keyIndexes, int take, MatchOptions o) + { + var parts = new string[take]; + for (int i = 0; i < take; i++) + parts[i] = keyIndexes[i] < row.Length ? NormValue(row[keyIndexes[i]], o.IgnoreCaseAndSpaces, o) : ""; + return parts; + } + + private static bool IsEmptyParts(string[] parts) => parts.All(string.IsNullOrEmpty); + + private static string JoinKey(string[] parts) => string.Join("\u00a6", parts); + + private static bool PartsContain(string[] a, string[] b) + { + for (int i = 0; i < a.Length; i++) + { + var x = a[i]; + var y = b[i]; + if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y)) return false; + if (x.Contains(y, StringComparison.Ordinal) || y.Contains(x, StringComparison.Ordinal)) continue; + return false; + } + return true; + } + + private static bool AnyColContains(string[] a, string[] b) + { + for (int i = 0; i < a.Length; i++) + { + var x = a[i]; + var y = b[i]; + if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y)) continue; + if (x.Contains(y, StringComparison.Ordinal) || y.Contains(x, StringComparison.Ordinal)) return true; + } + return false; + } + + private static double ColumnSim(string[] a, string[] b, bool orMode) + { + double sum = 0; + int n = 0; + double best = 0; + for (int i = 0; i < a.Length; i++) + { + if (a[i].Length == 0 && b[i].Length == 0 && !orMode) + { + sum += 1; + n++; + continue; + } + if (a[i].Length == 0 || b[i].Length == 0) + { + if (orMode) continue; + sum += 0; + n++; + continue; + } + var s = Similarity(a[i], b[i]); + if (orMode) + { + if (s > best) best = s; + } + else + { + sum += s; + n++; + } + } + if (orMode) return best; + return n == 0 ? 0 : sum / n; + } + + private sealed class Candidate + { + public string[]? BRow; + public string Type = ""; + public double Score; + public int BCount; + } + + private static List MatchMain(TableData main, TableData secondary, MatchOptions o, out List onlySecondary) + { + var mainIdx = KeyIndexes(o.MainUsed); + var secIdx = KeyIndexes(o.SecondaryUsed); + int keyCount = Math.Min(mainIdx.Count, secIdx.Count); + onlySecondary = new List(); + + if (keyCount == 0) + return main.Rows.Select(_ => (Candidate?)null).Cast().ToList(); + + var token = o.Token; + bool or = o.UseOr; + var candidates = new List(main.RowCount); + + if (o.Kind == MatchKind.Exact) + { + if (!or) + { + var mainKeys = new HashSet(StringComparer.Ordinal); + foreach (var row in main.Rows) + { + var parts = RowParts(row, mainIdx, keyCount, o); + if (!IsEmptyParts(parts)) mainKeys.Add(JoinKey(parts)); + } + + var bByKey = new Dictionary(StringComparer.Ordinal); + foreach (var row in secondary.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, secIdx, keyCount, o); + if (IsEmptyParts(parts)) continue; + var k = JoinKey(parts); + if (bByKey.TryGetValue(k, out var cur)) + bByKey[k] = (cur.Row, cur.Count + 1); + else + bByKey[k] = (row, 1); + } + + foreach (var row in main.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, mainIdx, keyCount, o); + if (IsEmptyParts(parts)) + { + candidates.Add(new Candidate()); + continue; + } + if (bByKey.TryGetValue(JoinKey(parts), out var hit)) + { + candidates.Add(new Candidate { BRow = hit.Row, Type = "точное", Score = 100, BCount = hit.Count }); + } + else + { + candidates.Add(new Candidate()); + } + } + + foreach (var row in secondary.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, secIdx, keyCount, o); + if (!IsEmptyParts(parts) && !mainKeys.Contains(JoinKey(parts))) + onlySecondary.Add(row); + } + } + else + { + var mainSets = new HashSet[keyCount]; + for (int j = 0; j < keyCount; j++) mainSets[j] = new HashSet(StringComparer.Ordinal); + foreach (var row in main.Rows) + { + var parts = RowParts(row, mainIdx, keyCount, o); + if (IsEmptyParts(parts)) continue; + for (int j = 0; j < keyCount; j++) + if (parts[j].Length > 0) mainSets[j].Add(parts[j]); + } + + var bByVal = new Dictionary[keyCount]; + for (int j = 0; j < keyCount; j++) bByVal[j] = new Dictionary(StringComparer.Ordinal); + foreach (var row in secondary.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, secIdx, keyCount, o); + if (IsEmptyParts(parts)) continue; + for (int j = 0; j < keyCount; j++) + { + if (parts[j].Length == 0) continue; + var v = parts[j]; + if (bByVal[j].TryGetValue(v, out var cur)) + bByVal[j][v] = (cur.Row, cur.Count + 1); + else + bByVal[j][v] = (row, 1); + } + } + + foreach (var row in main.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, mainIdx, keyCount, o); + if (IsEmptyParts(parts)) + { + candidates.Add(new Candidate()); + continue; + } + string[]? bestRow = null; + int bestCount = 0; + for (int j = 0; j < keyCount; j++) + { + if (parts[j].Length == 0) continue; + if (bByVal[j].TryGetValue(parts[j], out var hit) && hit.Count > bestCount) + { + bestRow = hit.Row; + bestCount = hit.Count; + } + } + if (bestRow is not null) + candidates.Add(new Candidate { BRow = bestRow, Type = "точное", Score = 100, BCount = bestCount }); + else + candidates.Add(new Candidate()); + } + + foreach (var row in secondary.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, secIdx, keyCount, o); + if (IsEmptyParts(parts)) continue; + bool matched = false; + for (int j = 0; j < keyCount; j++) + { + if (parts[j].Length > 0 && mainSets[j].Contains(parts[j])) + { + matched = true; + break; + } + } + if (!matched) onlySecondary.Add(row); + } + } + return candidates; + } + + // Частичное / Нечёткое: строим списки частей и сканируем строку за строкой. + var secRows = secondary.Rows + .Select(row => (Row: row, Parts: RowParts(row, secIdx, keyCount, o))) + .ToList(); + + var hits = new bool[secRows.Count]; + + foreach (var row in main.Rows) + { + token.ThrowIfCancellationRequested(); + var parts = RowParts(row, mainIdx, keyCount, o); + if (IsEmptyParts(parts)) + { + candidates.Add(new Candidate()); + continue; + } + + string[]? bestRow = null; + double bestScore = 0; + int hitCount = 0; + + if (o.Kind == MatchKind.Partial) + { + for (int i = 0; i < secRows.Count; i++) + { + var b = secRows[i]; + bool ok = or ? AnyColContains(parts, b.Parts) : PartsContain(parts, b.Parts); + if (!ok) continue; + hits[i] = true; + hitCount++; + if (bestRow is null) bestRow = b.Row; + } + if (bestRow is not null) + candidates.Add(new Candidate { BRow = bestRow, BCount = hitCount, Score = 100, Type = "частичное" }); + else + candidates.Add(new Candidate()); + } + else + { + double threshold = o.FuzzyThreshold / 100.0; + for (int i = 0; i < secRows.Count; i++) + { + var b = secRows[i]; + double sim = ColumnSim(parts, b.Parts, or); + if (sim >= threshold) + { + hits[i] = true; + hitCount++; + if (sim > bestScore) + { + bestScore = sim; + bestRow = b.Row; + } + } + } + if (bestRow is not null) + candidates.Add(new Candidate + { + BRow = bestRow, + BCount = hitCount, + Score = Math.Round(bestScore * 100, 1), + Type = "нечёткое" + }); + else + candidates.Add(new Candidate()); + } + } + + for (int i = 0; i < secRows.Count; i++) + { + token.ThrowIfCancellationRequested(); + if (!hits[i]) onlySecondary.Add(secRows[i].Row); + } + + return candidates; + } + + public static MatchResult RunAll(TableData main, TableData secondary, MatchOptions o) + { + var candidates = MatchMain(main, secondary, o, out var onlySecondary); + var matches = new List(); + var pairs = new List(); + var onlyMain = new List(); + + int n = Math.Min(candidates.Count, main.RowCount); + for (int i = 0; i < n; i++) + { + o.Token.ThrowIfCancellationRequested(); + var c = candidates[i]; + if (c.BRow is null) + { + onlyMain.Add(main.Rows[i]); + continue; + } + matches.Add(main.Rows[i]); + pairs.Add(new MatchPair + { + RowA = main.Rows[i], + RowB = c.BRow, + Type = c.Type, + Score = c.Score, + BCount = c.BCount + }); + } + + return new MatchResult + { + Matches = matches, + Pairs = pairs, + OnlyMain = onlyMain, + OnlySecondary = onlySecondary + }; + } + + public static MatchResult RunAll(TableData main, TableData secondary, bool[] mainUsed, bool[] secondaryUsed, bool ignore) + => RunAll(main, secondary, new MatchOptions { MainUsed = mainUsed, SecondaryUsed = secondaryUsed, IgnoreCaseAndSpaces = ignore }); + + public static MatchResult RunAll(TableData main, TableData secondary, bool[] mainUsed, bool[] secondaryUsed, bool ignore, bool partial) + => RunAll(main, secondary, new MatchOptions + { + MainUsed = mainUsed, + SecondaryUsed = secondaryUsed, + IgnoreCaseAndSpaces = ignore, + Kind = partial ? MatchKind.Partial : MatchKind.Exact + }); + + public static MergeOutput Merge(TableData main, TableData secondary, MatchOptions o, bool removeIdenticalColumns = true) + { + var candidates = MatchMain(main, secondary, o, out _); + + var columns = new List(main.Columns); + var extraCols = new List(); + + if (removeIdenticalColumns) + { + for (int j = 0; j < secondary.Columns.Count; j++) + { + if (columns.Contains(secondary.Columns[j])) + continue; + + var target = SortedColumnValues(secondary.Rows, j); + bool duplicate = false; + for (int c = 0; c < main.Columns.Count && !duplicate; c++) + duplicate = ColumnValuesEqual(target, SortedColumnValues(main.Rows, c)); + for (int e = 0; e < extraCols.Count && !duplicate; e++) + duplicate = ColumnValuesEqual(target, SortedColumnValues(secondary.Rows, extraCols[e])); + if (duplicate) continue; + + columns.Add(secondary.Columns[j]); + extraCols.Add(j); + } + } + else + { + for (int j = 0; j < secondary.Columns.Count; j++) + { + columns.Add(secondary.Columns[j]); + extraCols.Add(j); + } + } + + string[] BuildMerged(string[] row, string[] secRow) + { + var merged = new string[columns.Count]; + for (int c = 0; c < main.Columns.Count; c++) + merged[c] = c < row.Length ? row[c] : ""; + for (int e = 0; e < extraCols.Count; e++) + { + int j = extraCols[e]; + merged[main.Columns.Count + e] = j < secRow.Length ? secRow[j] : ""; + } + return merged; + } + + var rows = new List(); + int n = Math.Min(candidates.Count, main.RowCount); + for (int i = 0; i < n; i++) + { + o.Token.ThrowIfCancellationRequested(); + var c = candidates[i]; + if (c.BRow is not null) + rows.Add(BuildMerged(main.Rows[i], c.BRow)); + } + + return new MergeOutput { Columns = columns, Rows = rows }; + } + + public static MergeOutput Merge( + TableData main, + TableData secondary, + bool[] mainUsed, + bool[] secondaryUsed, + bool ignoreCaseAndSpaces, + bool partial = false, + bool removeIdenticalColumns = true) + => Merge(main, secondary, new MatchOptions + { + MainUsed = mainUsed, + SecondaryUsed = secondaryUsed, + IgnoreCaseAndSpaces = ignoreCaseAndSpaces, + Kind = partial ? MatchKind.Partial : MatchKind.Exact + }, removeIdenticalColumns); + + private static List SortedColumnValues(List rows, int colIndex) + { + var vals = new List(); + foreach (var r in rows) + vals.Add(colIndex < r.Length ? (r[colIndex] ?? "").Trim() : ""); + vals.Sort(StringComparer.Ordinal); + return vals; + } + + private static bool ColumnValuesEqual(List a, List b) + { + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; + return true; + } + + public static (TableData Table, int Removed) Dedupe(TableData table, bool[] used, bool ignore, MatchOptions? template = null) + { + var idx = KeyIndexes(used); + if (idx.Count == 0) + return (table, 0); + + var o = template?.Clone() ?? new MatchOptions(); + o.MainUsed = used; + o.SecondaryUsed = used; + o.IgnoreCaseAndSpaces = ignore; + + var seen = new HashSet(StringComparer.Ordinal); + var rows = new List(); + int removed = 0; + foreach (var row in table.Rows) + { + o.Token.ThrowIfCancellationRequested(); + var parts = RowParts(row, idx, idx.Count, o); + if (!IsEmptyParts(parts) && !seen.Add(JoinKey(parts))) + { + removed++; + continue; + } + rows.Add(row); + } + + return (new TableData + { + FilePath = table.FilePath, + SheetName = table.SheetName, + Columns = table.Columns, + Rows = rows + }, removed); + } + + public static (int EmptyKeys, int Total) KeyCoverage(TableData table, bool[] used, MatchOptions? o = null) + { + var idx = KeyIndexes(used); + int empty = 0; + foreach (var row in table.Rows) + { + bool allEmpty; + if (idx.Count == 0) + { + allEmpty = row.All(string.IsNullOrEmpty); + } + else + { + allEmpty = true; + foreach (var j in idx) + { + if (j >= row.Length || string.IsNullOrWhiteSpace(row[j])) + continue; + allEmpty = false; + break; + } + } + if (allEmpty) empty++; + } + return (empty, table.RowCount); + } +} + +public static class ExcelExporter +{ + public static void ExportAll(string path, TableData main, TableData secondary, MatchResult result, MergeOutput? merged = null) + { + using var wb = new XLWorkbook(); + + var wsMerged = wb.Worksheets.Add("Итог (совмещено)"); + var wsPairs = wb.Worksheets.Add("Пары A↔B"); + var wsMatches = wb.Worksheets.Add("Совпадения"); + var wsOnlyMain = wb.Worksheets.Add("Только в A"); + var wsOnlySecondary = wb.Worksheets.Add("Только в B"); + var wsAnalytics = wb.Worksheets.Add("Аналитика"); + + if (merged is null || merged.Rows.Count == 0) + merged = new MergeOutput { Columns = main.Columns, Rows = result.Matches }; + + WriteSheet(wsMerged, merged.Columns, merged.Rows); + + WritePairs(wsPairs, main, secondary, result); + + WriteMatches(wsMatches, main, result); + + WriteSheet(wsOnlyMain, main.Columns, result.OnlyMain); + WriteSheet(wsOnlySecondary, secondary.Columns, result.OnlySecondary); + + WriteAnalytics(wsAnalytics, main, secondary, result); + + wb.SaveAs(path); + } + + private static void WriteMatches(IXLWorksheet ws, TableData main, MatchResult result) + { + var headers = new List(main.Columns); + bool withMeta = result.Pairs.Count > 0 || main.Columns.Count == 0; + if (withMeta) + { + headers.Add("Тип"); + headers.Add("Сходство %"); + headers.Add("B-совпадений"); + } + + for (int c = 0; c < headers.Count; c++) + { + ws.Cell(1, c + 1).Value = headers[c]; + ws.Cell(1, c + 1).Style.Font.Bold = true; + ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF"); + } + + int rows = result.Matches.Count; + for (int i = 0; i < rows; i++) + { + var row = result.Matches[i]; + int baseCols = main.Columns.Count; + for (int c = 0; c < baseCols; c++) + { + SetCell(ws, i + 2, c + 1, c < row.Length ? row[c] : ""); + } + if (withMeta) + { + var type = result.Pairs.Count > i ? result.Pairs[i].Type : ""; + var score = result.Pairs.Count > i ? result.Pairs[i].Score : 0; + var count = result.Pairs.Count > i ? result.Pairs[i].BCount : 0; + SetCell(ws, i + 2, baseCols + 1, type); + SetCell(ws, i + 2, baseCols + 2, type == "нечёткое" ? score.ToString("0.#", CultureInfo.InvariantCulture) : ""); + SetCell(ws, i + 2, baseCols + 3, count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture)); + } + } + + ws.Columns().AdjustToContents(); + if (rows == 0) ws.Row(2).Cell(1).Value = "Нет данных"; + } + + private static void WritePairs(IXLWorksheet ws, TableData main, TableData secondary, MatchResult result) + { + var headers = new List(); + foreach (var h in main.Columns) headers.Add("A: " + h); + headers.Add("Тип"); + headers.Add("Сходство %"); + headers.Add("B-совпадений"); + foreach (var h in secondary.Columns) headers.Add("B: " + h); + + for (int c = 0; c < headers.Count; c++) + { + ws.Cell(1, c + 1).Value = headers[c]; + ws.Cell(1, c + 1).Style.Font.Bold = true; + ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF"); + } + + int aCols = main.Columns.Count; + int bCols = secondary.Columns.Count; + for (int i = 0; i < result.Pairs.Count; i++) + { + var p = result.Pairs[i]; + var rowA = p.RowA; + var rowB = p.RowB; + for (int c = 0; c < aCols; c++) + SetCell(ws, i + 2, c + 1, c < rowA.Length ? rowA[c] : ""); + SetCell(ws, i + 2, aCols + 1, p.Type); + SetCell(ws, i + 2, aCols + 2, p.Type == "нечёткое" ? p.Score.ToString("0.#", CultureInfo.InvariantCulture) : ""); + SetCell(ws, i + 2, aCols + 3, p.BCount == 0 ? "" : p.BCount.ToString(CultureInfo.InvariantCulture)); + for (int c = 0; c < bCols; c++) + SetCell(ws, i + 2, aCols + 3 + c + 1, c < rowB.Length ? rowB[c] : ""); + } + + ws.Columns().AdjustToContents(); + if (result.Pairs.Count == 0) ws.Row(2).Cell(1).Value = "Нет данных"; + } + + private static void WriteAnalytics(IXLWorksheet ws, TableData main, TableData secondary, MatchResult result) + { + ws.Cell(1, 1).Value = "Показатель"; + ws.Cell(1, 2).Value = "Значение"; + ws.Cell(1, 1).Style.Font.Bold = true; + ws.Cell(1, 2).Style.Font.Bold = true; + + int r = 2; + void Row(string name, object value) + { + ws.Cell(r, 1).Value = name; + ws.Cell(r, 2).Value = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + r++; + } + + Row("Всего в A", main.RowCount); + Row("Всего в B", secondary.RowCount); + Row("Совпадений (строк A)", result.Matches.Count); + Row("Только в A", result.OnlyMain.Count); + Row("Только в B", result.OnlySecondary.Count); + Row("Пар A↔B", result.Pairs.Count); + + var exact = result.Pairs.Count(p => p.Type == "точное"); + var partial = result.Pairs.Count(p => p.Type == "частичное"); + var fuzzy = result.Pairs.Count(p => p.Type == "нечёткое"); + Row("Из них точных", exact); + Row("Из них частичных", partial); + Row("Из них нечётких", fuzzy); + + double avg = result.Pairs.Count == 0 ? 0 : Math.Round(result.Pairs.Average(p => p.BCount), 2); + int maxBC = result.Pairs.Count == 0 ? 0 : result.Pairs.Max(p => p.BCount); + Row("Среднее B-совпадений (N→M)", avg); + Row("Максимум B-совпадений (N→M)", maxBC); + + ws.Columns().AdjustToContents(); + } + + private static void WriteSheet(IXLWorksheet ws, List columns, List rows) + { + int colCount = columns.Count; + for (int c = 0; c < colCount; c++) + { + ws.Cell(1, c + 1).Value = columns[c]; + ws.Cell(1, c + 1).Style.Font.Bold = true; + ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF"); + } + + for (int i = 0; i < rows.Count; i++) + { + var row = rows[i]; + for (int c = 0; c < row.Length && c < colCount; c++) + { + SetCell(ws, i + 2, c + 1, row[c]); + } + } + + ws.Columns().AdjustToContents(); + if (rows.Count == 0) ws.Row(2).Cell(1).Value = "Нет данных"; + } + + private static void SetCell(IXLWorksheet ws, int row, int col, string value) + { + var cell = ws.Cell(row, col); + if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var d) && + d.ToString(CultureInfo.InvariantCulture) == value) + cell.Value = d; + else + cell.Value = value; + } +} \ No newline at end of file diff --git a/MainWindow.cs b/MainWindow.cs new file mode 100644 index 0000000..9fbe282 --- /dev/null +++ b/MainWindow.cs @@ -0,0 +1,1324 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Styling; + +namespace ExcelMatcher; + +public sealed class MainWindow : Window +{ + private const int DisplayCap = 5000; + + private static class Ui + { + public static SolidColorBrush Solid(string hex) => new(Color.Parse(hex)); + public static readonly FontFamily Mono = new("DejaVu Sans Mono"); + public static readonly SolidColorBrush Bg = Solid("#07090F"); + public static readonly SolidColorBrush Card = Solid("#0E1420"); + public static readonly SolidColorBrush CardBorder = Solid("#22325A"); + public static readonly SolidColorBrush Accent = Solid("#00E5FF"); + public static readonly SolidColorBrush Magenta = Solid("#FF2DCB"); + public static readonly SolidColorBrush Text = Solid("#DCE7FF"); + public static readonly SolidColorBrush Muted = Solid("#7C8DB8"); + public static readonly SolidColorBrush Danger = Solid("#FF3860"); + public static readonly SolidColorBrush Success = Solid("#3EFF8B"); + public static readonly SolidColorBrush HeaderDark = Solid("#0B1220"); + public static readonly SolidColorBrush RowAlt = Solid("#101A2B"); + public static readonly SolidColorBrush BtnHover = new(Color.FromArgb(0x33, 0x00, 0xE5, 0xFF)); + public static readonly SolidColorBrush BtnHoverM = new(Color.FromArgb(0x2E, 0xFF, 0x2D, 0xCB)); + public static readonly SolidColorBrush HoverTextOnAccent = Solid("#00131A"); + public static readonly SolidColorBrush HoverTextOnMagenta = Solid("#1A0014"); + public static readonly SolidColorBrush GlowStrong = new(Color.FromArgb(0x38, 0x00, 0xE5, 0xFF)); + public static readonly SolidColorBrush GlowSoft = new(Color.FromArgb(0x24, 0x00, 0xE5, 0xFF)); + public static readonly SolidColorBrush DisabledBg = Solid("#13161E"); + public static readonly SolidColorBrush DisabledFg = Solid("#3E4A5F"); + public static readonly SolidColorBrush DisabledBorder = Solid("#232A38"); + + public const double Radius = 6; + + public static void Apply(bool dark) + { + Bg.Color = Color.Parse(dark ? "#07090F" : "#F2F6FC"); + Card.Color = Color.Parse(dark ? "#0E1420" : "#FFFFFF"); + CardBorder.Color = Color.Parse(dark ? "#22325A" : "#C8D4E8"); + Accent.Color = Color.Parse(dark ? "#00E5FF" : "#0066CC"); + Magenta.Color = Color.Parse(dark ? "#FF2DCB" : "#C2185B"); + Text.Color = Color.Parse(dark ? "#DCE7FF" : "#162036"); + Muted.Color = Color.Parse(dark ? "#7C8DB8" : "#5A6B8A"); + Danger.Color = Color.Parse(dark ? "#FF3860" : "#C81E3A"); + Success.Color = Color.Parse(dark ? "#3EFF8B" : "#168A4E"); + HeaderDark.Color = Color.Parse(dark ? "#0B1220" : "#E7EDF7"); + RowAlt.Color = Color.Parse(dark ? "#101A2B" : "#ECF1F9"); + BtnHover.Color = dark ? Color.FromArgb(0x33, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x12, 0x00, 0x66, 0xCC); + BtnHoverM.Color = dark ? Color.FromArgb(0x2E, 0xFF, 0x2D, 0xCB) : Color.FromArgb(0x12, 0xC2, 0x18, 0x5B); + HoverTextOnAccent.Color = Color.Parse(dark ? "#00131A" : "#FFFFFF"); + HoverTextOnMagenta.Color = Color.Parse(dark ? "#1A0014" : "#FFFFFF"); + GlowStrong.Color = dark ? Color.FromArgb(0x38, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x16, 0x00, 0x66, 0xCC); + GlowSoft.Color = dark ? Color.FromArgb(0x24, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x0E, 0x00, 0x66, 0xCC); + DisabledBg.Color = Color.Parse(dark ? "#13161E" : "#E7ECF4"); + DisabledFg.Color = Color.Parse(dark ? "#3E4A5F" : "#97A3B8"); + DisabledBorder.Color = Color.Parse(dark ? "#232A38" : "#D2DBE8"); + } + } + + private static Button StylePrimary(Button b) + { + b.Background = Ui.BtnHover; + b.Foreground = Ui.Accent; + b.BorderBrush = Ui.Accent; + b.BorderThickness = new Thickness(1.5); + b.CornerRadius = new CornerRadius(4); + b.Padding = new Thickness(22, 9); + b.FontWeight = FontWeight.Bold; + b.PointerEntered += (_, _) => { if (b.IsEnabled) { b.Background = Ui.Accent; b.Foreground = Ui.HoverTextOnAccent; } }; + b.PointerExited += (_, _) => { if (b.IsEnabled) { b.Background = Ui.BtnHover; b.Foreground = Ui.Accent; } }; + return b; + } + + private static Button StyleSecondary(Button b) + { + b.Background = Ui.BtnHoverM; + b.Foreground = Ui.Magenta; + b.BorderBrush = Ui.Magenta; + b.BorderThickness = new Thickness(1.5); + b.CornerRadius = new CornerRadius(4); + b.Padding = new Thickness(16, 8); + b.FontWeight = FontWeight.SemiBold; + b.PointerEntered += (_, _) => { if (b.IsEnabled) { b.Background = Ui.Magenta; b.Foreground = Ui.HoverTextOnMagenta; } }; + b.PointerExited += (_, _) => { if (b.IsEnabled) { b.Background = Ui.BtnHoverM; b.Foreground = Ui.Magenta; } }; + return b; + } + + private static void AddOption(Avalonia.Controls.Panel panel, Control c) + { + c.VerticalAlignment = VerticalAlignment.Center; + c.Margin = new Thickness(0, 2, 14, 2); + panel.Children.Add(c); + } + + private static void AddDivider(Avalonia.Controls.Panel panel) + { + panel.Children.Add(new Border + { + Width = 1, + Height = 18, + Background = Ui.CardBorder, + Margin = new Thickness(4, 6, 10, 6), + VerticalAlignment = VerticalAlignment.Center + }); + } + + private readonly TextBox _tbA = new() { PlaceholderText = "Путь к файлу A (*.xls / *.xlsx)" }; + private readonly Button _btnBrowseA = new() { Content = "Обзор…" }; + private readonly ComboBox _cbA = new() { MinWidth = 190 }; + private readonly StackPanel _colsA = new() { Orientation = Orientation.Vertical, Spacing = 2 }; + + private readonly TextBox _tbB = new() { PlaceholderText = "Путь к файлу B (*.xls / *.xlsx)" }; + private readonly Button _btnBrowseB = new() { Content = "Обзор…" }; + private readonly ComboBox _cbB = new() { MinWidth = 190 }; + private readonly StackPanel _colsB = new() { Orientation = Orientation.Vertical, Spacing = 2 }; + + private readonly CheckBox _chkIgnore = new() { Content = "Игнорировать регистр и лишние пробелы", IsChecked = true }; + + private readonly RadioButton _rbMatches = new() { Content = "Только совпадения (есть в обеих)" }; + private readonly RadioButton _rbOnlyA = new() { Content = "Только из A, которых нет в B" }; + private readonly RadioButton _rbOnlyB = new() { Content = "Только из B, которых нет в A" }; + private readonly RadioButton _rbBoth = new() { Content = "Совпадения + чего нет в A" }; + private readonly RadioButton _rbExact = new() { Content = "Точное совпадение", IsChecked = true }; + private readonly RadioButton _rbPartial = new() { Content = "Частичное (по фрагменту)" }; + private readonly CheckBox _chkDedupe = new() { Content = "Удалять дубликаты по ключевым колонкам" }; + private readonly CheckBox _chkCleanCols = new() { Content = "Убирать одинаковые колонки после слияния", IsChecked = true }; + + private readonly RadioButton _rbFuzzy = new() { Content = "Нечёткое (по сходству %)" }; + private readonly Slider _slFuzzy = new() + { + Minimum = 50, + Maximum = 99, + Value = 85, + IsSnapToTickEnabled = true, + TickFrequency = 1, + Width = 150, + VerticalAlignment = VerticalAlignment.Center + }; + private readonly TextBlock _lblFuzzy = new() + { + Text = "≥85%", + Width = 46, + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold + }; + private readonly CheckBox _chkExt = new() { Content = "Расширенная нормализация (даты/телефоны/пунктуация)" }; + private readonly RadioButton _rbAnd = new() { Content = "И ключи (все поля)", IsChecked = true }; + private readonly RadioButton _rbOr = new() { Content = "ИЛИ ключи (любое поле)" }; + private readonly TextBox _tbSynonyms = new() + { + AcceptsReturn = true, + MinHeight = 58, + TextWrapping = TextWrapping.Wrap, + FontFamily = Ui.Mono, + FontSize = 12 + }; + private readonly Button _btnAutoMap = new() { Content = "Подобрать ключи A↔B" }; + private readonly ProgressBar _progress = new() { IsIndeterminate = true, Height = 6, IsVisible = false }; + + private CancellationTokenSource? _cts; + private bool _running; + private readonly Button _btnRun = new() { Content = "Сопоставить" }; + + private readonly TextBlock _lblSummary = new() { FontWeight = FontWeight.Bold }; + private readonly TextBox _tbFilter = new() { PlaceholderText = "Фильтр по всем колонкам…" }; + private readonly ScrollViewer _resultScroll = new() + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto + }; + private readonly Button _btnSave = new() { Content = "Сохранить результат в Excel…" }; + private readonly TextBlock _lblStatus = new() { TextWrapping = TextWrapping.Wrap }; + private readonly TextBlock _lblCopyright = new() + { + Text = "© 2026 Dedicraf · Специально для Юрия Киселёва", + FontSize = 12, + Foreground = Ui.Muted + }; + + private readonly ToggleButton _themeToggle = new() + { + IsChecked = true, + Content = "ТЕМА: ТЁМНАЯ", + FontFamily = Ui.Mono, + FontSize = 12, + FontWeight = FontWeight.SemiBold, + Padding = new Thickness(14, 6, 14, 6), + Margin = new Thickness(0, 0, 10, 0), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + VerticalAlignment = VerticalAlignment.Center + }; + + private static readonly string[] _startArgs = Environment.GetCommandLineArgs(); + private bool _isDark = Array.IndexOf(_startArgs, "--light") < 0; + private readonly List _accentGradients = new(); + private readonly List _groupCards = new(); + private Border? _headerCard; + + private List _tablesA = new(); + private List _tablesB = new(); + private List _colCheckA = new(); + private List _colCheckB = new(); + + private string _loadedPathA = ""; + private string _loadedPathB = ""; + + private MatchResult? _lastResult; + private MergeOutput? _merged; + private TableData? _mainTable; + private TableData? _secondaryTable; + + private string _modeName = ""; + private List _viewHeaders = new(); + private List _viewData = new(); + private bool _viewShowSource; + private List? _viewSources; + private int _viewTotal; + private string _dedupeNote = ""; + + public MainWindow() + { + Title = "Сопоставлятор 3000"; + try { using var ic = AssetLoader.Open(new Uri("avares://ExcelMatcher/Assets/app.ico")); Icon = new WindowIcon(new Bitmap(ic)); } catch { } + Width = 1280; + Height = 800; + MinWidth = 980; + MinHeight = 620; + FontFamily = new FontFamily("Inter"); + FontSize = 14; + Background = Ui.Bg; + RequestedThemeVariant = _isDark ? ThemeVariant.Dark : ThemeVariant.Light; + + Ui.Apply(_isDark); + if (!_isDark) + { + _themeToggle.IsChecked = false; + _themeToggle.Content = "ТЕМА: СВЕТЛАЯ"; + } + _themeToggle.Background = Ui.BtnHover; + _themeToggle.Foreground = Ui.Accent; + _themeToggle.BorderBrush = Ui.Magenta; + _themeToggle.Click += (_, _) => ToggleTheme(); + + StylePrimary(_btnRun); + StyleSecondary(_btnBrowseA); + StyleSecondary(_btnBrowseB); + StylePrimary(_btnSave); + _lblSummary.TextTrimming = TextTrimming.CharacterEllipsis; + _lblSummary.Foreground = Ui.Muted; + _lblSummary.VerticalAlignment = VerticalAlignment.Center; + UpdateSaveButton(); + + _btnBrowseA.Click += (_, _) => _ = BrowseFile(_tbA, "Файл A (основная таблица)"); + _btnBrowseB.Click += (_, _) => _ = BrowseFile(_tbB, "Файл B (таблица для поиска совпадений)"); + _cbA.SelectionChanged += (_, _) => PopulateColumns(_tablesA, _cbA, _colsA, ref _colCheckA); + _cbB.SelectionChanged += (_, _) => PopulateColumns(_tablesB, _cbB, _colsB, ref _colCheckB); + + _tbA.KeyDown += (_, e) => { if (e.Key == Key.Enter) { e.Handled = true; LoadFile(true, true); } }; + _tbB.KeyDown += (_, e) => { if (e.Key == Key.Enter) { e.Handled = true; LoadFile(false, true); } }; + + _rbMatches.IsChecked = true; + foreach (var rb in new[] { _rbMatches, _rbOnlyA, _rbOnlyB, _rbBoth }) rb.GroupName = "mode"; + foreach (var rb in new[] { _rbExact, _rbPartial, _rbFuzzy }) rb.GroupName = "matchkind"; + foreach (var rb in new[] { _rbAnd, _rbOr }) rb.GroupName = "keylogic"; + + _slFuzzy.ValueChanged += (_, e) => + { + if (e.NewValue > 0) _lblFuzzy.Text = $"≥{Math.Round(e.NewValue)}%"; + }; + + _btnAutoMap.Click += (_, _) => AutoMapColumns(); + _tbSynonyms.PlaceholderText = "1 строка = 1 синоним.\nПишут так = канон. форма\nнапр.: ООО Ромашка = Ромашка"; + _progress.Margin = new Thickness(0, 8, 0, 2); + + _tbFilter.TextChanged += (_, _) => ReRender(); + + EnableDrop(_tbA, true); + EnableDrop(_tbB, false); + + _btnRun.Click += (_, _) => + { + if (_running) _cts?.Cancel(); + else _ = RunMatchingAsync(); + }; + _btnSave.Click += (_, _) => _ = SaveResult(); + + Content = BuildLayout(); + } + + private void ToggleTheme() + { + _isDark = _themeToggle.IsChecked == true; + Ui.Apply(_isDark); + RequestedThemeVariant = _isDark ? ThemeVariant.Dark : ThemeVariant.Light; + _themeToggle.Content = _isDark ? "ТЕМА: ТЁМНАЯ" : "ТЕМА: СВЕТЛАЯ"; + RefreshAccents(); + UpdateSaveButton(); + if (_viewData.Count > 0) ReRender(); + } + + private void RefreshAccents() + { + foreach (var g in _accentGradients) + { + if (g.GradientStops.Count >= 2) + { + g.GradientStops[0].Color = Ui.Accent.Color; + g.GradientStops[1].Color = Ui.Magenta.Color; + } + } + + if (_headerCard is not null) + _headerCard.BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = Ui.GlowStrong.Color }); + + foreach (var card in _groupCards) + card.BoxShadow = new BoxShadows(new BoxShadow { Blur = 20, Color = Ui.GlowSoft.Color }); + } + + private Control BuildLayout() + { + var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + root.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + root.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + root.RowDefinitions.Add(new RowDefinition(GridLength.Star)); + root.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + root.Margin = new Thickness(14, 10, 14, 10); + + var header = BuildHeader(); + header.Margin = new Thickness(0, 0, 0, 12); + root.Children.Add(header); + Grid.SetRow(root.Children[^1], 0); + + var top = BuildTopPanel(); + top.Margin = new Thickness(0, 0, 0, 10); + root.Children.Add(top); + Grid.SetRow(root.Children[^1], 1); + + var options = BuildOptionsPanel(); + options.Margin = new Thickness(0, 0, 0, 10); + root.Children.Add(options); + Grid.SetRow(root.Children[^1], 2); + + root.Children.Add(BuildResultsPanel()); + Grid.SetRow(root.Children[^1], 3); + + root.Children.Add(BuildBottomPanel()); + Grid.SetRow(root.Children[^1], 4); + + return root; + } + + private Control BuildHeader() + { + var grad = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative) + }; + grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0)); + grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1)); + _accentGradients.Add(grad); + + var textStack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 1 }; + textStack.Children.Add(new TextBlock + { + Text = "// СИСТЕМА СОПОСТАВЛЕНИЯ ДАННЫХ", + FontFamily = Ui.Mono, + FontSize = 11, + FontWeight = FontWeight.SemiBold, + Foreground = Ui.Muted, + LetterSpacing = 2 + }); + textStack.Children.Add(new TextBlock + { + Text = "СОПОСТАВЛЯТОР 3000", + FontFamily = Ui.Mono, + FontSize = 25, + FontWeight = FontWeight.ExtraBold, + Foreground = Ui.Accent + }); + textStack.Children.Add(new TextBlock + { + Text = "▸ сопоставление и совмещение данных из двух книг Excel", + FontFamily = Ui.Mono, + FontSize = 12, + Foreground = Ui.Magenta, + Margin = new Thickness(2, 3, 0, 0) + }); + + var pill = new Border + { + Background = Ui.BtnHover, + BorderBrush = Ui.Accent, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(14, 6, 14, 6), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "v1.0 · CYBER-МОДУЛЬ", + Foreground = Ui.Accent, + FontFamily = Ui.Mono, + FontWeight = FontWeight.SemiBold, + FontSize = 12 + } + }; + + var dock = new DockPanel { LastChildFill = true }; + DockPanel.SetDock(_themeToggle, Dock.Right); + dock.Children.Add(_themeToggle); + DockPanel.SetDock(pill, Dock.Right); + dock.Children.Add(pill); + dock.Children.Add(textStack); + + var stack = new StackPanel(); + var accentBar = new Border + { + Height = 3, + Background = grad, + CornerRadius = new CornerRadius(0, 0, 6, 6), + Margin = new Thickness(-18, 0, -18, -15) + }; + stack.Children.Add(dock); + stack.Children.Add(accentBar); + + _headerCard = new Border + { + Background = Ui.HeaderDark, + BorderBrush = Ui.Accent, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(18, 15, 18, 18), + BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = Ui.GlowStrong.Color }), + Child = stack + }; + return _headerCard; + } + + private Border MakeGroup(string title, string badge, Control body) + { + var titleRow = new DockPanel { LastChildFill = true }; + if (!string.IsNullOrEmpty(badge)) + { + var badgeBorder = new Border + { + Background = Ui.BtnHoverM, + BorderBrush = Ui.Magenta, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(10, 3, 10, 3), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = badge, + FontWeight = FontWeight.Bold, + Foreground = Ui.Accent, + FontFamily = Ui.Mono, + FontSize = 12 + } + }; + DockPanel.SetDock(badgeBorder, Dock.Right); + titleRow.Children.Add(badgeBorder); + } + titleRow.Children.Add(new TextBlock + { + Text = title, + FontWeight = FontWeight.SemiBold, + Foreground = Ui.Text, + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center + }); + + var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 10 }; + + var grad = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative) + }; + grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0)); + grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1)); + _accentGradients.Add(grad); + + var accentBar = new Border + { + Height = 3, + Background = grad, + CornerRadius = new CornerRadius(3, 3, 0, 0), + Margin = new Thickness(-14, -15, -14, 0) + }; + stack.Children.Add(accentBar); + stack.Children.Add(titleRow); + stack.Children.Add(body); + + var card = new Border + { + Background = Ui.Card, + BorderBrush = Ui.CardBorder, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(Ui.Radius), + Padding = new Thickness(14, 15, 14, 14), + BoxShadow = new BoxShadows(new BoxShadow { Blur = 20, Color = Ui.GlowSoft.Color }), + Child = stack + }; + _groupCards.Add(card); + return card; + } + + private Control BuildTopPanel() + { +var groupA = MakeGroup("Таблица A — основная", "A", BuildFilePanel(_tbA, _btnBrowseA, _cbA, _colsA)); + + var groupB = MakeGroup("Таблица B — для поиска совпадений", "B", BuildFilePanel(_tbB, _btnBrowseB, _cbB, _colsB)); + var grid = new Grid + { + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + grid.Children.Add(groupA); + grid.Children.Add(groupB); + Grid.SetColumn(groupB, 1); + groupA.Margin = new Thickness(0, 0, 7, 0); + groupB.Margin = new Thickness(7, 0, 0, 0); + return grid; + } + +private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPanel colsPanel) + { + tb.MinWidth = 300; + + var pathRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + pathRow.Children.Add(tb); + pathRow.Children.Add(browse); + + var sheetRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + sheetRow.Children.Add(new TextBlock + { + Text = "Лист:", + Foreground = Ui.Muted, + VerticalAlignment = VerticalAlignment.Center + }); + cb.MinWidth = 190; + cb.MaxWidth = 230; + sheetRow.Children.Add(cb); + + var colLabel = new TextBlock + { + Text = "Ключевые колонки для сопоставления", + Foreground = Ui.Muted, + FontWeight = FontWeight.SemiBold, + FontSize = 12 + }; + + var colScroll = new ScrollViewer + { + MaxHeight = 118, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = colsPanel + }; + + var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 7 }; + stack.Children.Add(pathRow); + stack.Children.Add(sheetRow); + stack.Children.Add(colLabel); + stack.Children.Add(colScroll); + return stack; + } + + private Control BuildOptionsPanel() + { + _btnRun.IsDefault = true; + + var modes = new WrapPanel { Orientation = Orientation.Horizontal }; + foreach (var rb in new[] { _rbMatches, _rbOnlyA, _rbOnlyB, _rbBoth }) + { + rb.VerticalAlignment = VerticalAlignment.Center; + rb.Margin = new Thickness(0, 2, 18, 2); + modes.Children.Add(rb); + } + + var kindLabel = new TextBlock + { + Text = "Способ:", + Foreground = Ui.Muted, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + var thresholdLabel = new TextBlock + { + Text = "Порог сходства:", + Foreground = Ui.Muted, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + + var matchKindRow = new WrapPanel { Orientation = Orientation.Horizontal }; + matchKindRow.Children.Add(kindLabel); + AddOption(matchKindRow, _rbExact); + AddOption(matchKindRow, _rbPartial); + AddOption(matchKindRow, _rbFuzzy); + AddDivider(matchKindRow); + matchKindRow.Children.Add(thresholdLabel); + matchKindRow.Children.Add(_slFuzzy); + matchKindRow.Children.Add(_lblFuzzy); + AddDivider(matchKindRow); + AddOption(matchKindRow, _chkExt); + + var keyRow = new WrapPanel { Orientation = Orientation.Horizontal }; + AddOption(keyRow, _rbAnd); + AddOption(keyRow, _rbOr); + AddDivider(keyRow); + AddOption(keyRow, _chkIgnore); + AddDivider(keyRow); + AddOption(keyRow, _chkDedupe); + AddDivider(keyRow); + AddOption(keyRow, _chkCleanCols); + AddDivider(keyRow); + + _btnAutoMap.Background = Ui.BtnHover; + _btnAutoMap.Foreground = Ui.Accent; + _btnAutoMap.BorderBrush = Ui.Accent; + _btnAutoMap.BorderThickness = new Thickness(1); + _btnAutoMap.CornerRadius = new CornerRadius(4); + _btnAutoMap.Padding = new Thickness(10, 6); + _btnAutoMap.FontWeight = FontWeight.SemiBold; + _btnAutoMap.VerticalAlignment = VerticalAlignment.Center; + keyRow.Children.Add(_btnAutoMap); + + var synonymsLabel = new TextBlock + { + Text = "Синонимы и нормализация (по строке «как пишут = канон. форма»):", + Foreground = Ui.Muted, + FontSize = 12, + Margin = new Thickness(0, 4, 0, 2) + }; + + var bottomRow = new DockPanel { LastChildFill = false, Margin = new Thickness(0, 8, 0, 0) }; + DockPanel.SetDock(_btnRun, Dock.Right); + bottomRow.Children.Add(_btnRun); + + var body = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 }; + body.Children.Add(modes); + body.Children.Add(matchKindRow); + body.Children.Add(keyRow); + body.Children.Add(synonymsLabel); + body.Children.Add(_tbSynonyms); + body.Children.Add(_progress); + body.Children.Add(bottomRow); + return MakeGroup("Режимы сопоставления", "Обработка", body); + } + + private Control BuildResultsPanel() + { + _tbFilter.Width = 250; + _tbFilter.HorizontalAlignment = HorizontalAlignment.Right; + + var filterRow = new DockPanel { LastChildFill = true, Margin = new Thickness(0, 0, 0, 4) }; + _lblSummary.VerticalAlignment = VerticalAlignment.Center; + DockPanel.SetDock(_tbFilter, Dock.Right); + filterRow.Children.Add(_tbFilter); + filterRow.Children.Add(_lblSummary); + + var dock = new DockPanel(); + DockPanel.SetDock(filterRow, Dock.Top); + dock.Children.Add(filterRow); + dock.Children.Add(_resultScroll); + + return MakeGroup("Результат", " ", dock); + } + + private DockPanel BuildBottomPanel() + { + var dock = new DockPanel { LastChildFill = true, Margin = new Thickness(4, 8, 4, 0) }; + + var saveBlock = new DockPanel { LastChildFill = false }; + DockPanel.SetDock(saveBlock, Dock.Right); + saveBlock.Children.Add(_btnSave); + + DockPanel.SetDock(_lblCopyright, Dock.Left); + dock.Children.Add(saveBlock); + dock.Children.Add(_lblCopyright); + dock.Children.Add(_lblStatus); + + _lblStatus.VerticalAlignment = VerticalAlignment.Center; + _lblCopyright.VerticalAlignment = VerticalAlignment.Center; + _btnSave.IsEnabled = false; + UpdateSaveButton(); + return dock; + } + + private void EnableDrop(TextBox tb, bool isMain) + { + DragDrop.SetAllowDrop(tb, true); + tb.AddHandler(DragDrop.DragOverEvent, (_, e) => + { + e.DragEffects = DragDropEffects.Copy; + e.Handled = true; + }); + tb.AddHandler(DragDrop.DropEvent, (_, e) => + { + if (e.DataTransfer is { } dt && dt.TryGetFiles() is { } files) + { + var first = files.FirstOrDefault(); + if (first is not null) + { + tb.Text = first.Path.LocalPath; + LoadFile(isMain, true); + } + } + e.Handled = true; + }); + } + + private async System.Threading.Tasks.Task BrowseFile(TextBox tb, string dialogTitle) + { + var top = TopLevel.GetTopLevel(this); + if (top is null) return; + + var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = dialogTitle, + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("Excel") { Patterns = new[] { "*.xlsx", "*.xlsm", "*.xltx", "*.xls", "*.xlt" } }, + FilePickerFileTypes.All + } + }); + + if (files.Count > 0) + { + tb.Text = files[0].Path.LocalPath; + LoadFile(tb == _tbA, true); + } + } + + private void LoadFile(bool isMain, bool showErrors) + { + var tb = isMain ? _tbA : _tbB; + var path = (tb.Text ?? "").Trim(); + var loadedPath = isMain ? _loadedPathA : _loadedPathB; + var loaded = isMain ? _tablesA : _tablesB; + + if (string.IsNullOrEmpty(path)) + { + if (showErrors) ShowError("Укажите путь к файлу Excel."); + return; + } + if (path == loadedPath && loaded.Count > 0) + { + if (showErrors) SetStatus($"Уже загружено: {Path.GetFileName(path)}.", false); + return; + } + if (!File.Exists(path)) + { + if (showErrors) ShowError($"Файл не найден: {path}"); + return; + } + + try + { + var tables = ExcelReader.LoadWorkbook(path); + if (tables.Count == 0) + { + ShowError("В файле нет таблиц (листов)."); + return; + } + + if (isMain) + { + _tablesA = tables; + _loadedPathA = path; + FillSheetCombo(_cbA, tables); + } + else + { + _tablesB = tables; + _loadedPathB = path; + FillSheetCombo(_cbB, tables); + } + if (showErrors) SetStatus($"Загружено: {Path.GetFileName(path)} — листов: {tables.Count}.", false); + } + catch (Exception ex) + { + ShowError($"Не удалось открыть файл: {ex.Message}"); + } + } + + private void EnsureFilesLoaded() + { + if (_tablesA.Count == 0 || _tbA.Text?.Trim() != _loadedPathA) LoadFile(true, false); + if (_tablesB.Count == 0 || _tbB.Text?.Trim() != _loadedPathB) LoadFile(false, false); + } + + private void FillSheetCombo(ComboBox cb, List tables) + { + cb.ItemsSource = tables.Select(t => $"{t.SheetName} ({t.RowCount} строк)").ToList(); + cb.SelectedIndex = 0; + } + + private void PopulateColumns(List tables, ComboBox cb, StackPanel colsPanel, ref List checks) + { + checks = new List(); + colsPanel.Children.Clear(); + + if (tables.Count == 0 || cb.SelectedIndex < 0 || cb.SelectedIndex >= tables.Count) return; + var table = tables[cb.SelectedIndex]; + + for (int i = 0; i < table.Columns.Count; i++) + { + var chk = new CheckBox { Content = table.Columns[i], IsChecked = i == 0 }; + checks.Add(chk); + colsPanel.Children.Add(chk); + } + } + + private TableData? SelectedTable(List tables, ComboBox cb) => + tables.Count > 0 && cb.SelectedIndex >= 0 && cb.SelectedIndex < tables.Count + ? tables[cb.SelectedIndex] + : null; + + private MatchOptions BuildOptions(bool[] mainUsed, bool[] secondaryUsed, CancellationToken token) + { + var kind = _rbFuzzy.IsChecked ?? false + ? MatchKind.Fuzzy + : (_rbPartial.IsChecked ?? false ? MatchKind.Partial : MatchKind.Exact); + + return new MatchOptions + { + MainUsed = mainUsed, + SecondaryUsed = secondaryUsed, + IgnoreCaseAndSpaces = _chkIgnore.IsChecked ?? true, + ExtendedNormalize = _chkExt.IsChecked ?? false, + Kind = kind, + FuzzyThreshold = (int)Math.Round(_slFuzzy.Value), + UseOr = _rbOr.IsChecked ?? false, + Synonyms = ParseSynonyms(), + Token = token + }; + } + + private IReadOnlyDictionary? ParseSynonyms() + { + Dictionary? dict = null; + foreach (var raw in (_tbSynonyms.Text ?? "").Replace('\r', '\n').Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + var parts = line.Split(new[] { '=' }, 2); + if (parts.Length != 2) continue; + var k = parts[0].Trim(); + var v = parts[1].Trim(); + if (k.Length == 0 || v.Length == 0) continue; + dict ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + dict[k] = v; + } + return dict; + } + + private async System.Threading.Tasks.Task RunMatchingAsync() + { + EnsureFilesLoaded(); + + var main = SelectedTable(_tablesA, _cbA); + var sec = SelectedTable(_tablesB, _cbB); + + if (main is null || sec is null) + { + ShowError("Загрузите обе таблицы (введите путь и нажмите Enter или используйте «Обзор…»)."); + return; + } + + var mainUsed = _colCheckA.Select(c => c.IsChecked ?? false).ToArray(); + var secUsed = _colCheckB.Select(c => c.IsChecked ?? false).ToArray(); + + if (mainUsed.Length == 0 || secUsed.Length == 0) + { + ShowError("Не удалось получить список колонок. Перезагрузите файлы."); + return; + } + if (mainUsed.All(x => !x) || secUsed.All(x => !x)) + { + ShowError("Отметьте хотя бы одну ключевую колонку в каждой таблице (по умолчанию отмечена первая)."); + return; + } + + var cts = new CancellationTokenSource(); + _cts = cts; + var token = cts.Token; + + _running = true; + _btnRun.Content = "Отмена"; + _progress.IsVisible = true; + SetStatus("Сопоставляю…", false); + + try + { + var options = BuildOptions(mainUsed, secUsed, token); + bool dedupe = _chkDedupe.IsChecked ?? false; + bool cleanCols = _chkCleanCols.IsChecked ?? true; + + var (result, merged, dMain, dSec, removedA, removedB) = await System.Threading.Tasks.Task.Run(() => + { + TableData m = main; + TableData s = sec; + int rA = 0, rB = 0; + if (dedupe) + { + (m, rA) = TableMatcher.Dedupe(main, mainUsed, options.IgnoreCaseAndSpaces, options); + (s, rB) = TableMatcher.Dedupe(sec, secUsed, options.IgnoreCaseAndSpaces, options); + } + var res = TableMatcher.RunAll(m, s, options); + var mg = TableMatcher.Merge(m, s, options, cleanCols); + return (res, mg, m, s, rA, rB); + }, token); + + _mainTable = dMain; + _secondaryTable = dSec; + _lastResult = result; + _merged = merged; + + _dedupeNote = removedA + removedB > 0 + ? $" Дублей удалено: A={removedA}, B={removedB}." + : ""; + + ShowSelectedMode(dMain, dSec, result); + + var ca = TableMatcher.KeyCoverage(dMain, mainUsed); + var cb = TableMatcher.KeyCoverage(dSec, secUsed); + SetStatus(BuildStats(dMain, dSec, result, ca, cb) + _dedupeNote, false); + + _btnSave.IsEnabled = true; + UpdateSaveButton(); + } + catch (OperationCanceledException) + { + SetStatus("Сопоставление отменено.", false); + } + catch (Exception ex) + { + _btnSave.IsEnabled = false; + UpdateSaveButton(); + ShowError($"Ошибка при сопоставлении: {ex.Message}"); + } + finally + { + _running = false; + _btnRun.Content = "Сопоставить"; + _progress.IsVisible = false; + _cts = null; + } + } + + private string BuildStats(TableData m, TableData s, MatchResult r, (int Empty, int Total) ca, (int Empty, int Total) cb) + { + double covA = ca.Total == 0 ? 100 : Math.Round((ca.Total - ca.Empty) * 100.0 / ca.Total, 1); + double covB = cb.Total == 0 ? 100 : Math.Round((cb.Total - cb.Empty) * 100.0 / cb.Total, 1); + + var sb = new System.Text.StringBuilder(); + sb.Append($"Всего: A={m.RowCount}, B={s.RowCount}. "); + sb.Append($"Совпадений: {r.Matches.Count} · Только в A: {r.OnlyMain.Count} · Только в B: {r.OnlySecondary.Count}."); + if (r.Pairs.Count > 0) + { + double avg = Math.Round(r.Pairs.Average(p => p.BCount), 2); + int max = r.Pairs.Max(p => p.BCount); + sb.Append($" Пары A↔B: {r.Pairs.Count}, ср. N→M: {avg.ToString(CultureInfo.InvariantCulture)}, макс: {max}."); + } + sb.Append($" Ключи заполнены: A={covA.ToString(CultureInfo.InvariantCulture)}%, B={covB.ToString(CultureInfo.InvariantCulture)}%."); + return sb.ToString(); + } + + private void AutoMapColumns() + { + var a = SelectedTable(_tablesA, _cbA); + var b = SelectedTable(_tablesB, _cbB); + if (a is null || b is null) + { + ShowError("Сначала загрузите таблицы A и B и выберите листы."); + return; + } + if (_colCheckA.Count == 0 || _colCheckB.Count == 0) + { + ShowError("Сначала выберите листы."); + return; + } + + var bIdx = new List(); + for (int j = 0; j < _colCheckB.Count; j++) + { + if ((_colCheckB[j].IsChecked ?? false)) bIdx.Add(j); + } + if (bIdx.Count == 0) bIdx.Add(0); + + foreach (var c in _colCheckA) c.IsChecked = false; + + var mapped = new HashSet(); + foreach (var j in bIdx) + { + var bh = b.Columns.Count > j ? b.Columns[j] : "Колонка " + (j + 1); + var x = bh.ToLowerInvariant(); + int best = -1; + double bestSim = 0; + for (int i = 0; i < a.Columns.Count; i++) + { + var y = a.Columns[i].ToLowerInvariant(); + double sim = TableMatcher.Similarity(x, y); + if (x.Length > 0 && y.Length > 0 && + (x.Contains(y, StringComparison.OrdinalIgnoreCase) || y.Contains(x, StringComparison.OrdinalIgnoreCase))) + sim = Math.Max(sim, 0.85); + if (sim > bestSim) + { + bestSim = sim; + best = i; + } + } + if (best >= 0 && bestSim >= 0.25) + { + _colCheckA[best].IsChecked = true; + mapped.Add(best); + } + } + + if (mapped.Count == 0) _colCheckA[0].IsChecked = true; + + SetStatus($"Подбор ключей: колонок B={bIdx.Count}, сопоставлено колонок A={mapped.Count}.", false); + } + + private void ShowSelectedMode(TableData main, TableData sec, MatchResult result) + { + MatchMode mode; + if ((_rbMatches.IsChecked ?? false)) mode = MatchMode.KeepMatches; + else if ((_rbOnlyA.IsChecked ?? false)) mode = MatchMode.KeepOnlyMain; + else if ((_rbOnlyB.IsChecked ?? false)) mode = MatchMode.KeepOnlySecondary; + else mode = MatchMode.KeepBoth; + + List headers; + List data = new(); + List? sources = null; + + switch (mode) + { + case MatchMode.KeepMatches: + if (_merged is { Rows.Count: > 0 } && _merged.Columns.Count > 0) + { + headers = _merged.Columns; + data = _merged.Rows; + } + else + { + headers = main.Columns; + data = result.Matches; + } + break; + + case MatchMode.KeepOnlyMain: + headers = main.Columns; + data = result.OnlyMain; + break; + + case MatchMode.KeepOnlySecondary: + headers = sec.Columns; + data = result.OnlySecondary; + sources = data.Select(_ => "B").ToList(); + break; + + default: + headers = main.Columns; + foreach (var r in result.Matches) data.Add(r); + sources = new List(); + foreach (var _ in result.Matches) sources.Add("A"); + foreach (var r in result.OnlySecondary) + { + var row = new string[main.Columns.Count]; + Array.Fill(row, ""); + for (int c = 0; c < row.Length && c < r.Length; c++) row[c] = r[c]; + data.Add(row); + sources.Add("B"); + } + break; + } + + bool showSource = sources is not null; + string modeName = mode switch + { + MatchMode.KeepMatches => "Совпадения (совмещённые данные)", + MatchMode.KeepOnlyMain => "Строки A без совпадений в B", + MatchMode.KeepOnlySecondary => "Строки B без совпадений в A", + _ => "Совпадения + чего нет в A", + }; + if (_rbPartial.IsChecked ?? false) modeName += " · частичное"; + if (_rbFuzzy.IsChecked ?? false) modeName += " · нечёткое"; + if (_rbOr.IsChecked ?? false) modeName += " · ИЛИ-ключи"; + if (_chkExt.IsChecked ?? false) modeName += " · расшир.нормализация"; + if (_chkDedupe.IsChecked ?? false) modeName += " · без дублей"; + + RenderTable(headers, data, showSource, sources, modeName); + + SetStatus($"Совпадений: {result.Matches.Count} · Только в A: {result.OnlyMain.Count} · Только в B: {result.OnlySecondary.Count}{_dedupeNote}", false); + } + + private void RenderTable(List headers, List data, bool showSource, List? sources, string modeName) + { + _viewHeaders = headers; + _viewData = data; + _viewShowSource = showSource; + _viewSources = sources; + _viewTotal = data.Count; + _modeName = modeName; + ReRender(); + } + + private void ReRender() + { + string q = (_tbFilter.Text ?? "").Trim().ToLowerInvariant(); + var tokens = q.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Distinct().ToArray(); + + var filtered = new List(); + List? filteredSources = _viewSources is null ? null : new List(); + for (int i = 0; i < _viewData.Count; i++) + { + var row = _viewData[i]; + bool ok = true; + foreach (var t in tokens) + { + bool hit = false; + for (int c = 0; c < row.Length; c++) + { + if ((row[c] ?? "").ToLowerInvariant().Contains(t)) { hit = true; break; } + } + if (!hit && _viewShowSource && _viewSources is not null && i < _viewSources.Count && + _viewSources[i].ToLowerInvariant().Contains(t)) + hit = true; + if (!hit) { ok = false; break; } + } + if (!ok) continue; + + filtered.Add(row); + filteredSources?.Add(_viewSources is null ? "" : _viewSources[i]); + } + + string highlight = q.Length == 0 || filtered.Count == _viewTotal ? "" : $" — показано {filtered.Count} из {_viewTotal}"; + string cap = _viewTotal > DisplayCap ? $" (показаны первые {DisplayCap} из {_viewTotal})" : ""; + _lblSummary.Text = $"{_modeName}: {_viewTotal} строк{highlight}{cap}"; + _lblSummary.Foreground = Ui.Accent; + _lblSummary.FontWeight = FontWeight.SemiBold; + _lblSummary.TextAlignment = TextAlignment.Left; + _lblSummary.HorizontalAlignment = HorizontalAlignment.Left; + + RenderGrid(_viewHeaders, filtered, _viewShowSource, filteredSources ?? _viewSources); + } + + private void RenderGrid(List headers, List data, bool showSource, List? sources) + { + var host = new StackPanel { Orientation = Orientation.Vertical }; + + int colCount = headers.Count; + var widths = new double[colCount]; + for (int c = 0; c < colCount; c++) + { + int maxLen = headers[c].Length; + for (int i = 0; i < Math.Min(data.Count, 300); i++) + { + var row = data[i]; + if (c < row.Length && row[c] is not null) maxLen = Math.Max(maxLen, row[c].Length); + } + widths[c] = Math.Clamp(maxLen * 8.2 + 16, 70, 340); + } + + var header = new StackPanel { Orientation = Orientation.Horizontal, Background = Ui.HeaderDark }; + if (showSource) + { + header.Children.Add(CellHeader("Источник", 80)); + } + for (int c = 0; c < colCount; c++) + { + header.Children.Add(CellHeader(headers[c], widths[c])); + } + host.Children.Add(header); + + var grad = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative) + }; + grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0)); + grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1)); + host.Children.Add(new Border { Height = 2, Background = grad }); + + int shownCount = Math.Min(data.Count, DisplayCap); + for (int i = 0; i < shownCount; i++) + { + var rowPanel = new StackPanel { Orientation = Orientation.Horizontal }; + if (i % 2 == 1) rowPanel.Background = Ui.RowAlt; + + if (showSource && sources is not null) + { + rowPanel.Children.Add(CellText(i < sources.Count ? sources[i] : "", 80, true)); + } + var row = data[i]; + for (int c = 0; c < colCount; c++) + { + rowPanel.Children.Add(CellText(c < row.Length ? row[c] : "", widths[c], false)); + } + host.Children.Add(rowPanel); + } + + if (data.Count == 0) + { + bool filteredNothing = (_tbFilter.Text ?? "").Trim().Length > 0; + host.Children.Add(new TextBlock + { + Text = filteredNothing + ? "Ничего не найдено по заданному фильтру (данные не менялись)." + : "Нет данных по выбранному критерию.", + Margin = new Thickness(6, 14, 0, 4), + Foreground = Ui.Muted + }); + } + + _resultScroll.Content = host; + } + + private static TextBlock CellHeader(string text, double width) => + new() + { + Text = text, + Width = width, + FontWeight = FontWeight.SemiBold, + Foreground = Ui.Accent, + TextTrimming = TextTrimming.CharacterEllipsis, + Padding = new Thickness(6, 8, 6, 8) + }; + + private static TextBlock CellText(string text, double width, bool bold) + { + var tb = new TextBlock + { + Text = string.IsNullOrEmpty(text) ? " " : text, + Width = width, + TextTrimming = TextTrimming.CharacterEllipsis, + Padding = new Thickness(6, 4, 6, 4), + Foreground = Ui.Text, + FontWeight = bold ? FontWeight.SemiBold : FontWeight.Normal + }; + ToolTip.SetTip(tb, text); + return tb; + } + + private async System.Threading.Tasks.Task SaveResult() + { + if (_lastResult is null || _mainTable is null || _secondaryTable is null) + { + SetStatus("Сначала выполните сопоставление.", true); + return; + } + + var top = TopLevel.GetTopLevel(this); + if (top is null) return; + + var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Сохранить результат сопоставления", + SuggestedFileName = "результат_сопоставления.xlsx", + DefaultExtension = "xlsx", + ShowOverwritePrompt = true, + FileTypeChoices = new[] + { + new FilePickerFileType("Excel (.xlsx)") { Patterns = new[] { "*.xlsx" } } + } + }); + + if (file is null) return; + + try + { + var path = file.Path.LocalPath; + ExcelExporter.ExportAll(path, _mainTable, _secondaryTable, _lastResult, _merged); + SetStatus($"Сохранено: {path}", false); + } + catch (Exception ex) + { + SetStatus($"Ошибка сохранения: {ex.Message}", true); + } + } + + private void ShowError(string message) + { + _lblSummary.Text = message; + _lblSummary.FontWeight = FontWeight.SemiBold; + _lblSummary.Foreground = Ui.Danger; + SetStatus(message, true); + } + + private void UpdateSaveButton() + { + _btnSave.Background = _btnSave.IsEnabled ? Ui.BtnHover : Ui.DisabledBg; + _btnSave.Foreground = _btnSave.IsEnabled ? Ui.Accent : Ui.DisabledFg; + _btnSave.BorderBrush = _btnSave.IsEnabled ? Ui.Accent : Ui.DisabledBorder; + _btnSave.BorderThickness = new Thickness(1.5); + } + + private void SetStatus(string message, bool isError) + { + _lblStatus.Text = message; + _lblStatus.Foreground = isError ? Ui.Danger : Ui.Success; + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..88fb130 --- /dev/null +++ b/Program.cs @@ -0,0 +1,15 @@ +using Avalonia; + +namespace ExcelMatcher; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont(); +} \ No newline at end of file