From 7e5322d831056936907d43033287d3a25688feb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:16 +0300 Subject: [PATCH] =?UTF-8?q?Listform=20i=C3=A7in=20GetDataFolderPath=20veya?= =?UTF-8?q?=20GetDataFilePath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/instructions/lowcode.instructions.md | 15 +- README.md | 2 +- .../McpDevelopAppService.cs | 32 +- .../ListForms/ListFormSeedDataSynchronizer.cs | 269 ++----------- .../ListForms/ListFormWizardAppService.cs | 39 +- .../ListForms/WizardExportCollector.cs | 157 +++----- .../ListForms/WizardImportValidator.cs | 62 +-- .../ListForms/WizardRecordCleaner.cs | 29 +- .../ListForms/WizardSeedRowStore.cs | 36 +- .../ListForms/WizardTableCleaner.cs | 30 +- .../Data/ListFormSeedDataApplier.cs | 47 +-- .../Data/ListFormSeedTarget.cs | 360 ++++++++++++++++++ ...tificationTenantDefaultsSeedContributor.cs | 34 +- claude.md | 12 +- 14 files changed, 576 insertions(+), 548 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Domain/Data/ListFormSeedTarget.cs diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index 8b64cadc..307cbea4 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -4649,14 +4649,15 @@ alanıdır; boşsa ne geri yükleme ne de senkronizasyon yapılır. **Hedef dosya ya da klasördür.** Klasör modunda da her dosya aynı zarfı (`ListFormCode`, `KeyFieldName`, `GeneratedAt`, `Order`, `Rows`) taşır; `Rows` tek satırdır ve dosya adı -`{anahtar}.json`'dur. Ekranın `SeedFilePath`'i varsayılan `data/{ListFormCode}.json` iken -diskte `data/{ListFormCode}/` klasörü varsa migrate yolu klasöre çevirir. `SeedFilePath` `.json` ile bitiyorsa bütün satırlar tek -dosyadadır (varsayılan). Uzantısız yazılırsa hedef klasördür (`data/{ListFormCode}`) ve her satır -`{anahtar}.json` olarak kendi dosyasında durur; klasör yoksa ilk kayıtta oluşturulur. Klasör modu -satırı büyük ya da sık yazılan ekranlar içindir — şu an tek örnek Custom Components -(`SeedPathResolver.GetDataFolderPath`). Uygulayıcı `data/` altını özyinelemeli tarar ve bir +`{anahtar}.json`'dur. Biçimi yalnızca `SeedFilePath` belirler; diskte ne bulunduğu kararı +değiştirmez. `.json` ile bitiyorsa bütün satırlar tek dosyadadır (varsayılan). Uzantısız +yazılırsa hedef klasördür (`data/{ListFormCode}`) ve her satır `{anahtar}.json` olarak kendi +dosyasında durur; klasör yoksa ilk kayıtta oluşturulur. Klasör modu satırı büyük ya da sık yazılan +ekranlar içindir (`SeedPathResolver.GetDataFolderPath`). Hedefi okuyan ve yazan her yol +`ListFormSeedTarget`'ı kullanır. Uygulayıcı `data/` altını özyinelemeli tarar ve bir klasörün tamamını **tek unit of work'te**, ekran tanımını bir kez çözerek uygular; dosyadaki -`ListFormCode` boşsa ekran kodu klasör adından gelir. Ekrandaki hangi işlemin dosyaya +`ListFormCode` boşsa ekran kodu klasör adından gelir. Ekranın tanımladığı hedefin dışında kalan +dosya (biçim değiştirilmiş ekranın eski dosyası) uygulanmaz, uyarıyla atlanır. Ekrandaki hangi işlemin dosyaya yansıyacağını `SeedSyncInsert`, `SeedSyncUpdate` ve `SeedSyncDelete` bayrakları belirler — üçü de kapalıyken dosya yalnızca seed sırasında okunur, hiç yazılmaz. diff --git a/README.md b/README.md index 413aaac0..d300db38 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ yazılır, PostgreSQL'de `PostgreSqlModelSyntax` ile çevrilir (`PlatformDbConte | `{sql\|postgres}/host/{object\|execute}` | Aynı iki kural, **yalnızca host veritabanında**. Tenant veritabanında karşılığı olmayan nesnelere dayanan script buraya konur — örneğin HangFire tabloları tenant migrate'inde de yalnızca host'a kurulduğu için `Sas_H_BackgroundWorker_JobFlow` view'ı buradadır. Sıra: `object` → `host/object` → `execute` → `host/execute`. SqlTableDesigner ve SqlQueryManager her zaman `object/`'e yazar; yalnızca host'ta çalışması gereken script File Manager'dan buraya taşınır. Wizard export/import bu klasörü de taşır. | | `wizard` | ListForm Wizard'ın ürettiği `.json` konfigürasyonları; `WizardDataSeeder` okur. | | `crud` | CrudEndpoint konfigürasyonları; `CrudDataSeeder` dosyaları `ICrudEndpointSeedApplier` üzerinden uygular. Politika **damga karşılaştırmalıdır**: eksik endpoint her zaman eklenir, mevcut bir kayıt yalnızca dosyanın `GeneratedAt` damgası kaydın `LastModificationTime`/`CreationTime` değerinden **yeniyse** güncellenir ya da silinir; aksi halde kayıt korunur ve log'a drift uyarısı düşer. Böylece CRUD Endpoint Manager'dan yapılan bir revizyon depodan gelen eski bir dosya yüzünden migrate sırasında geri alınmaz. Damgası olmayan (eski biçimde yazılmış) dosya hiçbir zaman yeni sayılmaz. | -| `data` | Ekranlardan girilen listelerin veri aynası: tek dosya (`{ListFormCode}.json`) ya da satır başına dosya tutan klasör (`{ListFormCode}/{anahtar}.json`; klasör özyinelemeli taranır ve tamamı tek unit of work'te uygulanır). `ListForm.SeedFilePath` dolu olan ekranda `SeedSyncInsert/Update/Delete` bayraklarından işaretli olan işlemler dosyaya yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında bütün contributor'lar bittikten sonra `ListFormSeedDataApplier` dosyayı geri uygular: anahtarı veritabanında **olmayan** satırları ekler, var olanlara dokunmaz. Aynı uygulayıcı wizard import'unun kapanışında da (`CompleteImport`) yalnızca o import'ta yazılan dosyalar için, `overwriteExisting` açık olarak çalışır — orada var olan satır dosyadaki değerlerle güncellenir. | +| `data` | Ekranlardan girilen listelerin veri aynası: tek dosya (`{ListFormCode}.json`) ya da satır başına dosya tutan klasör (`{ListFormCode}/{anahtar}.json`; klasör özyinelemeli taranır ve tamamı tek unit of work'te uygulanır). Biçimi yalnızca ekranın `SeedFilePath` tanımı belirler; hedefi okuyan/yazan her yol `ListFormSeedTarget` üzerinden gider ve tanımın dışında kalan dosya uygulanmaz. `ListForm.SeedFilePath` dolu olan ekranda `SeedSyncInsert/Update/Delete` bayraklarından işaretli olan işlemler dosyaya yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında bütün contributor'lar bittikten sonra `ListFormSeedDataApplier` dosyayı geri uygular: anahtarı veritabanında **olmayan** satırları ekler, var olanlara dokunmaz. Aynı uygulayıcı wizard import'unun kapanışında da (`CompleteImport`) yalnızca o import'ta yazılan dosyalar için, `overwriteExisting` açık olarak çalışır — orada var olan satır dosyadaki değerlerle güncellenir. | Klasör kökünde kalmış eski `.sql` dosyaları geriye dönük uyumluluk için hâlâ işlenir, fakat `SqlDataSeeder` bunları uyarı ile loglar — ilgili klasöre taşınmaları beklenir. diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs index b8cd10b0..6f5033eb 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Data; using System.Globalization; -using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -360,26 +359,22 @@ public class McpDevelopAppService( } /// - /// Demo satirlarini ekranin veri dosyasina (ListForm.SeedFilePath) yazar; veritabanina - /// dokunmaz. Migrate ekrani kurduktan sonra ListFormSeedDataApplier satirlari ekler ve - /// kiraci/audit alanlarini doldurur. Guid anahtar burada uretilir: dosya her uygulandiginda ayni - /// kayda karsilik gelir. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez. + /// Demo satirlarini ekranin veri seed hedefine (ListForm.SeedFilePath; tanima gore tek + /// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra + /// ListFormSeedDataApplier satirlari ekler ve kiraci/audit alanlarini doldurur. Guid anahtar + /// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir + /// varsa (tekrar deploy) yeniden eklenmez. /// private async Task WriteSeedRowsAsync(string fileName, List> rows) { var listForm = (await wizardAppService.GetFile(fileName)).ListForm; - var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/'); - if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath)) + var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath); + if (listForm == null || target == null) throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); - var path = SeedPathResolver.GetPath(configuration, relativePath.Split('/')); - var seedFile = File.Exists(path) - ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) - : null; - seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName }; - - var keyFieldName = seedFile.KeyFieldName; - var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal); + var keyFieldName = listForm.KeyFieldName; + var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal); + var added = new List<(object Key, Dictionary Row)>(); foreach (var row in rows) { @@ -390,11 +385,12 @@ public class McpDevelopAppService( if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase)) values[keyFieldName] = GuidGenerator.Create(); - seedFile.Rows.Add(values); + added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values)); } - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile)); + // Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz. + if (added.Count > 0 && (await target.UpsertRowsAsync(listForm.ListFormCode, keyFieldName, added)).Count > 0) + throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]); } /// Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin. diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs index 9f4de55b..42088343 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -83,12 +82,6 @@ public class ListFormSeedDataSynchronizer( { private const int SyncAllPageSize = 10000; - /// - /// Ekranin seed hedefi. true ise her satir klasorun altinda kendi - /// dosyasindadir ({anahtar}.json); false ise butun satirlar tek dosyadadir. - /// - private sealed record SeedTarget(string Path, bool IsFolder); - public Task SyncRowAsync(string listFormCode, object? key, SeedSyncOperation operation) => SyncRowsAsync(listFormCode, [key], operation); @@ -114,27 +107,8 @@ public class ListFormSeedDataSynchronizer( if (rows.Count == 0) return; - if (target.IsFolder) - { - foreach (var (key, row) in rows) - await WriteRowFileAsync(listForm, target, key, row); - - return; - } - - var seedFile = await ReadSeedFileAsync(target.Path, listForm); - - foreach (var (key, row) in rows) - { - var index = FindRowIndex(seedFile, listForm.KeyFieldName, key); - - if (index >= 0) - seedFile.Rows[index] = row; - else - seedFile.Rows.Add(row); - } - - await WriteSeedFileAsync(target.Path, seedFile); + foreach (var key in await target.UpsertRowsAsync(listForm.ListFormCode, listForm.KeyFieldName, rows)) + logger.LogWarning("Key '{Key}' cannot be used as a file name, the '{ListFormCode}' seed row was not written.", key, listForm.ListFormCode); }); } @@ -142,45 +116,15 @@ public class ListFormSeedDataSynchronizer( { await ExecuteAsync(listFormCode, listForm => listForm.SeedSyncDelete, async (listForm, target) => { - if (keys == null || keys.Length == 0) + // Anahtarsiz silmede hangi satirin gittigi bilinmez; hedef listeden yeniden uretilir. + // Tek dosya diskte yoksa da ayni yol izlenir, dosya listenin kalan satirlariyla dogar. + if (keys == null || keys.Length == 0 || (!target.IsFolder && !target.Exists)) { await WriteAllRowsAsync(listForm, target); return; } - if (target.IsFolder) - { - foreach (var key in keys) - { - var rowPath = ResolveRowFilePath(target, key); - if (rowPath != null && File.Exists(rowPath)) - File.Delete(rowPath); - } - - return; - } - - if (!File.Exists(target.Path)) - { - await WriteAllRowsAsync(listForm, target); - return; - } - - var seedFile = await ReadSeedFileAsync(target.Path, listForm); - var removed = false; - - foreach (var key in keys) - { - var index = FindRowIndex(seedFile, listForm.KeyFieldName, key); - if (index < 0) - continue; - - seedFile.Rows.RemoveAt(index); - removed = true; - } - - if (removed) - await WriteSeedFileAsync(target.Path, seedFile); + await target.RemoveRowsAsync(listForm.ListFormCode, listForm.KeyFieldName, keys); }); } @@ -193,35 +137,12 @@ public class ListFormSeedDataSynchronizer( if (string.IsNullOrWhiteSpace(fieldName) || wanted.Count == 0) return; - bool Matches(Dictionary row) => - row.Any(kv => string.Equals(kv.Key, fieldName, StringComparison.OrdinalIgnoreCase) && - wanted.Contains(ToComparableText(kv.Value))); - // Yalnizca dosyaya dokunur, veritabanindan satir okumaz; commit beklenmez. Ertelenseydi // ayni istekte sonradan yazilan wizard satirlarini silebilirdi. - await RunAsync(listFormCode, listForm => listForm.SeedSyncDelete, async (_, target) => - { - List files = target.IsFolder - ? Directory.Exists(target.Path) ? [.. Directory.EnumerateFiles(target.Path, "*.json")] : [] - : File.Exists(target.Path) ? [target.Path] : []; - - foreach (var filePath in files) - { - var seedFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath), - ListFormSeedDataFileDto.JsonOptions); - - if (seedFile?.Rows == null || seedFile.Rows.RemoveAll(Matches) == 0) - continue; - - // Klasor modunda satir dosyasi tek satirliktir; bosalan dosya klasorde kalirsa - // applier onu satirsiz bir ekran dosyasi olarak okur. - if (target.IsFolder && seedFile.Rows.Count == 0) - File.Delete(filePath); - else - await WriteSeedFileAsync(filePath, seedFile); - } - }, createTarget: false); + await RunAsync(listFormCode, listForm => listForm.SeedSyncDelete, (_, target) => + target.RemoveRowsAsync(row => row.Any(kv => + string.Equals(kv.Key, fieldName, StringComparison.OrdinalIgnoreCase) && + wanted.Contains(ListFormSeedTarget.KeyText(kv.Value))))); } public async Task SyncAllAsync(string listFormCode) @@ -237,21 +158,11 @@ public class ListFormSeedDataSynchronizer( } /// Hedefi listenin tamamiyla yeniden yazar. - private async Task WriteAllRowsAsync(ListForm listForm, SeedTarget target) + private async Task WriteAllRowsAsync(ListForm listForm, ListFormSeedTarget target) { - var rows = await ReadAllRowsAsync(listForm); - - if (target.IsFolder) - { - await WriteRowFilesAsync(listForm, target, rows); - return; - } - - // Uygulama sirasi dosyanin kendi bilgisidir; listeden uretilemez, var olan degeri korunur. - var seedFile = await ReadSeedFileAsync(target.Path, listForm); - seedFile.Rows = rows; - - await WriteSeedFileAsync(target.Path, seedFile); + var skipped = await target.ReplaceRowsAsync(listForm.ListFormCode, listForm.KeyFieldName, await ReadAllRowsAsync(listForm)); + if (skipped > 0) + logger.LogWarning("{Count} row(s) without a usable key were skipped while writing the '{ListFormCode}' seed folder.", skipped, listForm.ListFormCode); } /// @@ -281,94 +192,6 @@ public class ListFormSeedDataSynchronizer( } } - /// - /// Klasor modunda her satiri kendi dosyasina yazar ve listede kalmayan satirlarin dosyalarini - /// siler — klasor listenin aynasidir. - /// - private async Task WriteRowFilesAsync(ListForm listForm, SeedTarget target, List> rows) - { - var order = await ReadFolderOrderAsync(target); - var written = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var row in rows) - { - var fileName = SeedPathResolver.GetRowFileName(ToComparableText(row.GetValueOrDefault(listForm.KeyFieldName))); - if (fileName == null) - { - logger.LogWarning("A row without a key was skipped while writing the '{ListFormCode}' seed folder.", listForm.ListFormCode); - continue; - } - - var filePath = Path.Combine(target.Path, fileName); - var seedFile = await ReadSeedFileAsync(filePath, listForm); - seedFile.Rows = [row]; - seedFile.Order = order; - - await WriteSeedFileAsync(filePath, seedFile); - written.Add(fileName); - } - - foreach (var filePath in Directory.EnumerateFiles(target.Path, "*.json")) - { - if (!written.Contains(Path.GetFileName(filePath))) - File.Delete(filePath); - } - } - - /// Klasor modunda tek bir satirin dosyasini yazar. - private async Task WriteRowFileAsync(ListForm listForm, SeedTarget target, object key, Dictionary row) - { - var filePath = ResolveRowFilePath(target, key); - if (filePath == null) - { - logger.LogWarning("Key '{Key}' cannot be used as a file name, the '{ListFormCode}' seed row was not written.", key, listForm.ListFormCode); - return; - } - - var isNew = !File.Exists(filePath); - - var seedFile = await ReadSeedFileAsync(filePath, listForm); - seedFile.Rows = [row]; - - // Yeni dosya uygulama sirasini kardeslerinden alir; 0 ile yazilsaydi ekran, bagimli oldugu - // tanimlardan once uygulanirdi. - if (isNew) - seedFile.Order = await ReadFolderOrderAsync(target); - - await WriteSeedFileAsync(filePath, seedFile); - } - - /// - /// Klasordeki satir dosyalarinin tasidigi uygulama sirasi (Order); bir ekranin butun satir - /// dosyalarinda ayni degerdir, bu yuzden ilk kardes dosyadan okunur. - /// - private static async Task ReadFolderOrderAsync(SeedTarget target) - { - var sibling = Directory.EnumerateFiles(target.Path, "*.json").FirstOrDefault(); - if (sibling == null) - return 0; - - try - { - var seedFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(sibling), - ListFormSeedDataFileDto.JsonOptions); - - return seedFile?.Order ?? 0; - } - catch (JsonException) - { - return 0; - } - } - - private static string? ResolveRowFilePath(SeedTarget target, object key) - { - var fileName = SeedPathResolver.GetRowFileName(ToComparableText(key)); - - return fileName == null ? null : Path.Combine(target.Path, fileName); - } - /// /// Seed dosyasi bagli degilse ya da islemin bayragi kapaliysa hicbir sey yapmaz. /// Senkronizasyon hatasi kullanicinin kayit islemini bozmaz; yalnizca loglanir. @@ -376,7 +199,7 @@ public class ListFormSeedDataSynchronizer( private Task ExecuteAsync( string listFormCode, Func isEnabled, - Func action) + Func action) { if (string.IsNullOrWhiteSpace(listFormCode)) return Task.CompletedTask; @@ -401,8 +224,7 @@ public class ListFormSeedDataSynchronizer( private async Task RunAsync( string listFormCode, Func isEnabled, - Func action, - bool createTarget = true) + Func action) { // Seed dosyalari urunun varsayilanidir ve host ile kendi veritabani olan her tenant'a kurulur; // bir tenant'in kaydi dosyaya yazilsaydi o tenant'in verisi butun veritabanlarina yayilirdi. @@ -412,18 +234,19 @@ public class ListFormSeedDataSynchronizer( try { var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); - if (listForm == null || string.IsNullOrWhiteSpace(listForm.SeedFilePath) || !isEnabled(listForm)) + if (listForm == null || !isEnabled(listForm)) return; - var isFolder = SeedPathResolver.IsSeedFolderPath(listForm.SeedFilePath); + var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm.SeedFilePath); + if (target == null) + { + if (!string.IsNullOrWhiteSpace(listForm.SeedFilePath)) + logger.LogWarning("Seed file path '{SeedFilePath}' of '{ListFormCode}' is not under the data folder, it is not synchronized.", listForm.SeedFilePath, listFormCode); - var fullPath = SeedPathResolver.GetPath(configuration, listForm.SeedFilePath.Replace('\\', '/').Trim('/').Split('/')); + return; + } - // Klasor ilk kayitta olusur; ekran, hedefi diskte hazir bulmak zorunda degildir. - if (createTarget) - Directory.CreateDirectory(isFolder ? fullPath : Path.GetDirectoryName(fullPath)!); - - await action(listForm, new SeedTarget(fullPath, isFolder)); + await action(listForm, target); } catch (Exception ex) { @@ -468,44 +291,4 @@ public class ListFormSeedDataSynchronizer( return rows; } - - private static int FindRowIndex(ListFormSeedDataFileDto seedFile, string keyFieldName, object key) - { - var keyText = ToComparableText(key); - - return seedFile.Rows.FindIndex(row => - row.TryGetValue(keyFieldName, out var value) && - string.Equals(ToComparableText(value), keyText, StringComparison.OrdinalIgnoreCase)); - } - - /// Anahtarlar dosyada JsonElement, istekte string/Guid olarak gelir; metin uzerinden karsilastirilir. - private static string ToComparableText(object? value) - { - return value switch - { - null => string.Empty, - JsonElement { ValueKind: JsonValueKind.String } element => element.GetString() ?? string.Empty, - JsonElement element => element.ToString(), - _ => value.ToString() ?? string.Empty - }; - } - - private static async Task ReadSeedFileAsync(string filePath, ListForm listForm) - { - var seedFile = File.Exists(filePath) - ? JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath), - ListFormSeedDataFileDto.JsonOptions) - : null; - - seedFile ??= new ListFormSeedDataFileDto(); - seedFile.ListFormCode = listForm.ListFormCode; - seedFile.KeyFieldName = listForm.KeyFieldName; - seedFile.Rows ??= []; - - return seedFile; - } - - private static Task WriteSeedFileAsync(string filePath, ListFormSeedDataFileDto seedFile) => - File.WriteAllTextAsync(filePath, ListFormSeedDataFileDto.Serialize(seedFile)); } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index a61ef4c1..d6af71b3 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -122,7 +122,7 @@ public class ListFormWizardAppService( if (previous?.Wizard != null) input.SeededAt = previous.Wizard.SeededAt; - var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot()); + var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot(), await ReadSeedFilePathsAsync()); await BindExistingMenuParentsAsync(input, rows, previous); // Ad ve kod catismalari, dosyalar yazilmaya baslamadan once reddedilir; aksi halde yarim @@ -320,6 +320,11 @@ public class ListFormWizardAppService( listFormDto = input.BuildListForm(isDeleted, isCreated); fieldDtos = input.BuildFields(); + // Veri aynasinin yeri ekranin tanimidir; tasarimcida klasore cevrilmis bir hedef + // duzenlemede varsayilan tek dosyaya geri donmez. + if (input.SeedData && !string.IsNullOrWhiteSpace(previous?.ListForm?.SeedFilePath)) + listFormDto.SeedFilePath = previous.ListForm.SeedFilePath; + foreach (var item in input.Groups.SelectMany(g => g.Items)) await WriteLanguageRowsAsync(rows, item.CaptionName, item.EnglishCaption, item.TurkishCaption, owned, inserted); } @@ -1034,7 +1039,7 @@ public class ListFormWizardAppService( } var componentDependencies = fileByComponent.Count > 0 - ? await new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration)) + ? await new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration), await ReadSeedFilePathsAsync()) .ReadCustomComponentDependenciesAsync() : []; @@ -1396,7 +1401,7 @@ public class ListFormWizardAppService( /// private async Task> RemovePendingSeedRowsAsync(WizardSeedFileDto seed) { - var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot()); + var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot(), await ReadSeedFilePathsAsync()); var owned = (seed.InsertedRecords ?? new WizardInsertedRecordsDto()).Union(seed.Pending?.Replaced); var usages = new Dictionary<(WizardRecordTypeEnum, string), List>(); @@ -1585,6 +1590,19 @@ public class ListFormWizardAppService( } /// Seed klasorunun tam yolu. + /// + /// Wizard'in satir yazdigi ve export/import'un okudugu paylasilan ekranlarin seed hedefleri + /// (ListFormCode → SeedFilePath). Hedefin tek dosya mi klasor mu oldugu yalnizca bu + /// tanimdan gelir; diskten tahmin edilmez. + /// + private async Task> ReadSeedFilePathsAsync() + { + string[] codes = [WizardExportCollector.CustomComponentsListFormCode, .. WizardSeedRowStore.ListFormCodes]; + var listForms = await repoListForm.GetListAsync(a => codes.Contains(a.ListFormCode)); + + return listForms.ToDictionary(a => a.ListFormCode, a => (string?)a.SeedFilePath, StringComparer.OrdinalIgnoreCase); + } + private string ResolveScopeRoot() { return Path.GetFullPath(SeedPathResolver.GetRootPath(_configuration)); @@ -1717,7 +1735,7 @@ public class ListFormWizardAppService( var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath)) ?? throw new UserFriendlyException("Dosya okunamadı."); - var collector = new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration)); + var collector = new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration), await ReadSeedFilePathsAsync()); var items = await collector.CollectAsync([filePath]); return (items, collector.Warnings, ArchiveName(seed.Wizard?.WizardName ?? Path.GetFileNameWithoutExtension(fileName), "Wizard")); @@ -1735,7 +1753,7 @@ public class ListFormWizardAppService( var title = devRequest?.Title ?? files[0].Seed!.DevRequest.Title; var fileNames = files.Select(f => f.FileName).ToList(); - var collector = new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration)); + var collector = new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration), await ReadSeedFilePathsAsync()); var items = await collector.CollectAsync([.. fileNames.Select(ResolveWizardSeedFilePath)]); return (items, collector.Warnings, ArchiveName(title, "DevRequest"), fileNames); @@ -2012,7 +2030,8 @@ public class ListFormWizardAppService( DatabaseObjectExistsAsync, ReadTableColumnsAsync, TableHasRowsAsync, - listFormCode => repoListForm.AnyAsync(a => a.ListFormCode == listFormCode)); + listFormCode => repoListForm.AnyAsync(a => a.ListFormCode == listFormCode), + await ReadSeedFilePathsAsync()); return await validator.ValidateAsync(stagedEntries); } @@ -2301,9 +2320,13 @@ public class ListFormWizardAppService( } // 4) Veri satirlari. - var wizardRowFiles = WizardSeedRowStore.ListFormCodes.Select(SeedPathResolver.GetDataFilePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + var seedFilePaths = await ReadSeedFilePathsAsync(); + var wizardRowTargets = WizardSeedRowStore.ListFormCodes + .Select(code => ListFormSeedTarget.Create(scopeRoot, seedFilePaths.GetValueOrDefault(code))) + .OfType() + .ToList(); var dataFiles = (await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json")) - .Where(path => !wizardRowFiles.Contains(path)) + .Where(path => !wizardRowTargets.Any(target => target.Contains(path))) .ToList(); foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true)) { diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs index fc05569e..252eaae7 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs @@ -30,8 +30,13 @@ public sealed record WizardExportItem(string Path, string Reason, string? FullPa /// Bulunamayan bagimliliklar sessizce atlanmaz; uzerinden cagirana tasinir ve /// indirmeden once kullaniciya gosterilir. /// +/// +/// Paylasilan seed hedeflerinin (custom component'ler, menu/yetki/dil satirlari) dosya mi klasor +/// mu oldugu ekran tanimlarindan gelir (seedFilePaths: ListFormCode → SeedFilePath); +/// diskten tahmin edilmez. +/// /// -public sealed class WizardExportCollector(string scopeRoot) +public sealed class WizardExportCollector(string scopeRoot, IReadOnlyDictionary seedFilePaths) { /// Custom component'lerin seed kaynagi olan ListForm ekrani. public const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents; @@ -281,30 +286,10 @@ public sealed class WizardExportCollector(string scopeRoot) if (customComponents.Count == 0) return; - foreach (var (relativePath, seedFile) in await ReadCustomComponentFilesAsync()) - { - // Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla birlestirilir. - var rows = seedFile.Rows - .Where(x => customComponents.Contains(GetRowText(x, CustomComponentNameField) ?? string.Empty)) - .ToList(); - - // Satir basina dosya tutan klasorde ilgisiz bilesenin dosyasi arsive hic girmez. - if (rows.Count == 0) - continue; - - var filtered = new ListFormSeedDataFileDto - { - ListFormCode = seedFile.ListFormCode, - KeyFieldName = seedFile.KeyFieldName, - GeneratedAt = seedFile.GeneratedAt, - Order = seedFile.Order, - Rows = rows, - }; - - var content = ListFormSeedDataFileDto.Serialize(filtered); - - items[relativePath] = new WizardExportItem(relativePath, "Custom component tanımı", null, content); - } + AddFilteredRows( + await ReadCustomComponentFilesAsync(), + row => customComponents.Contains(GetRowText(row, CustomComponentNameField) ?? string.Empty), + "Custom component tanımı"); } /// @@ -326,17 +311,31 @@ public sealed class WizardExportCollector(string scopeRoot) foreach (var (listFormCode, field, keys) in targets) { - var relativePath = SeedPathResolver.GetDataFilePath(listFormCode); - var fullPath = ResolveScopeFilePath(relativePath); - if (fullPath == null || keys.Count == 0) + if (keys.Count == 0) continue; - var seedFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(fullPath), ListFormSeedDataFileDto.JsonOptions); - var wanted = keys.ToHashSet(StringComparer.OrdinalIgnoreCase); - var rows = seedFile?.Rows?.Where(x => wanted.Contains(GetRowText(x, field) ?? string.Empty)).ToList() ?? []; - if (seedFile == null || rows.Count == 0) + AddFilteredRows( + await ReadSeedFilesAsync(listFormCode), + row => wanted.Contains(GetRowText(row, field) ?? string.Empty), + "Menü, yetki ve dil satırları"); + } + } + + /// + /// Paylasilan bir seed hedefinin dosyalarini yalnizca satirlarina + /// indirgeyerek arsive ekler. Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla + /// birlesir. Satir basina dosya tutan klasorde ilgisiz satirin dosyasi arsive hic girmez. + /// + private void AddFilteredRows( + IEnumerable<(string Path, ListFormSeedDataFileDto File)> files, + Func, bool> keep, + string reason) + { + foreach (var (relativePath, seedFile) in files) + { + var rows = seedFile.Rows.Where(keep).ToList(); + if (rows.Count == 0) continue; var filtered = new ListFormSeedDataFileDto @@ -348,8 +347,7 @@ public sealed class WizardExportCollector(string scopeRoot) Rows = rows, }; - items[relativePath] = new WizardExportItem( - relativePath, "Menü, yetki ve dil satırları", null, ListFormSeedDataFileDto.Serialize(filtered)); + items[relativePath] = new WizardExportItem(relativePath, reason, null, ListFormSeedDataFileDto.Serialize(filtered)); } } @@ -389,45 +387,41 @@ public sealed class WizardExportCollector(string scopeRoot) return rows; } + /// Custom component seed kaynagi; ekranin tanimindaki hedeften bir kez okunur. + private async Task> ReadCustomComponentFilesAsync() => + customComponentFiles ??= await ReadSeedFilesAsync(CustomComponentsListFormCode); + /// - /// Custom component seed kaynagi. Ekranin SeedFilePath degeri burada bilinmedigi icin - /// kaynak diskten cozulur: satir basina dosya tutan klasor varsa oradaki dosyalar, yoksa tek - /// dosya okunur. + /// Ekranin tanimladigi seed hedefindeki dosyalar (kapsam-goreli yol + icerik). Tanim yoksa ya da + /// hedef diskte yoksa uyari uretilir; okunamayan dosya atlanir ve uyarisi tasinir. /// - private async Task> ReadCustomComponentFilesAsync() + private async Task> ReadSeedFilesAsync(string listFormCode) { - if (customComponentFiles != null) - return customComponentFiles; - - customComponentFiles = []; - - var folderPath = SeedPathResolver.GetDataFolderPath(CustomComponentsListFormCode); - var relativePaths = ResolveScopeFolderPath(folderPath) == null - ? [SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode)] - : EnumerateSeedFolderFiles(folderPath).ToList(); - - foreach (var relativePath in relativePaths) + var target = ListFormSeedTarget.Create(scopeRoot, seedFilePaths.GetValueOrDefault(listFormCode)); + if (target == null || !target.Exists) { - var sourcePath = ResolveScopeFilePath(relativePath); - if (sourcePath == null) - { - warnings.Add($"{relativePath}: custom component seed dosyası bulunamadı."); - continue; - } + warnings.Add($"{listFormCode}: ekranın veri seed hedefi ({target?.RelativePath ?? "tanımsız"}) bulunamadı."); + return []; + } + var files = new List<(string, ListFormSeedDataFileDto)>(); + + foreach (var relativePath in target.GetRelativeFiles()) + { try { - var file = JsonSerializer.Deserialize(await File.ReadAllTextAsync(sourcePath), JsonReadOptions); + var file = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(ResolveScopeFilePath(relativePath)!), ListFormSeedDataFileDto.JsonOptions); if (file != null) - customComponentFiles.Add((relativePath, file)); + files.Add((relativePath, file)); } catch (JsonException) { - warnings.Add($"{relativePath}: custom component seed dosyası çözülemedi."); + warnings.Add($"{relativePath}: seed dosyası çözülemedi."); } } - return customComponentFiles; + return files; } // ── Dosya toplama ────────────────────────────────────────────────────────────── @@ -512,37 +506,19 @@ public sealed class WizardExportCollector(string scopeRoot) } /// - /// Ekranin veri seed hedefini arsive ekler. Hedef klasorse (satir basina dosya) klasordeki butun - /// satir dosyalari girer; tek dosyaysa o dosya eklenir. Hicbir sey eklenemezse false doner. + /// Ekranin veri seed hedefini (tanima gore tek dosya ya da satir klasorundeki butun dosyalar) + /// arsive ekler. Hicbir sey eklenemezse false doner. /// - private bool AddSeedData(string? relativePath, string reason) + private bool AddSeedData(string? seedFilePath, string reason) { - if (!SeedPathResolver.IsSeedFolderPath(relativePath)) - return AddDiskFile(relativePath, reason); - var added = false; - foreach (var rowPath in EnumerateSeedFolderFiles(relativePath!)) - added |= AddDiskFile(rowPath, reason); + foreach (var relativePath in ListFormSeedTarget.Create(scopeRoot, seedFilePath)?.GetRelativeFiles() ?? []) + added |= AddDiskFile(relativePath, reason); return added; } - /// Kapsam-goreli bir seed klasorundeki satir dosyalarinin kapsam-goreli yollari. - private IEnumerable EnumerateSeedFolderFiles(string relativeFolderPath) - { - var normalized = relativeFolderPath.Replace('\\', '/').Trim('/'); - var fullPath = ResolveScopeFolderPath(normalized); - - if (fullPath == null) - return []; - - return Directory - .EnumerateFiles(fullPath, "*.json") - .Select(x => $"{normalized}/{System.IO.Path.GetFileName(x)}") - .OrderBy(x => x, StringComparer.OrdinalIgnoreCase); - } - /// Kapsam icindeki var olan bir dosyayi arsive ekler; kapsam disina cikan ya da bulunmayan yol icin false doner. private bool AddDiskFile(string? relativePath, string reason) { @@ -575,21 +551,6 @@ public sealed class WizardExportCollector(string scopeRoot) return File.Exists(fullPath) ? fullPath : null; } - /// Kapsam icindeki var olan bir klasorun tam yolu; kapsam disina cikan yol icin null. - private string? ResolveScopeFolderPath(string? relativePath) - { - if (string.IsNullOrWhiteSpace(relativePath) || relativePath.Contains("..", StringComparison.Ordinal)) - return null; - - var fullPath = System.IO.Path.GetFullPath( - System.IO.Path.Combine(scopeRoot, relativePath.Replace('/', System.IO.Path.DirectorySeparatorChar))); - - if (!fullPath.StartsWith(scopeRoot + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - return null; - - return Directory.Exists(fullPath) ? fullPath : null; - } - private string? ResolveWizardFileByListFormCode(string listFormCode) { wizardFilesByListFormCode ??= BuildWizardIndex(scopeRoot); diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs index 6677bd4a..93d048f9 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs @@ -35,7 +35,8 @@ public sealed class WizardImportValidator( Func> databaseObjectExistsAsync, Func?>> tableColumnsAsync, Func> tableHasRowsAsync, - Func> listFormExistsAsync) + Func> listFormExistsAsync, + IReadOnlyDictionary seedFilePaths) { private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true }; @@ -206,9 +207,9 @@ public sealed class WizardImportValidator( } var seedFilePath = seed.ListForm.SeedFilePath; + var seedTarget = ListFormSeedTarget.Create(scopeRoot, seedFilePath); if (!string.IsNullOrWhiteSpace(seedFilePath) - && !SeedDataIncoming(seedFilePath, paths) - && !ScopeSeedDataExists(seedFilePath)) + && (seedTarget == null || (!paths.Any(seedTarget.Contains) && !seedTarget.Exists))) { Add(WizardImportIssueSeverityEnum.Warning, path, $"{screen}: ekranın veri dosyası ({seedFilePath}) ne zip'te ne hedefte var; ekran boş gelecek."); @@ -486,7 +487,10 @@ public sealed class WizardImportValidator( var names = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var fullPath in ReadTargetCustomComponentFiles()) + // Hedefteki bilesenlerin yeri ekranin tanimindan gelir (tek dosya ya da satir klasoru). + var target = ListFormSeedTarget.Create(scopeRoot, seedFilePaths.GetValueOrDefault(WizardExportCollector.CustomComponentsListFormCode)); + + foreach (var fullPath in target?.GetFiles() ?? []) { try { @@ -507,26 +511,6 @@ public sealed class WizardImportValidator( return targetCustomComponents = names; } - /// - /// Hedefteki custom component seed dosyalari: satir basina dosya tutan klasor varsa onun - /// icindekiler, yoksa tek dosya. - /// - private List ReadTargetCustomComponentFiles() - { - var folderPath = System.IO.Path.Combine( - scopeRoot, - SeedPathResolver.GetDataFolderPath(WizardExportCollector.CustomComponentsListFormCode) - .Replace('/', System.IO.Path.DirectorySeparatorChar)); - - if (Directory.Exists(folderPath)) - return [.. Directory.EnumerateFiles(folderPath, "*.json")]; - - var filePath = ResolveScopeFilePath( - SeedPathResolver.GetDataFilePath(WizardExportCollector.CustomComponentsListFormCode)); - - return filePath == null ? [] : [filePath]; - } - private SubFormDto[] ReadSubForms(WizardSeedFileDto seed, string path) { try @@ -579,36 +563,6 @@ public sealed class WizardImportValidator( private bool ScopeFileExists(string? relativePath) => ResolveScopeFilePath(relativePath) != null; - /// - /// Ekranin veri seed hedefi hedef ortamda var mi. Hedef klasorse (satir basina dosya) klasorun - /// kendisine bakilir; tek dosyaysa dosyaya. - /// - private bool ScopeSeedDataExists(string? relativePath) - { - if (!SeedPathResolver.IsSeedFolderPath(relativePath)) - return ScopeFileExists(relativePath); - - var normalized = relativePath!.Replace('\\', '/').Trim('/'); - if (normalized.Contains("..", StringComparison.Ordinal)) - return false; - - var fullPath = System.IO.Path.GetFullPath( - System.IO.Path.Combine(scopeRoot, normalized.Replace('/', System.IO.Path.DirectorySeparatorChar))); - - return fullPath.StartsWith(scopeRoot + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - && Directory.Exists(fullPath); - } - - /// Ekranin veri seed hedefi zip'te var mi; klasor modunda klasorun altindaki satir dosyalari aranir. - private static bool SeedDataIncoming(string relativePath, HashSet paths) - { - var normalized = relativePath.Replace('\\', '/').Trim('/'); - - return SeedPathResolver.IsSeedFolderPath(normalized) - ? paths.Any(x => x.StartsWith(normalized + "/", StringComparison.OrdinalIgnoreCase)) - : paths.Contains(normalized); - } - private string? ResolveScopeFilePath(string? relativePath) { var normalized = relativePath?.Replace('\\', '/').Trim('/'); diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs index a0c74386..75b9da5f 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -182,32 +181,12 @@ public class WizardRecordCleaner( /// public void DeleteSeedDataMirror(string? seedFilePath, List report) { - var relativePath = seedFilePath?.Replace('\\', '/').Trim('/'); - if (string.IsNullOrWhiteSpace(relativePath) || - relativePath.Contains("..", StringComparison.Ordinal) || - !relativePath.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase)) - { + var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), seedFilePath); + if (target == null || !target.Delete()) return; - } - var path = SeedPathResolver.GetPath(configuration, relativePath.Split('/')); - if (SeedPathResolver.IsSeedFolderPath(relativePath)) - { - if (!Directory.Exists(path)) - return; - - Directory.Delete(path, recursive: true); - } - else - { - if (!File.Exists(path)) - return; - - File.Delete(path); - } - - Add(report, WizardRecordTypeEnum.SeedFile, relativePath, WizardDeleteStatusEnum.Deleted); - logger.LogInformation("Wizard seed data mirror deleted: {Path}", path); + Add(report, WizardRecordTypeEnum.SeedFile, target.RelativePath, WizardDeleteStatusEnum.Deleted); + logger.LogInformation("Wizard seed data mirror deleted: {Path}", target.FullPath); } private static string FieldName(ListFormField field) => $"{field.ListFormCode}.{field.FieldName}"; diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs index f6f4fb3e..af87ce32 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Text.Json; using System.Threading.Tasks; using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Enums; +using Volo.Abp; namespace Sozsoft.Platform.ListForms; @@ -41,30 +40,36 @@ public sealed class WizardSeedRowStore /// Wizard'in satir yazdigi ekran kodlari; import bu dosyalari veritabanina uygulamaz. public static IReadOnlyList ListFormCodes { get; } = [.. Specs.Select(s => s.ListFormCode)]; - private readonly Dictionary files = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary files = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet dirty = new(StringComparer.OrdinalIgnoreCase); private WizardSeedRowStore() { } - /// Seed klasorundeki dosyalari okur; olmayan dosya bos olarak baslar ve ilk yazimda olusur. - public static async Task LoadAsync(string scopeRoot) + /// + /// Ekranlarin tanimladigi seed hedeflerini (: ListFormCode → + /// SeedFilePath) okur; tek dosya ya da satir klasoru olmasi tanimdan gelir. Olmayan hedef bos + /// baslar ve ilk yazimda olusur. Hedefi tanimsiz bir ekran deploy'u durdurur: satirlar yazilacak + /// yer bilinmeden dosyaya dusurulmez. + /// + public static async Task LoadAsync(string scopeRoot, IReadOnlyDictionary seedFilePaths) { var store = new WizardSeedRowStore(); foreach (var spec in Specs) { - var path = Path.Combine(scopeRoot, SeedPathResolver.GetDataFilePath(spec.ListFormCode).Replace('/', Path.DirectorySeparatorChar)); + var target = ListFormSeedTarget.Create(scopeRoot, seedFilePaths.GetValueOrDefault(spec.ListFormCode)) + ?? throw new UserFriendlyException($"'{spec.ListFormCode}' ekranının veri seed hedefi (SeedFilePath) tanımlı değil."); - var file = File.Exists(path) - ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) - : null; + var file = new ListFormSeedDataFileDto + { + ListFormCode = spec.ListFormCode, + KeyFieldName = spec.KeyFieldName, + Rows = await target.ReadRowsAsync(), + }; - file ??= new ListFormSeedDataFileDto { ListFormCode = spec.ListFormCode, KeyFieldName = spec.KeyFieldName, Order = spec.Order }; - file.Rows ??= []; - - store.files[spec.ListFormCode] = (path, file); + store.files[spec.ListFormCode] = (target, spec, file); } return store; @@ -207,10 +212,9 @@ public sealed class WizardSeedRowStore { foreach (var listFormCode in dirty) { - var (path, file) = files[listFormCode]; + var (target, spec, file) = files[listFormCode]; - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(file)); + await target.ReplaceRowsAsync(spec.ListFormCode, spec.KeyFieldName, file.Rows, spec.Order); } dirty.Clear(); diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardTableCleaner.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardTableCleaner.cs index 80921bdc..e2db9590 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardTableCleaner.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardTableCleaner.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -183,7 +182,7 @@ public class WizardTableCleaner( /// /// Talep silmesinde talebin tablolarini sorgulayan Custom Endpoint'leri veritabanindan ve - /// data/App.DeveloperKit.CustomEndpoints/ klasorundeki satir dosyalarindan siler. + /// ekranin veri seed hedefinden (tanima gore tek dosya ya da satir klasoru) siler. /// public async Task DeleteCustomEndpointsAsync(IReadOnlyCollection names, List report) { @@ -211,28 +210,21 @@ public class WizardTableCleaner( /// Henuz migrate edilmemis, yalnizca seed dosyasinda duran ve tabloyu sorgulayan endpoint'ler. private async Task> FindSeedEndpointsAsync(WizardDeleteTableDto table) { - // Ekran klasor modundadir: her endpoint data/App.DeveloperKit.CustomEndpoints/{Id}.json dosyasindadir. - var folder = ScopeFile(SeedPathResolver.GetDataFolderPath(CustomEndpointsListFormCode)); - if (!Directory.Exists(folder)) + // Endpoint satirlarinin yeri ekranin tanimindan gelir (tek dosya ya da satir klasoru). + var listForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == CustomEndpointsListFormCode); + var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath); + if (target == null) return []; var pattern = TablePattern(table.TableName); - var names = new List(); - string? Text(Dictionary row, string field) => - row.FirstOrDefault(kv => string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)).Value?.ToString(); + static string? Text(Dictionary row, string field) => WizardExportCollector.GetRowText(row, field); - foreach (var path in Directory.EnumerateFiles(folder, "*.json")) - { - var file = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions); - names.AddRange((file?.Rows ?? []) - .Where(row => string.Equals(Text(row, nameof(CustomEndpoint.DataSourceCode)), table.DataSourceCode, StringComparison.OrdinalIgnoreCase) && - Text(row, nameof(CustomEndpoint.Sql)) is { } sql && pattern.IsMatch(sql)) - .Select(row => Text(row, nameof(CustomEndpoint.Name))) - .OfType()); - } - - return names; + return [.. (await target.ReadRowsAsync()) + .Where(row => string.Equals(Text(row, nameof(CustomEndpoint.DataSourceCode)), table.DataSourceCode, StringComparison.OrdinalIgnoreCase) && + Text(row, nameof(CustomEndpoint.Sql)) is { } sql && pattern.IsMatch(sql)) + .Select(row => Text(row, nameof(CustomEndpoint.Name))) + .OfType()]; } private async Task ReadTableStateAsync(WizardDeleteTableDto table) diff --git a/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataApplier.cs b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataApplier.cs index 554600da..d86a084a 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataApplier.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataApplier.cs @@ -151,8 +151,6 @@ public class ListFormSeedDataApplier( return; } - await MigrateSeedFilePathsAsync(tenantId, dataPath); - // Alt klasorler de taranir: satir basina dosya tutan ekranin verisi // data/{ListFormCode}/{anahtar}.json altindadir. var jsonFiles = Directory.GetFiles(dataPath, "*.json", SearchOption.AllDirectories); @@ -205,41 +203,6 @@ public class ListFormSeedDataApplier( .Select(a => a.Target)]; } - /// - /// Tek dosyaya bagli bir ekranin seed hedefini, kapsamda o ekranin satir klasoru varsa klasore - /// tasir. Bir ekran klasor moduna gecirildiginde var olan kurulumlarin kaydi artik uretilmeyen - /// eski dosyayi gosterir; klasorun diskte olmasi gecisin tek isaretidir. Elle verilmis bir yola - /// dokunulmaz: yalnizca eski varsayilan (data/{ListFormCode}.json) degerler tasinir. - /// - private async Task MigrateSeedFilePathsAsync(Guid? tenantId, string dataPath) - { - using (currentTenant.Change(tenantId)) - { - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - - var listForms = await listFormRepository.GetListAsync(a => a.SeedFilePath != null && a.SeedFilePath != ""); - - foreach (var listForm in listForms) - { - var filePath = SeedPathResolver.GetDataFilePath(listForm.ListFormCode); - if (!string.Equals(listForm.SeedFilePath?.Replace('\\', '/'), filePath, StringComparison.OrdinalIgnoreCase)) - continue; - - if (!Directory.Exists(Path.Combine(dataPath, listForm.ListFormCode))) - continue; - - listForm.SeedFilePath = SeedPathResolver.GetDataFolderPath(listForm.ListFormCode); - await listFormRepository.UpdateAsync(listForm); - - logger.LogInformation( - "[{ListFormCode}] Seed file path migrated to the row folder '{SeedFilePath}'.", - listForm.ListFormCode, listForm.SeedFilePath); - } - - await uow.CompleteAsync(); - } - } - /// Dosya, bir ekranin satir klasorunun (data/{ListFormCode}/) altinda mi. private static bool IsRowFile(string dataPath, string filePath) { @@ -307,6 +270,16 @@ public class ListFormSeedDataApplier( return new ListFormSeedDataApplyResult(target.Path, 0, 0, 0, $"ListForm '{listFormCode}' not found."); } + // Ekran hedefini tanimliyorsa yalnizca o hedef uygulanir: tanimin disinda kalan dosya + // (bicimi degismis ekranin eski dosyasi) artik ekrana ait degildir ve satirlari kopya + // ya da bayat kayit uretir. + if (!string.IsNullOrWhiteSpace(listForm.SeedFilePath) && + !string.Equals(listForm.SeedFilePath.Replace('\\', '/').Trim('/'), target.Path, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("[{Target}] ListForm '{ListFormCode}' defines its seed target as '{SeedFilePath}', skipped.", target.Path, listFormCode, listForm.SeedFilePath); + return new ListFormSeedDataApplyResult(target.Path, 0, 0, 0, $"ListForm '{listFormCode}' seed target is '{listForm.SeedFilePath}'."); + } + var fields = await listFormFieldRepository.GetListAsync(a => a.ListFormCode == listFormCode); // Anahtar alan ekranin butun satir dosyalarinda aynidir; dosyada yoksa ekrandan gelir. diff --git a/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedTarget.cs b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedTarget.cs new file mode 100644 index 00000000..794976a8 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedTarget.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Bir ekranin veri seed hedefi. Bicim yalnizca tanimdan (ListForm.SeedFilePath) gelir: +/// .json ile biten deger butun satirlari tasiyan tek dosyadir, uzantisiz deger her satiri +/// {anahtar}.json olarak tutan klasordur. Diskte ne bulundugu bu karari degistirmez; ayni +/// ekranin hem dosyasi hem klasoru varsa okunan, tanimin gosterdigidir. +/// +/// Hedefi okuyan ve yazan her yol (senkronizasyon, export/import, temizleme, MCP, seeder) bu sinifi +/// kullanir; dosya/klasor ayrimi ikinci bir yerde yazilmaz. +/// +/// +public sealed class ListFormSeedTarget +{ + private readonly string scopeRoot; + + private ListFormSeedTarget(string scopeRoot, string relativePath, bool isFolder) + { + this.scopeRoot = scopeRoot; + RelativePath = relativePath; + IsFolder = isFolder; + FullPath = Path.Combine(scopeRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// Seed kokune gore goreli, / ayracli yol. + public string RelativePath { get; } + + public string FullPath { get; } + + public bool IsFolder { get; } + + public bool Exists => IsFolder ? Directory.Exists(FullPath) : File.Exists(FullPath); + + /// + /// Tanimi kapsamina baglar. Tanim bossa, data/ disini + /// gosteriyorsa ya da kapsamdan cikiyorsa null doner: veri aynasi yalnizca data/ altinda yasar. + /// + public static ListFormSeedTarget? Create(string scopeRoot, string? seedFilePath) + { + var relativePath = seedFilePath?.Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(relativePath) || + relativePath.Contains("..", StringComparison.Ordinal) || + !relativePath.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var root = Path.GetFullPath(scopeRoot); + + return new ListFormSeedTarget(root, relativePath, SeedPathResolver.IsSeedFolderPath(relativePath)); + } + + /// Dosya yolu hedefin kendisi ya da klasor modunda onun satir dosyasi mi. + public bool Contains(string relativePath) + { + var normalized = relativePath.Replace('\\', '/').Trim('/'); + + return IsFolder + ? normalized.StartsWith(RelativePath + "/", StringComparison.OrdinalIgnoreCase) + : string.Equals(normalized, RelativePath, StringComparison.OrdinalIgnoreCase); + } + + /// Hedefte diskte bulunan dosyalarin tam yollari; klasor modunda satir dosyalari ada gore sirali. + public IReadOnlyList GetFiles() + { + if (!IsFolder) + return File.Exists(FullPath) ? [FullPath] : []; + + return Directory.Exists(FullPath) + ? [.. Directory.EnumerateFiles(FullPath, "*.json").OrderBy(path => path, StringComparer.OrdinalIgnoreCase)] + : []; + } + + /// sonucunun seed kokune gore goreli, / ayracli hali. + public IReadOnlyList GetRelativeFiles() => + [.. GetFiles().Select(path => Path.GetRelativePath(scopeRoot, path).Replace('\\', '/'))]; + + /// Hedefteki dosyalari okur; okunamayan dosyada cagirana gider. + public async Task> ReadFilesAsync() + { + var files = new List<(string, ListFormSeedDataFileDto)>(); + + foreach (var path in GetFiles()) + { + var file = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions); + if (file != null) + files.Add((path, file)); + } + + return files; + } + + /// Hedefteki butun satirlar; klasor modunda dosya sirasiyla birlestirilir. + public async Task>> ReadRowsAsync() => + [.. (await ReadFilesAsync()).SelectMany(file => file.File.Rows ?? [])]; + + /// Klasor modunda satirin dosya yolu; anahtar dosya adina cevrilemezse null. + public string? GetRowFilePath(object? key) + { + var fileName = SeedPathResolver.GetRowFileName(KeyText(key)); + + return fileName == null ? null : Path.Combine(FullPath, fileName); + } + + /// + /// Satirlari hedefe yazar: anahtari eslesen satirin yerine gecer, yoksa eklenir. Tek dosya bir kez + /// okunup bir kez yazilir; klasor modunda yalnizca ilgili satirin dosyasina dokunulur. Anahtari dosya + /// adina cevrilemeyen satirlar yazilmaz ve anahtarlari dondurulur. + /// + public async Task> UpsertRowsAsync( + string listFormCode, + string keyFieldName, + IEnumerable<(object Key, Dictionary Row)> rows) + { + var skipped = new List(); + + if (IsFolder) + { + Directory.CreateDirectory(FullPath); + + foreach (var (key, row) in rows) + { + var filePath = GetRowFilePath(key); + if (filePath == null) + { + skipped.Add(key); + continue; + } + + var isNew = !File.Exists(filePath); + var seedFile = await ReadFileAsync(filePath, listFormCode, keyFieldName); + seedFile.Rows = [row]; + + // Yeni dosya uygulama sirasini kardeslerinden alir; 0 ile yazilsaydi ekran, bagimli + // oldugu tanimlardan once uygulanirdi. + if (isNew) + seedFile.Order = await ReadFolderOrderAsync() ?? 0; + + await WriteFileAsync(filePath, seedFile); + } + + return skipped; + } + + var file = await ReadFileAsync(FullPath, listFormCode, keyFieldName); + + foreach (var (key, row) in rows) + { + var index = FindRowIndex(file.Rows, keyFieldName, key); + if (index >= 0) + file.Rows[index] = row; + else + file.Rows.Add(row); + } + + await WriteFileAsync(FullPath, file); + + return skipped; + } + + /// + /// Hedefi verilen satirlarla bastan yazar; klasor modunda listede kalmayan satirlarin dosyalari + /// silinir — hedef listenin aynasidir. Tek dosyada uygulama sirasi dosyanin kendi bilgisidir ve + /// korunur; hedef henuz yoksa yazilir. Anahtari dosya adina + /// cevrilemeyen satirlarin sayisini dondurur. + /// + public async Task ReplaceRowsAsync(string listFormCode, string keyFieldName, List> rows, int defaultOrder = 0) + { + if (!IsFolder) + { + var isNew = !File.Exists(FullPath); + var file = await ReadFileAsync(FullPath, listFormCode, keyFieldName); + file.Rows = rows; + if (isNew) + file.Order = defaultOrder; + await WriteFileAsync(FullPath, file); + + return 0; + } + + Directory.CreateDirectory(FullPath); + + var order = await ReadFolderOrderAsync() ?? defaultOrder; + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + var skipped = 0; + + foreach (var row in rows) + { + var filePath = GetRowFilePath(row.GetValueOrDefault(keyFieldName)); + if (filePath == null) + { + skipped++; + continue; + } + + var seedFile = await ReadFileAsync(filePath, listFormCode, keyFieldName); + seedFile.Rows = [row]; + seedFile.Order = order; + + await WriteFileAsync(filePath, seedFile); + written.Add(filePath); + } + + foreach (var filePath in GetFiles().Where(path => !written.Contains(path))) + File.Delete(filePath); + + return skipped; + } + + /// Anahtari verilen satirlari hedeften cikarir. + public async Task RemoveRowsAsync(string listFormCode, string keyFieldName, IEnumerable keys) + { + if (IsFolder) + { + foreach (var key in keys) + { + var rowPath = GetRowFilePath(key); + if (rowPath != null && File.Exists(rowPath)) + File.Delete(rowPath); + } + + return; + } + + if (!File.Exists(FullPath)) + return; + + var file = await ReadFileAsync(FullPath, listFormCode, keyFieldName); + var removed = 0; + + foreach (var key in keys) + { + var index = FindRowIndex(file.Rows, keyFieldName, key); + if (index < 0) + continue; + + file.Rows.RemoveAt(index); + removed++; + } + + if (removed > 0) + await WriteFileAsync(FullPath, file); + } + + /// + /// true donen satirlari hedeften cikarir; dosyanin basligina dokunulmaz. + /// Klasor modunda bosalan satir dosyasi silinir: klasorde kalirsa applier onu satirsiz bir ekran + /// dosyasi olarak okurdu. + /// + public async Task RemoveRowsAsync(Func, bool> matches) + { + foreach (var (path, file) in await ReadFilesAsync()) + { + if (file.Rows == null || file.Rows.RemoveAll(row => matches(row)) == 0) + continue; + + if (IsFolder && file.Rows.Count == 0) + File.Delete(path); + else + await WriteFileAsync(path, file); + } + } + + /// Hedef ya da icindekiler diskten silinir; silinecek bir sey yoksa false. + public bool Delete() + { + if (IsFolder) + { + if (!Directory.Exists(FullPath)) + return false; + + Directory.Delete(FullPath, recursive: true); + return true; + } + + if (!File.Exists(FullPath)) + return false; + + File.Delete(FullPath); + return true; + } + + /// + /// Klasordeki satir dosyalarinin tasidigi uygulama sirasi (Order); bir ekranin butun satir + /// dosyalarinda ayni degerdir, bu yuzden ilk kardes dosyadan okunur. Kardes yoksa null. + /// + private async Task ReadFolderOrderAsync() + { + var sibling = GetFiles().FirstOrDefault(); + if (sibling == null) + return null; + + try + { + var file = JsonSerializer.Deserialize(await File.ReadAllTextAsync(sibling), ListFormSeedDataFileDto.JsonOptions); + + return file?.Order ?? 0; + } + catch (JsonException) + { + return 0; + } + } + + /// Anahtarlar dosyada JsonElement, istekte string/Guid olarak gelir; metin uzerinden karsilastirilir. + public static string KeyText(object? value) + { + return value switch + { + null => string.Empty, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString() ?? string.Empty, + JsonElement element => element.ToString(), + _ => value.ToString() ?? string.Empty + }; + } + + private static int FindRowIndex(List> rows, string keyFieldName, object key) + { + var keyText = KeyText(key); + + return rows.FindIndex(row => + row.TryGetValue(keyFieldName, out var value) && + string.Equals(KeyText(value), keyText, StringComparison.OrdinalIgnoreCase)); + } + + private static async Task ReadFileAsync(string filePath, string listFormCode, string keyFieldName) + { + var file = File.Exists(filePath) + ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(filePath), ListFormSeedDataFileDto.JsonOptions) + : null; + + file ??= new ListFormSeedDataFileDto(); + file.ListFormCode = listFormCode; + file.KeyFieldName = keyFieldName; + file.Rows ??= []; + + return file; + } + + /// + /// Icerik degismediyse dosyaya dokunulmaz: klasor modunda hedef bastan yazildiginda yalnizca + /// degisen satirin dosyasi yazilir, digerleri git'te ve diskte iz birakmaz. + /// + private static async Task WriteFileAsync(string filePath, ListFormSeedDataFileDto file) + { + var content = ListFormSeedDataFileDto.Serialize(file); + if (File.Exists(filePath) && string.Equals(await File.ReadAllTextAsync(filePath), content, StringComparison.Ordinal)) + return; + + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllTextAsync(filePath, content); + } +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/NotificationTenantDefaultsSeedContributor.cs b/api/src/Sozsoft.Platform.Domain/Data/NotificationTenantDefaultsSeedContributor.cs index 857f25ee..40c901a7 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/NotificationTenantDefaultsSeedContributor.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/NotificationTenantDefaultsSeedContributor.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Sozsoft.Notifications.Entities; +using Sozsoft.Platform.Entities; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -27,11 +27,12 @@ namespace Sozsoft.Platform.Data.Seeds; public class NotificationTenantDefaultsSeedContributor( IRepository notificationTypeRepository, IRepository notificationRuleRepository, + IRepository listFormRepository, IConfiguration configuration, ILogger logger) : IDataSeedContributor, ITransientDependency { - private const string TypesFile = "App.Notifications.NotificationTypes"; - private const string RulesFile = "App.Notifications.NotificationRules"; + private const string TypesListFormCode = PlatformConsts.AppCodes.Notifications.NotificationTypes; + private const string RulesListFormCode = PlatformConsts.AppCodes.Notifications.NotificationRules; public async Task SeedAsync(DataSeedContext context) { @@ -43,7 +44,7 @@ public class NotificationTenantDefaultsSeedContributor( return; } - var types = ReadRows(TypesFile); + var types = await ReadRowsAsync(TypesListFormCode); if (types.Count == 0) { return; @@ -82,7 +83,7 @@ public class NotificationTenantDefaultsSeedContributor( var existingRules = await notificationRuleRepository.GetListAsync(); - foreach (var row in ReadRows(RulesFile)) + foreach (var row in await ReadRowsAsync(RulesListFormCode)) { var fileTypeId = GetString(row, "NotificationTypeId"); if (fileTypeId == null || !typeIdByFileId.TryGetValue(fileTypeId, out var typeId)) @@ -122,26 +123,21 @@ public class NotificationTenantDefaultsSeedContributor( } /// - /// Varsayilan kumeyi veri seed dosyasindan okur; ikinci bir varsayilan - /// listesi kodda tutulmaz. Dosya yoksa kiraci bos baslar, bu bir hata degildir. + /// Varsayilan kumeyi ekranin tanimladigi veri seed hedefinden (tek dosya ya da satir klasoru) + /// okur; ikinci bir varsayilan listesi kodda tutulmaz. Hedef yoksa kiraci bos baslar, bu bir + /// hata degildir. /// - private List ReadRows(string listFormCode) + private async Task> ReadRowsAsync(string listFormCode) { - var path = SeedPathResolver.GetPath( - configuration, - SeedPathResolver.GetDataFilePath(listFormCode).Split('/')); - - if (!File.Exists(path)) + var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); + var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath); + if (target == null || !target.Exists) { - logger.LogInformation("Notification defaults file not found, skipping: {Path}", path); + logger.LogInformation("Notification defaults seed target of '{ListFormCode}' not found, skipping: {SeedFilePath}", listFormCode, listForm?.SeedFilePath); return []; } - using var document = JsonDocument.Parse(File.ReadAllText(path)); - - return document.RootElement.TryGetProperty("Rows", out var rows) && rows.ValueKind == JsonValueKind.Array - ? [.. rows.EnumerateArray().Select(row => row.Clone())] - : []; + return [.. (await target.ReadRowsAsync()).Select(row => JsonSerializer.SerializeToElement(row))]; } private static string? GetString(JsonElement row, string propertyName) diff --git a/claude.md b/claude.md index 388454e5..33a8907d 100644 --- a/claude.md +++ b/claude.md @@ -466,11 +466,17 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → değer **tek dosyadır** (`data/{ListFormCode}.json`, bütün satırlar orada), uzantısız değer **klasördür** (`data/{ListFormCode}/{anahtar}.json`, her satır kendi dosyasında) ve klasör yoksa ilk kayıtta oluşturulur. Klasör modu satırı büyük ya da sık yazılan ekranlar içindir - (varsayılan tek dosya; şu an klasör kullanan ekranlar Custom Components ve Custom Endpoints). Applier `data/` + (varsayılan tek dosya). **Biçimin tek kaynağı ekranın tanımıdır**; diskte ne bulunduğu kararı + değiştirmez ve tanım kod içinde ekran adıyla sabitlenmez. Hedefi okuyan/yazan her yol + (senkronizasyon, export/import, temizleme, wizard satır deposu, MCP demo satırları, bildirim + varsayılanları) `ListFormSeedTarget` üzerinden gider; paylaşılan ekranların tanımı + `ListFormWizardAppService.ReadSeedFilePathsAsync` ile okunup bileşenlere verilir. Applier `data/` altını özyinelemeli tarar ve **bir klasörün tamamını tek unit of work'te** uygular — ekran tanımı, alan listesi ve veri kaynağı satır dosyası başına değil ekran başına bir kez çözülür; - dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Tek dosyaya bağlı eski bir kayıt, - klasörü diskte varsa migrate sırasında klasöre taşınır. + dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Ekran bir hedef tanımlıyorsa + yalnızca o hedef uygulanır; tanımın dışında kalan dosya (biçimi değişmiş ekranın eski dosyası) + uyarıyla atlanır. Biçimi değiştirmek tanımı (ListForm detayı ya da seeder) değiştirmektir; + yeniden wizard deploy'u tanımlı hedefi korur. Migrate/seed sırasında ve wizard import'unun kapanışında (`CompleteImport`) `ListFormSeedDataApplier` dosyayı geri uygular. Migrate/seed'de kayıt yoksa eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki