Listform için GetDataFolderPath veya GetDataFilePath

This commit is contained in:
Sedat ÖZTÜRK 2026-10-07 17:48:16 +03:00
parent b9b26eff7c
commit 7e5322d831
14 changed files with 576 additions and 548 deletions

View file

@ -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`, **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ı `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 `{anahtar}.json`'dur. Biçimi yalnızca `SeedFilePath` belirler; diskte ne bulunduğu kararı
diskte `data/{ListFormCode}/` klasörü varsa migrate yolu klasöre çevirir. `SeedFilePath` `.json` ile bitiyorsa bütün satırlar tek değiştirmez. `.json` ile bitiyorsa bütün satırlar tek dosyadadır (varsayılan). Uzantısız
dosyadadır (varsayılan). Uzantısız yazılırsa hedef klasördür (`data/{ListFormCode}`) ve her satır yazılırsa hedef klasördür (`data/{ListFormCode}`) ve her satır `{anahtar}.json` olarak kendi
`{anahtar}.json` olarak kendi dosyasında durur; klasör yoksa ilk kayıtta oluşturulur. Klasör modu 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
satırı büyük ya da sık yazılan ekranlar içindir — şu an tek örnek Custom Components ekranlar içindir (`SeedPathResolver.GetDataFolderPath`). Hedefi okuyan ve yazan her yol
(`SeedPathResolver.GetDataFolderPath`). Uygulayıcı `data/` altını özyinelemeli tarar ve bir `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 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 yansıyacağını `SeedSyncInsert`, `SeedSyncUpdate` ve `SeedSyncDelete` bayrakları belirler — üçü de
kapalıyken dosya yalnızca seed sırasında okunur, hiç yazılmaz. kapalıyken dosya yalnızca seed sırasında okunur, hiç yazılmaz.

View file

@ -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. | | `{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. | | `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. | | `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, 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. fakat `SqlDataSeeder` bunları uyarı ile loglar — ilgili klasöre taşınmaları beklenir.

View file

@ -2,7 +2,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@ -360,26 +359,22 @@ public class McpDevelopAppService(
} }
/// <summary> /// <summary>
/// Demo satirlarini ekranin veri dosyasina (<c>ListForm.SeedFilePath</c>) yazar; veritabanina /// Demo satirlarini ekranin veri seed hedefine (<c>ListForm.SeedFilePath</c>; tanima gore tek
/// dokunmaz. Migrate ekrani kurduktan sonra <c>ListFormSeedDataApplier</c> satirlari ekler ve /// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra
/// kiraci/audit alanlarini doldurur. Guid anahtar burada uretilir: dosya her uygulandiginda ayni /// <c>ListFormSeedDataApplier</c> satirlari ekler ve kiraci/audit alanlarini doldurur. Guid anahtar
/// kayda karsilik gelir. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez. /// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir
/// varsa (tekrar deploy) yeniden eklenmez.
/// </summary> /// </summary>
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows) private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
{ {
var listForm = (await wizardAppService.GetFile(fileName)).ListForm; var listForm = (await wizardAppService.GetFile(fileName)).ListForm;
var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/'); var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath);
if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath)) if (listForm == null || target == null)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
var path = SeedPathResolver.GetPath(configuration, relativePath.Split('/')); var keyFieldName = listForm.KeyFieldName;
var seedFile = File.Exists(path) var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) var added = new List<(object Key, Dictionary<string, object> Row)>();
: 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);
foreach (var row in rows) foreach (var row in rows)
{ {
@ -390,11 +385,12 @@ public class McpDevelopAppService(
if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase)) if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
values[keyFieldName] = GuidGenerator.Create(); values[keyFieldName] = GuidGenerator.Create();
seedFile.Rows.Add(values); added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values));
} }
Directory.CreateDirectory(Path.GetDirectoryName(path)!); // Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz.
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile)); if (added.Count > 0 && (await target.UpsertRowsAsync(listForm.ListFormCode, keyFieldName, added)).Count > 0)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]);
} }
/// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary> /// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary>

View file

@ -1,7 +1,6 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -83,12 +82,6 @@ public class ListFormSeedDataSynchronizer(
{ {
private const int SyncAllPageSize = 10000; private const int SyncAllPageSize = 10000;
/// <summary>
/// Ekranin seed hedefi. <see cref="IsFolder"/> true ise her satir klasorun altinda kendi
/// dosyasindadir (<c>{anahtar}.json</c>); false ise butun satirlar tek dosyadadir.
/// </summary>
private sealed record SeedTarget(string Path, bool IsFolder);
public Task SyncRowAsync(string listFormCode, object? key, SeedSyncOperation operation) => public Task SyncRowAsync(string listFormCode, object? key, SeedSyncOperation operation) =>
SyncRowsAsync(listFormCode, [key], operation); SyncRowsAsync(listFormCode, [key], operation);
@ -114,27 +107,8 @@ public class ListFormSeedDataSynchronizer(
if (rows.Count == 0) if (rows.Count == 0)
return; return;
if (target.IsFolder) 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);
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);
}); });
} }
@ -142,45 +116,15 @@ public class ListFormSeedDataSynchronizer(
{ {
await ExecuteAsync(listFormCode, listForm => listForm.SeedSyncDelete, async (listForm, target) => 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); await WriteAllRowsAsync(listForm, target);
return; return;
} }
if (target.IsFolder) await target.RemoveRowsAsync(listForm.ListFormCode, listForm.KeyFieldName, keys);
{
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);
}); });
} }
@ -193,35 +137,12 @@ public class ListFormSeedDataSynchronizer(
if (string.IsNullOrWhiteSpace(fieldName) || wanted.Count == 0) if (string.IsNullOrWhiteSpace(fieldName) || wanted.Count == 0)
return; return;
bool Matches(Dictionary<string, object> 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 // Yalnizca dosyaya dokunur, veritabanindan satir okumaz; commit beklenmez. Ertelenseydi
// ayni istekte sonradan yazilan wizard satirlarini silebilirdi. // ayni istekte sonradan yazilan wizard satirlarini silebilirdi.
await RunAsync(listFormCode, listForm => listForm.SeedSyncDelete, async (_, target) => await RunAsync(listFormCode, listForm => listForm.SeedSyncDelete, (_, target) =>
{ target.RemoveRowsAsync(row => row.Any(kv =>
List<string> files = target.IsFolder string.Equals(kv.Key, fieldName, StringComparison.OrdinalIgnoreCase) &&
? Directory.Exists(target.Path) ? [.. Directory.EnumerateFiles(target.Path, "*.json")] : [] wanted.Contains(ListFormSeedTarget.KeyText(kv.Value)))));
: File.Exists(target.Path) ? [target.Path] : [];
foreach (var filePath in files)
{
var seedFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
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);
} }
public async Task SyncAllAsync(string listFormCode) public async Task SyncAllAsync(string listFormCode)
@ -237,21 +158,11 @@ public class ListFormSeedDataSynchronizer(
} }
/// <summary>Hedefi listenin tamamiyla yeniden yazar.</summary> /// <summary>Hedefi listenin tamamiyla yeniden yazar.</summary>
private async Task WriteAllRowsAsync(ListForm listForm, SeedTarget target) private async Task WriteAllRowsAsync(ListForm listForm, ListFormSeedTarget target)
{ {
var rows = await ReadAllRowsAsync(listForm); var skipped = await target.ReplaceRowsAsync(listForm.ListFormCode, listForm.KeyFieldName, await ReadAllRowsAsync(listForm));
if (skipped > 0)
if (target.IsFolder) logger.LogWarning("{Count} row(s) without a usable key were skipped while writing the '{ListFormCode}' seed folder.", skipped, listForm.ListFormCode);
{
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);
} }
/// <summary> /// <summary>
@ -281,94 +192,6 @@ public class ListFormSeedDataSynchronizer(
} }
} }
/// <summary>
/// Klasor modunda her satiri kendi dosyasina yazar ve listede kalmayan satirlarin dosyalarini
/// siler — klasor listenin aynasidir.
/// </summary>
private async Task WriteRowFilesAsync(ListForm listForm, SeedTarget target, List<Dictionary<string, object>> rows)
{
var order = await ReadFolderOrderAsync(target);
var written = new HashSet<string>(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);
}
}
/// <summary>Klasor modunda tek bir satirin dosyasini yazar.</summary>
private async Task WriteRowFileAsync(ListForm listForm, SeedTarget target, object key, Dictionary<string, object> 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);
}
/// <summary>
/// Klasordeki satir dosyalarinin tasidigi uygulama sirasi (<c>Order</c>); bir ekranin butun satir
/// dosyalarinda ayni degerdir, bu yuzden ilk kardes dosyadan okunur.
/// </summary>
private static async Task<int> ReadFolderOrderAsync(SeedTarget target)
{
var sibling = Directory.EnumerateFiles(target.Path, "*.json").FirstOrDefault();
if (sibling == null)
return 0;
try
{
var seedFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
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);
}
/// <summary> /// <summary>
/// Seed dosyasi bagli degilse ya da islemin bayragi kapaliysa hicbir sey yapmaz. /// Seed dosyasi bagli degilse ya da islemin bayragi kapaliysa hicbir sey yapmaz.
/// Senkronizasyon hatasi kullanicinin kayit islemini bozmaz; yalnizca loglanir. /// Senkronizasyon hatasi kullanicinin kayit islemini bozmaz; yalnizca loglanir.
@ -376,7 +199,7 @@ public class ListFormSeedDataSynchronizer(
private Task ExecuteAsync( private Task ExecuteAsync(
string listFormCode, string listFormCode,
Func<ListForm, bool> isEnabled, Func<ListForm, bool> isEnabled,
Func<ListForm, SeedTarget, Task> action) Func<ListForm, ListFormSeedTarget, Task> action)
{ {
if (string.IsNullOrWhiteSpace(listFormCode)) if (string.IsNullOrWhiteSpace(listFormCode))
return Task.CompletedTask; return Task.CompletedTask;
@ -401,8 +224,7 @@ public class ListFormSeedDataSynchronizer(
private async Task RunAsync( private async Task RunAsync(
string listFormCode, string listFormCode,
Func<ListForm, bool> isEnabled, Func<ListForm, bool> isEnabled,
Func<ListForm, SeedTarget, Task> action, Func<ListForm, ListFormSeedTarget, Task> action)
bool createTarget = true)
{ {
// Seed dosyalari urunun varsayilanidir ve host ile kendi veritabani olan her tenant'a kurulur; // 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. // bir tenant'in kaydi dosyaya yazilsaydi o tenant'in verisi butun veritabanlarina yayilirdi.
@ -412,18 +234,19 @@ public class ListFormSeedDataSynchronizer(
try try
{ {
var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode);
if (listForm == null || string.IsNullOrWhiteSpace(listForm.SeedFilePath) || !isEnabled(listForm)) if (listForm == null || !isEnabled(listForm))
return; 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. await action(listForm, target);
if (createTarget)
Directory.CreateDirectory(isFolder ? fullPath : Path.GetDirectoryName(fullPath)!);
await action(listForm, new SeedTarget(fullPath, isFolder));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -468,44 +291,4 @@ public class ListFormSeedDataSynchronizer(
return rows; 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));
}
/// <summary>Anahtarlar dosyada JsonElement, istekte string/Guid olarak gelir; metin uzerinden karsilastirilir.</summary>
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<ListFormSeedDataFileDto> ReadSeedFileAsync(string filePath, ListForm listForm)
{
var seedFile = File.Exists(filePath)
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
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));
} }

View file

@ -122,7 +122,7 @@ public class ListFormWizardAppService(
if (previous?.Wizard != null) if (previous?.Wizard != null)
input.SeededAt = previous.Wizard.SeededAt; input.SeededAt = previous.Wizard.SeededAt;
var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot()); var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot(), await ReadSeedFilePathsAsync());
await BindExistingMenuParentsAsync(input, rows, previous); await BindExistingMenuParentsAsync(input, rows, previous);
// Ad ve kod catismalari, dosyalar yazilmaya baslamadan once reddedilir; aksi halde yarim // Ad ve kod catismalari, dosyalar yazilmaya baslamadan once reddedilir; aksi halde yarim
@ -320,6 +320,11 @@ public class ListFormWizardAppService(
listFormDto = input.BuildListForm(isDeleted, isCreated); listFormDto = input.BuildListForm(isDeleted, isCreated);
fieldDtos = input.BuildFields(); 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)) foreach (var item in input.Groups.SelectMany(g => g.Items))
await WriteLanguageRowsAsync(rows, item.CaptionName, item.EnglishCaption, item.TurkishCaption, owned, inserted); await WriteLanguageRowsAsync(rows, item.CaptionName, item.EnglishCaption, item.TurkishCaption, owned, inserted);
} }
@ -1034,7 +1039,7 @@ public class ListFormWizardAppService(
} }
var componentDependencies = fileByComponent.Count > 0 var componentDependencies = fileByComponent.Count > 0
? await new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration)) ? await new WizardExportCollector(SeedPathResolver.GetRootPath(_configuration), await ReadSeedFilePathsAsync())
.ReadCustomComponentDependenciesAsync() .ReadCustomComponentDependenciesAsync()
: []; : [];
@ -1396,7 +1401,7 @@ public class ListFormWizardAppService(
/// </summary> /// </summary>
private async Task<List<WizardDeleteLogItemDto>> RemovePendingSeedRowsAsync(WizardSeedFileDto seed) private async Task<List<WizardDeleteLogItemDto>> 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 owned = (seed.InsertedRecords ?? new WizardInsertedRecordsDto()).Union(seed.Pending?.Replaced);
var usages = new Dictionary<(WizardRecordTypeEnum, string), List<WizardRecordRefDto>>(); var usages = new Dictionary<(WizardRecordTypeEnum, string), List<WizardRecordRefDto>>();
@ -1585,6 +1590,19 @@ public class ListFormWizardAppService(
} }
/// <summary>Seed klasorunun tam yolu.</summary> /// <summary>Seed klasorunun tam yolu.</summary>
/// <summary>
/// Wizard'in satir yazdigi ve export/import'un okudugu paylasilan ekranlarin seed hedefleri
/// (ListFormCode → <c>SeedFilePath</c>). Hedefin tek dosya mi klasor mu oldugu yalnizca bu
/// tanimdan gelir; diskten tahmin edilmez.
/// </summary>
private async Task<Dictionary<string, string?>> 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() private string ResolveScopeRoot()
{ {
return Path.GetFullPath(SeedPathResolver.GetRootPath(_configuration)); return Path.GetFullPath(SeedPathResolver.GetRootPath(_configuration));
@ -1717,7 +1735,7 @@ public class ListFormWizardAppService(
var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath)) var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath))
?? throw new UserFriendlyException("Dosya okunamadı."); ?? 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]); var items = await collector.CollectAsync([filePath]);
return (items, collector.Warnings, ArchiveName(seed.Wizard?.WizardName ?? Path.GetFileNameWithoutExtension(fileName), "Wizard")); 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 title = devRequest?.Title ?? files[0].Seed!.DevRequest.Title;
var fileNames = files.Select(f => f.FileName).ToList(); 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)]); var items = await collector.CollectAsync([.. fileNames.Select(ResolveWizardSeedFilePath)]);
return (items, collector.Warnings, ArchiveName(title, "DevRequest"), fileNames); return (items, collector.Warnings, ArchiveName(title, "DevRequest"), fileNames);
@ -2012,7 +2030,8 @@ public class ListFormWizardAppService(
DatabaseObjectExistsAsync, DatabaseObjectExistsAsync,
ReadTableColumnsAsync, ReadTableColumnsAsync,
TableHasRowsAsync, TableHasRowsAsync,
listFormCode => repoListForm.AnyAsync(a => a.ListFormCode == listFormCode)); listFormCode => repoListForm.AnyAsync(a => a.ListFormCode == listFormCode),
await ReadSeedFilePathsAsync());
return await validator.ValidateAsync(stagedEntries); return await validator.ValidateAsync(stagedEntries);
} }
@ -2301,9 +2320,13 @@ public class ListFormWizardAppService(
} }
// 4) Veri satirlari. // 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<ListFormSeedTarget>()
.ToList();
var dataFiles = (await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json")) var dataFiles = (await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json"))
.Where(path => !wizardRowFiles.Contains(path)) .Where(path => !wizardRowTargets.Any(target => target.Contains(path)))
.ToList(); .ToList();
foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true)) foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true))
{ {

View file

@ -30,8 +30,13 @@ public sealed record WizardExportItem(string Path, string Reason, string? FullPa
/// Bulunamayan bagimliliklar sessizce atlanmaz; <see cref="Warnings"/> uzerinden cagirana tasinir ve /// Bulunamayan bagimliliklar sessizce atlanmaz; <see cref="Warnings"/> uzerinden cagirana tasinir ve
/// indirmeden once kullaniciya gosterilir. /// indirmeden once kullaniciya gosterilir.
/// </para> /// </para>
/// <para>
/// Paylasilan seed hedeflerinin (custom component'ler, menu/yetki/dil satirlari) dosya mi klasor
/// mu oldugu ekran tanimlarindan gelir (<c>seedFilePaths</c>: ListFormCode → <c>SeedFilePath</c>);
/// diskten tahmin edilmez.
/// </para>
/// </summary> /// </summary>
public sealed class WizardExportCollector(string scopeRoot) public sealed class WizardExportCollector(string scopeRoot, IReadOnlyDictionary<string, string?> seedFilePaths)
{ {
/// <summary>Custom component'lerin seed kaynagi olan ListForm ekrani.</summary> /// <summary>Custom component'lerin seed kaynagi olan ListForm ekrani.</summary>
public const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents; public const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents;
@ -281,30 +286,10 @@ public sealed class WizardExportCollector(string scopeRoot)
if (customComponents.Count == 0) if (customComponents.Count == 0)
return; return;
foreach (var (relativePath, seedFile) in await ReadCustomComponentFilesAsync()) AddFilteredRows(
{ await ReadCustomComponentFilesAsync(),
// Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla birlestirilir. row => customComponents.Contains(GetRowText(row, CustomComponentNameField) ?? string.Empty),
var rows = seedFile.Rows "Custom component tanımı");
.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);
}
} }
/// <summary> /// <summary>
@ -326,17 +311,31 @@ public sealed class WizardExportCollector(string scopeRoot)
foreach (var (listFormCode, field, keys) in targets) foreach (var (listFormCode, field, keys) in targets)
{ {
var relativePath = SeedPathResolver.GetDataFilePath(listFormCode); if (keys.Count == 0)
var fullPath = ResolveScopeFilePath(relativePath);
if (fullPath == null || keys.Count == 0)
continue; continue;
var seedFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
await File.ReadAllTextAsync(fullPath), ListFormSeedDataFileDto.JsonOptions);
var wanted = keys.ToHashSet(StringComparer.OrdinalIgnoreCase); var wanted = keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var rows = seedFile?.Rows?.Where(x => wanted.Contains(GetRowText(x, field) ?? string.Empty)).ToList() ?? []; AddFilteredRows(
if (seedFile == null || rows.Count == 0) await ReadSeedFilesAsync(listFormCode),
row => wanted.Contains(GetRowText(row, field) ?? string.Empty),
"Menü, yetki ve dil satırları");
}
}
/// <summary>
/// Paylasilan bir seed hedefinin dosyalarini yalnizca <paramref name="keep"/> satirlarina
/// indirgeyerek arsive ekler. Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla
/// birlesir. Satir basina dosya tutan klasorde ilgisiz satirin dosyasi arsive hic girmez.
/// </summary>
private void AddFilteredRows(
IEnumerable<(string Path, ListFormSeedDataFileDto File)> files,
Func<Dictionary<string, object>, bool> keep,
string reason)
{
foreach (var (relativePath, seedFile) in files)
{
var rows = seedFile.Rows.Where(keep).ToList();
if (rows.Count == 0)
continue; continue;
var filtered = new ListFormSeedDataFileDto var filtered = new ListFormSeedDataFileDto
@ -348,8 +347,7 @@ public sealed class WizardExportCollector(string scopeRoot)
Rows = rows, Rows = rows,
}; };
items[relativePath] = new WizardExportItem( items[relativePath] = new WizardExportItem(relativePath, reason, null, ListFormSeedDataFileDto.Serialize(filtered));
relativePath, "Menü, yetki ve dil satırları", null, ListFormSeedDataFileDto.Serialize(filtered));
} }
} }
@ -389,45 +387,41 @@ public sealed class WizardExportCollector(string scopeRoot)
return rows; return rows;
} }
/// <summary>Custom component seed kaynagi; ekranin tanimindaki hedeften bir kez okunur.</summary>
private async Task<List<(string Path, ListFormSeedDataFileDto File)>> ReadCustomComponentFilesAsync() =>
customComponentFiles ??= await ReadSeedFilesAsync(CustomComponentsListFormCode);
/// <summary> /// <summary>
/// Custom component seed kaynagi. Ekranin <c>SeedFilePath</c> degeri burada bilinmedigi icin /// Ekranin tanimladigi seed hedefindeki dosyalar (kapsam-goreli yol + icerik). Tanim yoksa ya da
/// kaynak diskten cozulur: satir basina dosya tutan klasor varsa oradaki dosyalar, yoksa tek /// hedef diskte yoksa uyari uretilir; okunamayan dosya atlanir ve uyarisi tasinir.
/// dosya okunur.
/// </summary> /// </summary>
private async Task<List<(string Path, ListFormSeedDataFileDto File)>> ReadCustomComponentFilesAsync() private async Task<List<(string Path, ListFormSeedDataFileDto File)>> ReadSeedFilesAsync(string listFormCode)
{ {
if (customComponentFiles != null) var target = ListFormSeedTarget.Create(scopeRoot, seedFilePaths.GetValueOrDefault(listFormCode));
return customComponentFiles; if (target == null || !target.Exists)
customComponentFiles = [];
var folderPath = SeedPathResolver.GetDataFolderPath(CustomComponentsListFormCode);
var relativePaths = ResolveScopeFolderPath(folderPath) == null
? [SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode)]
: EnumerateSeedFolderFiles(folderPath).ToList();
foreach (var relativePath in relativePaths)
{ {
var sourcePath = ResolveScopeFilePath(relativePath); warnings.Add($"{listFormCode}: ekranın veri seed hedefi ({target?.RelativePath ?? "tanımsız"}) bulunamadı.");
if (sourcePath == null) return [];
{
warnings.Add($"{relativePath}: custom component seed dosyası bulunamadı.");
continue;
} }
var files = new List<(string, ListFormSeedDataFileDto)>();
foreach (var relativePath in target.GetRelativeFiles())
{
try try
{ {
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(sourcePath), JsonReadOptions); var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
await File.ReadAllTextAsync(ResolveScopeFilePath(relativePath)!), ListFormSeedDataFileDto.JsonOptions);
if (file != null) if (file != null)
customComponentFiles.Add((relativePath, file)); files.Add((relativePath, file));
} }
catch (JsonException) catch (JsonException)
{ {
warnings.Add($"{relativePath}: custom component seed dosyası çözülemedi."); warnings.Add($"{relativePath}: seed dosyası çözülemedi.");
} }
} }
return customComponentFiles; return files;
} }
// ── Dosya toplama ────────────────────────────────────────────────────────────── // ── Dosya toplama ──────────────────────────────────────────────────────────────
@ -512,37 +506,19 @@ public sealed class WizardExportCollector(string scopeRoot)
} }
/// <summary> /// <summary>
/// Ekranin veri seed hedefini arsive ekler. Hedef klasorse (satir basina dosya) klasordeki butun /// Ekranin veri seed hedefini (tanima gore tek dosya ya da satir klasorundeki butun dosyalar)
/// satir dosyalari girer; tek dosyaysa o dosya eklenir. Hicbir sey eklenemezse false doner. /// arsive ekler. Hicbir sey eklenemezse false doner.
/// </summary> /// </summary>
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; var added = false;
foreach (var rowPath in EnumerateSeedFolderFiles(relativePath!)) foreach (var relativePath in ListFormSeedTarget.Create(scopeRoot, seedFilePath)?.GetRelativeFiles() ?? [])
added |= AddDiskFile(rowPath, reason); added |= AddDiskFile(relativePath, reason);
return added; return added;
} }
/// <summary>Kapsam-goreli bir seed klasorundeki satir dosyalarinin kapsam-goreli yollari.</summary>
private IEnumerable<string> 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);
}
/// <summary>Kapsam icindeki var olan bir dosyayi arsive ekler; kapsam disina cikan ya da bulunmayan yol icin false doner.</summary> /// <summary>Kapsam icindeki var olan bir dosyayi arsive ekler; kapsam disina cikan ya da bulunmayan yol icin false doner.</summary>
private bool AddDiskFile(string? relativePath, string reason) private bool AddDiskFile(string? relativePath, string reason)
{ {
@ -575,21 +551,6 @@ public sealed class WizardExportCollector(string scopeRoot)
return File.Exists(fullPath) ? fullPath : null; return File.Exists(fullPath) ? fullPath : null;
} }
/// <summary>Kapsam icindeki var olan bir klasorun tam yolu; kapsam disina cikan yol icin null.</summary>
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) private string? ResolveWizardFileByListFormCode(string listFormCode)
{ {
wizardFilesByListFormCode ??= BuildWizardIndex(scopeRoot); wizardFilesByListFormCode ??= BuildWizardIndex(scopeRoot);

View file

@ -35,7 +35,8 @@ public sealed class WizardImportValidator(
Func<string?, string, Task<bool?>> databaseObjectExistsAsync, Func<string?, string, Task<bool?>> databaseObjectExistsAsync,
Func<string, Task<IReadOnlyList<SqlLiveColumn>?>> tableColumnsAsync, Func<string, Task<IReadOnlyList<SqlLiveColumn>?>> tableColumnsAsync,
Func<string, Task<bool?>> tableHasRowsAsync, Func<string, Task<bool?>> tableHasRowsAsync,
Func<string, Task<bool>> listFormExistsAsync) Func<string, Task<bool>> listFormExistsAsync,
IReadOnlyDictionary<string, string?> seedFilePaths)
{ {
private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true };
@ -206,9 +207,9 @@ public sealed class WizardImportValidator(
} }
var seedFilePath = seed.ListForm.SeedFilePath; var seedFilePath = seed.ListForm.SeedFilePath;
var seedTarget = ListFormSeedTarget.Create(scopeRoot, seedFilePath);
if (!string.IsNullOrWhiteSpace(seedFilePath) if (!string.IsNullOrWhiteSpace(seedFilePath)
&& !SeedDataIncoming(seedFilePath, paths) && (seedTarget == null || (!paths.Any(seedTarget.Contains) && !seedTarget.Exists)))
&& !ScopeSeedDataExists(seedFilePath))
{ {
Add(WizardImportIssueSeverityEnum.Warning, path, Add(WizardImportIssueSeverityEnum.Warning, path,
$"{screen}: ekranın veri dosyası ({seedFilePath}) ne zip'te ne hedefte var; ekran boş gelecek."); $"{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<string>(StringComparer.OrdinalIgnoreCase); var names = new HashSet<string>(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 try
{ {
@ -507,26 +511,6 @@ public sealed class WizardImportValidator(
return targetCustomComponents = names; return targetCustomComponents = names;
} }
/// <summary>
/// Hedefteki custom component seed dosyalari: satir basina dosya tutan klasor varsa onun
/// icindekiler, yoksa tek dosya.
/// </summary>
private List<string> 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) private SubFormDto[] ReadSubForms(WizardSeedFileDto seed, string path)
{ {
try try
@ -579,36 +563,6 @@ public sealed class WizardImportValidator(
private bool ScopeFileExists(string? relativePath) => ResolveScopeFilePath(relativePath) != null; private bool ScopeFileExists(string? relativePath) => ResolveScopeFilePath(relativePath) != null;
/// <summary>
/// Ekranin veri seed hedefi hedef ortamda var mi. Hedef klasorse (satir basina dosya) klasorun
/// kendisine bakilir; tek dosyaysa dosyaya.
/// </summary>
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);
}
/// <summary>Ekranin veri seed hedefi zip'te var mi; klasor modunda klasorun altindaki satir dosyalari aranir.</summary>
private static bool SeedDataIncoming(string relativePath, HashSet<string> 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) private string? ResolveScopeFilePath(string? relativePath)
{ {
var normalized = relativePath?.Replace('\\', '/').Trim('/'); var normalized = relativePath?.Replace('\\', '/').Trim('/');

View file

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -182,32 +181,12 @@ public class WizardRecordCleaner(
/// </summary> /// </summary>
public void DeleteSeedDataMirror(string? seedFilePath, List<WizardDeleteLogItemDto> report) public void DeleteSeedDataMirror(string? seedFilePath, List<WizardDeleteLogItemDto> report)
{ {
var relativePath = seedFilePath?.Replace('\\', '/').Trim('/'); var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), seedFilePath);
if (string.IsNullOrWhiteSpace(relativePath) || if (target == null || !target.Delete())
relativePath.Contains("..", StringComparison.Ordinal) ||
!relativePath.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase))
{
return;
}
var path = SeedPathResolver.GetPath(configuration, relativePath.Split('/'));
if (SeedPathResolver.IsSeedFolderPath(relativePath))
{
if (!Directory.Exists(path))
return; return;
Directory.Delete(path, recursive: true); Add(report, WizardRecordTypeEnum.SeedFile, target.RelativePath, WizardDeleteStatusEnum.Deleted);
} logger.LogInformation("Wizard seed data mirror deleted: {Path}", target.FullPath);
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);
} }
private static string FieldName(ListFormField field) => $"{field.ListFormCode}.{field.FieldName}"; private static string FieldName(ListFormField field) => $"{field.ListFormCode}.{field.FieldName}";

View file

@ -1,11 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Volo.Abp;
namespace Sozsoft.Platform.ListForms; namespace Sozsoft.Platform.ListForms;
@ -41,30 +40,36 @@ public sealed class WizardSeedRowStore
/// <summary>Wizard'in satir yazdigi ekran kodlari; import bu dosyalari veritabanina uygulamaz.</summary> /// <summary>Wizard'in satir yazdigi ekran kodlari; import bu dosyalari veritabanina uygulamaz.</summary>
public static IReadOnlyList<string> ListFormCodes { get; } = [.. Specs.Select(s => s.ListFormCode)]; public static IReadOnlyList<string> ListFormCodes { get; } = [.. Specs.Select(s => s.ListFormCode)];
private readonly Dictionary<string, (string Path, ListFormSeedDataFileDto File)> files = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, (ListFormSeedTarget Target, FileSpec Spec, ListFormSeedDataFileDto File)> files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> dirty = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> dirty = new(StringComparer.OrdinalIgnoreCase);
private WizardSeedRowStore() private WizardSeedRowStore()
{ {
} }
/// <summary>Seed klasorundeki dosyalari okur; olmayan dosya bos olarak baslar ve ilk yazimda olusur.</summary> /// <summary>
public static async Task<WizardSeedRowStore> LoadAsync(string scopeRoot) /// Ekranlarin tanimladigi seed hedeflerini (<paramref name="seedFilePaths"/>: ListFormCode →
/// <c>SeedFilePath</c>) 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.
/// </summary>
public static async Task<WizardSeedRowStore> LoadAsync(string scopeRoot, IReadOnlyDictionary<string, string?> seedFilePaths)
{ {
var store = new WizardSeedRowStore(); var store = new WizardSeedRowStore();
foreach (var spec in Specs) 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) var file = new ListFormSeedDataFileDto
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) {
: null; ListFormCode = spec.ListFormCode,
KeyFieldName = spec.KeyFieldName,
Rows = await target.ReadRowsAsync(),
};
file ??= new ListFormSeedDataFileDto { ListFormCode = spec.ListFormCode, KeyFieldName = spec.KeyFieldName, Order = spec.Order }; store.files[spec.ListFormCode] = (target, spec, file);
file.Rows ??= [];
store.files[spec.ListFormCode] = (path, file);
} }
return store; return store;
@ -207,10 +212,9 @@ public sealed class WizardSeedRowStore
{ {
foreach (var listFormCode in dirty) foreach (var listFormCode in dirty)
{ {
var (path, file) = files[listFormCode]; var (target, spec, file) = files[listFormCode];
Directory.CreateDirectory(Path.GetDirectoryName(path)!); await target.ReplaceRowsAsync(spec.ListFormCode, spec.KeyFieldName, file.Rows, spec.Order);
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(file));
} }
dirty.Clear(); dirty.Clear();

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -183,7 +182,7 @@ public class WizardTableCleaner(
/// <summary> /// <summary>
/// Talep silmesinde talebin tablolarini sorgulayan Custom Endpoint'leri veritabanindan ve /// Talep silmesinde talebin tablolarini sorgulayan Custom Endpoint'leri veritabanindan ve
/// <c>data/App.DeveloperKit.CustomEndpoints/</c> klasorundeki satir dosyalarindan siler. /// ekranin veri seed hedefinden (tanima gore tek dosya ya da satir klasoru) siler.
/// </summary> /// </summary>
public async Task DeleteCustomEndpointsAsync(IReadOnlyCollection<string> names, List<WizardDeleteLogItemDto> report) public async Task DeleteCustomEndpointsAsync(IReadOnlyCollection<string> names, List<WizardDeleteLogItemDto> report)
{ {
@ -211,28 +210,21 @@ public class WizardTableCleaner(
/// <summary>Henuz migrate edilmemis, yalnizca seed dosyasinda duran ve tabloyu sorgulayan endpoint'ler.</summary> /// <summary>Henuz migrate edilmemis, yalnizca seed dosyasinda duran ve tabloyu sorgulayan endpoint'ler.</summary>
private async Task<List<string>> FindSeedEndpointsAsync(WizardDeleteTableDto table) private async Task<List<string>> FindSeedEndpointsAsync(WizardDeleteTableDto table)
{ {
// Ekran klasor modundadir: her endpoint data/App.DeveloperKit.CustomEndpoints/{Id}.json dosyasindadir. // Endpoint satirlarinin yeri ekranin tanimindan gelir (tek dosya ya da satir klasoru).
var folder = ScopeFile(SeedPathResolver.GetDataFolderPath(CustomEndpointsListFormCode)); var listForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == CustomEndpointsListFormCode);
if (!Directory.Exists(folder)) var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath);
if (target == null)
return []; return [];
var pattern = TablePattern(table.TableName); var pattern = TablePattern(table.TableName);
var names = new List<string>();
string? Text(Dictionary<string, object> row, string field) => static string? Text(Dictionary<string, object> row, string field) => WizardExportCollector.GetRowText(row, field);
row.FirstOrDefault(kv => string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
foreach (var path in Directory.EnumerateFiles(folder, "*.json")) return [.. (await target.ReadRowsAsync())
{
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions);
names.AddRange((file?.Rows ?? [])
.Where(row => string.Equals(Text(row, nameof(CustomEndpoint.DataSourceCode)), table.DataSourceCode, StringComparison.OrdinalIgnoreCase) && .Where(row => string.Equals(Text(row, nameof(CustomEndpoint.DataSourceCode)), table.DataSourceCode, StringComparison.OrdinalIgnoreCase) &&
Text(row, nameof(CustomEndpoint.Sql)) is { } sql && pattern.IsMatch(sql)) Text(row, nameof(CustomEndpoint.Sql)) is { } sql && pattern.IsMatch(sql))
.Select(row => Text(row, nameof(CustomEndpoint.Name))) .Select(row => Text(row, nameof(CustomEndpoint.Name)))
.OfType<string>()); .OfType<string>()];
}
return names;
} }
private async Task ReadTableStateAsync(WizardDeleteTableDto table) private async Task ReadTableStateAsync(WizardDeleteTableDto table)

View file

@ -151,8 +151,6 @@ public class ListFormSeedDataApplier(
return; return;
} }
await MigrateSeedFilePathsAsync(tenantId, dataPath);
// Alt klasorler de taranir: satir basina dosya tutan ekranin verisi // Alt klasorler de taranir: satir basina dosya tutan ekranin verisi
// data/{ListFormCode}/{anahtar}.json altindadir. // data/{ListFormCode}/{anahtar}.json altindadir.
var jsonFiles = Directory.GetFiles(dataPath, "*.json", SearchOption.AllDirectories); var jsonFiles = Directory.GetFiles(dataPath, "*.json", SearchOption.AllDirectories);
@ -205,41 +203,6 @@ public class ListFormSeedDataApplier(
.Select(a => a.Target)]; .Select(a => a.Target)];
} }
/// <summary>
/// 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 (<c>data/{ListFormCode}.json</c>) degerler tasinir.
/// </summary>
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();
}
}
/// <summary>Dosya, bir ekranin satir klasorunun (<c>data/{ListFormCode}/</c>) altinda mi.</summary> /// <summary>Dosya, bir ekranin satir klasorunun (<c>data/{ListFormCode}/</c>) altinda mi.</summary>
private static bool IsRowFile(string dataPath, string filePath) 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."); 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); var fields = await listFormFieldRepository.GetListAsync(a => a.ListFormCode == listFormCode);
// Anahtar alan ekranin butun satir dosyalarinda aynidir; dosyada yoksa ekrandan gelir. // Anahtar alan ekranin butun satir dosyalarinda aynidir; dosyada yoksa ekrandan gelir.

View file

@ -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;
/// <summary>
/// Bir ekranin veri seed hedefi. Bicim yalnizca tanimdan (<c>ListForm.SeedFilePath</c>) gelir:
/// <c>.json</c> ile biten deger butun satirlari tasiyan tek dosyadir, uzantisiz deger her satiri
/// <c>{anahtar}.json</c> olarak tutan klasordur. Diskte ne bulundugu bu karari degistirmez; ayni
/// ekranin hem dosyasi hem klasoru varsa okunan, tanimin gosterdigidir.
/// <para>
/// Hedefi okuyan ve yazan her yol (senkronizasyon, export/import, temizleme, MCP, seeder) bu sinifi
/// kullanir; dosya/klasor ayrimi ikinci bir yerde yazilmaz.
/// </para>
/// </summary>
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));
}
/// <summary>Seed kokune gore goreli, <c>/</c> ayracli yol.</summary>
public string RelativePath { get; }
public string FullPath { get; }
public bool IsFolder { get; }
public bool Exists => IsFolder ? Directory.Exists(FullPath) : File.Exists(FullPath);
/// <summary>
/// Tanimi <paramref name="scopeRoot"/> kapsamina baglar. Tanim bossa, <c>data/</c> disini
/// gosteriyorsa ya da kapsamdan cikiyorsa null doner: veri aynasi yalnizca <c>data/</c> altinda yasar.
/// </summary>
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));
}
/// <summary>Dosya yolu hedefin kendisi ya da klasor modunda onun satir dosyasi mi.</summary>
public bool Contains(string relativePath)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
return IsFolder
? normalized.StartsWith(RelativePath + "/", StringComparison.OrdinalIgnoreCase)
: string.Equals(normalized, RelativePath, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Hedefte diskte bulunan dosyalarin tam yollari; klasor modunda satir dosyalari ada gore sirali.</summary>
public IReadOnlyList<string> GetFiles()
{
if (!IsFolder)
return File.Exists(FullPath) ? [FullPath] : [];
return Directory.Exists(FullPath)
? [.. Directory.EnumerateFiles(FullPath, "*.json").OrderBy(path => path, StringComparer.OrdinalIgnoreCase)]
: [];
}
/// <summary><see cref="GetFiles"/> sonucunun seed kokune gore goreli, <c>/</c> ayracli hali.</summary>
public IReadOnlyList<string> GetRelativeFiles() =>
[.. GetFiles().Select(path => Path.GetRelativePath(scopeRoot, path).Replace('\\', '/'))];
/// <summary>Hedefteki dosyalari okur; okunamayan dosyada <see cref="JsonException"/> cagirana gider.</summary>
public async Task<List<(string Path, ListFormSeedDataFileDto File)>> ReadFilesAsync()
{
var files = new List<(string, ListFormSeedDataFileDto)>();
foreach (var path in GetFiles())
{
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions);
if (file != null)
files.Add((path, file));
}
return files;
}
/// <summary>Hedefteki butun satirlar; klasor modunda dosya sirasiyla birlestirilir.</summary>
public async Task<List<Dictionary<string, object>>> ReadRowsAsync() =>
[.. (await ReadFilesAsync()).SelectMany(file => file.File.Rows ?? [])];
/// <summary>Klasor modunda satirin dosya yolu; anahtar dosya adina cevrilemezse null.</summary>
public string? GetRowFilePath(object? key)
{
var fileName = SeedPathResolver.GetRowFileName(KeyText(key));
return fileName == null ? null : Path.Combine(FullPath, fileName);
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<object>> UpsertRowsAsync(
string listFormCode,
string keyFieldName,
IEnumerable<(object Key, Dictionary<string, object> Row)> rows)
{
var skipped = new List<object>();
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;
}
/// <summary>
/// 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 <paramref name="defaultOrder"/> yazilir. Anahtari dosya adina
/// cevrilemeyen satirlarin sayisini dondurur.
/// </summary>
public async Task<int> ReplaceRowsAsync(string listFormCode, string keyFieldName, List<Dictionary<string, object>> 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<string>(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;
}
/// <summary>Anahtari verilen satirlari hedeften cikarir.</summary>
public async Task RemoveRowsAsync(string listFormCode, string keyFieldName, IEnumerable<object> 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);
}
/// <summary>
/// <paramref name="matches"/> true donen satirlari hedeften cikarir; dosyanin basligina dokunulmaz.
/// Klasor modunda bosalan satir dosyasi silinir: klasorde kalirsa applier onu satirsiz bir ekran
/// dosyasi olarak okurdu.
/// </summary>
public async Task RemoveRowsAsync(Func<Dictionary<string, object>, 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);
}
}
/// <summary>Hedef ya da icindekiler diskten silinir; silinecek bir sey yoksa false.</summary>
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;
}
/// <summary>
/// Klasordeki satir dosyalarinin tasidigi uygulama sirasi (<c>Order</c>); bir ekranin butun satir
/// dosyalarinda ayni degerdir, bu yuzden ilk kardes dosyadan okunur. Kardes yoksa null.
/// </summary>
private async Task<int?> ReadFolderOrderAsync()
{
var sibling = GetFiles().FirstOrDefault();
if (sibling == null)
return null;
try
{
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(sibling), ListFormSeedDataFileDto.JsonOptions);
return file?.Order ?? 0;
}
catch (JsonException)
{
return 0;
}
}
/// <summary>Anahtarlar dosyada JsonElement, istekte string/Guid olarak gelir; metin uzerinden karsilastirilir.</summary>
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<Dictionary<string, object>> 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<ListFormSeedDataFileDto> ReadFileAsync(string filePath, string listFormCode, string keyFieldName)
{
var file = File.Exists(filePath)
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(filePath), ListFormSeedDataFileDto.JsonOptions)
: null;
file ??= new ListFormSeedDataFileDto();
file.ListFormCode = listFormCode;
file.KeyFieldName = keyFieldName;
file.Rows ??= [];
return file;
}
/// <summary>
/// Icerik degismediyse dosyaya dokunulmaz: klasor modunda hedef bastan yazildiginda yalnizca
/// degisen satirin dosyasi yazilir, digerleri git'te ve diskte iz birakmaz.
/// </summary>
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);
}
}

View file

@ -1,12 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Sozsoft.Notifications.Entities; using Sozsoft.Notifications.Entities;
using Sozsoft.Platform.Entities;
using Volo.Abp.Data; using Volo.Abp.Data;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
@ -27,11 +27,12 @@ namespace Sozsoft.Platform.Data.Seeds;
public class NotificationTenantDefaultsSeedContributor( public class NotificationTenantDefaultsSeedContributor(
IRepository<NotificationType, Guid> notificationTypeRepository, IRepository<NotificationType, Guid> notificationTypeRepository,
IRepository<NotificationRule, Guid> notificationRuleRepository, IRepository<NotificationRule, Guid> notificationRuleRepository,
IRepository<ListForm, Guid> listFormRepository,
IConfiguration configuration, IConfiguration configuration,
ILogger<NotificationTenantDefaultsSeedContributor> logger) : IDataSeedContributor, ITransientDependency ILogger<NotificationTenantDefaultsSeedContributor> logger) : IDataSeedContributor, ITransientDependency
{ {
private const string TypesFile = "App.Notifications.NotificationTypes"; private const string TypesListFormCode = PlatformConsts.AppCodes.Notifications.NotificationTypes;
private const string RulesFile = "App.Notifications.NotificationRules"; private const string RulesListFormCode = PlatformConsts.AppCodes.Notifications.NotificationRules;
public async Task SeedAsync(DataSeedContext context) public async Task SeedAsync(DataSeedContext context)
{ {
@ -43,7 +44,7 @@ public class NotificationTenantDefaultsSeedContributor(
return; return;
} }
var types = ReadRows(TypesFile); var types = await ReadRowsAsync(TypesListFormCode);
if (types.Count == 0) if (types.Count == 0)
{ {
return; return;
@ -82,7 +83,7 @@ public class NotificationTenantDefaultsSeedContributor(
var existingRules = await notificationRuleRepository.GetListAsync(); var existingRules = await notificationRuleRepository.GetListAsync();
foreach (var row in ReadRows(RulesFile)) foreach (var row in await ReadRowsAsync(RulesListFormCode))
{ {
var fileTypeId = GetString(row, "NotificationTypeId"); var fileTypeId = GetString(row, "NotificationTypeId");
if (fileTypeId == null || !typeIdByFileId.TryGetValue(fileTypeId, out var typeId)) if (fileTypeId == null || !typeIdByFileId.TryGetValue(fileTypeId, out var typeId))
@ -122,26 +123,21 @@ public class NotificationTenantDefaultsSeedContributor(
} }
/// <summary> /// <summary>
/// Varsayilan kumeyi veri seed dosyasindan okur; ikinci bir varsayilan /// Varsayilan kumeyi ekranin tanimladigi veri seed hedefinden (tek dosya ya da satir klasoru)
/// listesi kodda tutulmaz. Dosya yoksa kiraci bos baslar, bu bir hata degildir. /// okur; ikinci bir varsayilan listesi kodda tutulmaz. Hedef yoksa kiraci bos baslar, bu bir
/// hata degildir.
/// </summary> /// </summary>
private List<JsonElement> ReadRows(string listFormCode) private async Task<List<JsonElement>> ReadRowsAsync(string listFormCode)
{ {
var path = SeedPathResolver.GetPath( var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode);
configuration, var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath);
SeedPathResolver.GetDataFilePath(listFormCode).Split('/')); if (target == null || !target.Exists)
if (!File.Exists(path))
{ {
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 []; return [];
} }
using var document = JsonDocument.Parse(File.ReadAllText(path)); return [.. (await target.ReadRowsAsync()).Select(row => JsonSerializer.SerializeToElement(row))];
return document.RootElement.TryGetProperty("Rows", out var rows) && rows.ValueKind == JsonValueKind.Array
? [.. rows.EnumerateArray().Select(row => row.Clone())]
: [];
} }
private static string? GetString(JsonElement row, string propertyName) private static string? GetString(JsonElement row, string propertyName)

View file

@ -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 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 **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 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 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; 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, dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Ekran bir hedef tanımlıyorsa
klasörü diskte varsa migrate sırasında klasöre taşınır. 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 Migrate/seed sırasında ve wizard import'unun kapanışında
(`CompleteImport`) `ListFormSeedDataApplier` dosyayı geri uygular. Migrate/seed'de kayıt yoksa (`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 eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki