commit fa9ae8ed6f6bc8416a5a41fd3170364e1588a1eb Author: Dedicraf Date: Sat Sep 19 14:17:35 2026 +0300 Initial import of ExcelMatcher 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 0000000..1d3d1a7 Binary files /dev/null and b/Assets/app.ico differ 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