Enhance grid import/export functionality to support dynamic groups and collections
Updated the grid import/export process to allow for the inclusion of groups and collections directly from the configuration file. This enhancement ensures that missing groups and collections are created during import, improving the flexibility of the grid management system. Adjusted related classes and methods to accommodate these changes, ensuring a cohesive integration. Enhanced documentation and user prompts to clarify the new functionality and its usage.
This commit is contained in:
@@ -38,7 +38,12 @@ public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.GroupRequired);
|
||||
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
// Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит
|
||||
// слоты в том же прогоне, а запрос увидел бы их только после сохранения.
|
||||
var known =
|
||||
dbContext.Groups.Local.Any(g => g.Id == groupId)
|
||||
|| await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -19,7 +19,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
|
||||
/// обрезается, и об обрезке прямо написано в запросе.
|
||||
/// </summary>
|
||||
private const int ShowLimit = 150;
|
||||
private const int ShowLimit = 300;
|
||||
|
||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
||||
|
||||
@@ -116,8 +116,9 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
}
|
||||
|
||||
text.AppendLine(
|
||||
"Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько "
|
||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы."
|
||||
"Эти группы уже собраны — их имена можно ставить в слоты как есть. «Единиц» — сколько "
|
||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы. "
|
||||
+ "Если нужного разреза библиотеки здесь нет, собери свою группу в разделе groups."
|
||||
);
|
||||
text.AppendLine();
|
||||
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
|
||||
@@ -149,8 +150,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
}
|
||||
|
||||
text.AppendLine(
|
||||
$"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; "
|
||||
+ "слоты всё равно ссылаются на группы, а не на шоу."
|
||||
$"Всего готового к эфиру: {shows.Count}. Названия — ключ: собирая свои группы, пиши их "
|
||||
+ "дословно отсюда, иначе шоу не найдётся при загрузке."
|
||||
);
|
||||
text.AppendLine();
|
||||
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
|
||||
|
||||
+105
-3
@@ -3,12 +3,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
|
||||
public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
||||
public sealed class ExportGridQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
DynamicGroupResolver dynamicResolver
|
||||
) : IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
||||
{
|
||||
public async Task<Result<GridConfig>> Handle(
|
||||
ExportGridQuery query,
|
||||
@@ -54,6 +57,21 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Состав групп едет вместе с сеткой: на другой установке тех же групп нет, а слот без
|
||||
// группы поставить некуда. Собираем только использованные — выгружать всю библиотеку
|
||||
// ради одного канала незачем.
|
||||
var used = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Select(s => s.Group)
|
||||
.Append(Name(template.FallbackGroupId, groups))
|
||||
.OfType<string>()
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var (exportedGroups, exportedCollections) = await ExportGroupsAsync(
|
||||
used,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(
|
||||
new GridConfig(
|
||||
layers,
|
||||
@@ -64,11 +82,95 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
),
|
||||
Rules: PlanningRules.FromJson(template.RulesJson),
|
||||
FallbackGroup: Name(template.FallbackGroupId, groups),
|
||||
DefaultJunction: Name(template.DefaultJunctionId, junctions)
|
||||
DefaultJunction: Name(template.DefaultJunctionId, junctions),
|
||||
Collections: exportedCollections,
|
||||
Groups: exportedGroups
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Состав использованных групп и коллекций именами. Динамическая группа выгружается снимком
|
||||
/// вычисленного состава, а не правилом: правило ссылается на жанры и рейтинги этой установки,
|
||||
/// и на чужой переехало бы бессмыслицей.
|
||||
/// </summary>
|
||||
private async Task<(
|
||||
List<GridConfigGroup> Groups,
|
||||
List<GridConfigCollection> Collections
|
||||
)> ExportGroupsAsync(IReadOnlySet<string> used, CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showNames = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
var collectionNames = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
|
||||
|
||||
var exported = new List<GridConfigGroup>();
|
||||
var collectionIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var group in groups.Where(g => used.Contains(g.Name.Trim())))
|
||||
{
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
|
||||
var shows = composition
|
||||
.Where(e => e.Kind == GroupElementKind.Show)
|
||||
.Select(e => showNames.GetValueOrDefault(e.Id))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
var collections = new List<string>();
|
||||
foreach (var element in composition.Where(e => e.Kind == GroupElementKind.Collection))
|
||||
{
|
||||
if (collectionNames.GetValueOrDefault(element.Id) is not { } name)
|
||||
continue;
|
||||
collections.Add(name);
|
||||
collectionIds.Add(element.Id);
|
||||
}
|
||||
|
||||
exported.Add(
|
||||
new GridConfigGroup(
|
||||
group.Name,
|
||||
shows.Count > 0 ? shows : null,
|
||||
collections.Count > 0 ? collections : null,
|
||||
group.Description
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var items = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var exportedCollections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => collectionIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (
|
||||
exported,
|
||||
[
|
||||
.. exportedCollections.Select(collection => new GridConfigCollection(
|
||||
collection.Name,
|
||||
[
|
||||
.. items
|
||||
.Where(i => i.CollectionId == collection.Id)
|
||||
.Select(i => showNames.GetValueOrDefault(i.ShowId))
|
||||
.OfType<string>(),
|
||||
],
|
||||
collection.Description
|
||||
)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private static GridConfigSlot ToConfig(
|
||||
Slot slot,
|
||||
IReadOnlyDictionary<Guid, string> groups,
|
||||
|
||||
@@ -44,6 +44,28 @@ public sealed record GridConfigLayer(
|
||||
LayerApplicability? Applicability = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Коллекция в формате обмена: франшиза или цикл, собранный из шоу по названиям. Нужна там, где
|
||||
/// в слот ставится не одно шоу, а серия фильмов целиком.
|
||||
/// </summary>
|
||||
public sealed record GridConfigCollection(
|
||||
string Name,
|
||||
IReadOnlyList<string> Shows,
|
||||
string? Description = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Группа в формате обмена: список шоу и коллекций по названиям. Слот ссылается на группу, поэтому
|
||||
/// собрать сетку под библиотеку, где групп ещё нет, можно только вместе с ними — импорт заводит
|
||||
/// недостающие сам.
|
||||
/// </summary>
|
||||
public sealed record GridConfigGroup(
|
||||
string Name,
|
||||
IReadOnlyList<string>? Shows = null,
|
||||
IReadOnlyList<string>? Collections = null,
|
||||
string? Description = null
|
||||
);
|
||||
|
||||
/// <summary>Параметры канала — переносятся справочно: импорт их не применяет.</summary>
|
||||
public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes);
|
||||
|
||||
@@ -61,7 +83,11 @@ public sealed record GridConfig(
|
||||
/// <summary>Имя аварийной группы — ею закрываются паузы между слотами.</summary>
|
||||
string? FallbackGroup = null,
|
||||
/// <summary>Имя стыка по умолчанию.</summary>
|
||||
string? DefaultJunction = null
|
||||
string? DefaultJunction = null,
|
||||
/// <summary>Коллекции, которых может не быть в библиотеке: импорт заводит недостающие.</summary>
|
||||
IReadOnlyList<GridConfigCollection>? Collections = null,
|
||||
/// <summary>Группы, на которые ссылаются слоты. Существующие по имени не трогаются.</summary>
|
||||
IReadOnlyList<GridConfigGroup>? Groups = null
|
||||
)
|
||||
{
|
||||
public const string CurrentFormat = "telewave.grid";
|
||||
|
||||
@@ -18,8 +18,17 @@ public static class GridPromptText
|
||||
- Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются
|
||||
им же следующего дня. Ночь после полуночи принадлежит предыдущему дню.
|
||||
- Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик
|
||||
из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка
|
||||
доступных ниже. Придумывать новые группы, шоу и стыки нельзя.
|
||||
из указанной группы. Поэтому группа задаётся именем.
|
||||
- Группы можно собирать самому: перечисли их в разделе groups, набрав из шоу библиотеки —
|
||||
«Вечерние сериалы», «Детское утро», «Кино 90-х». Тех, что уже есть в списке доступных
|
||||
групп, заводить заново не надо: имя оттуда просто ставится в слот. Группы с новым именем
|
||||
будут созданы при загрузке файла.
|
||||
- Коллекция — это цикл или франшиза («Терминатор», «Пираты Карибского моря»); собирается
|
||||
в разделе collections и потом целиком кладётся в группу. Нужна там, где в слот идёт серия
|
||||
фильмов, а не отдельное кино.
|
||||
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||
@@ -51,6 +60,17 @@ public static class GridPromptText
|
||||
},
|
||||
"fallbackGroup": "имя группы", // чем закрывать паузы
|
||||
"defaultJunction": "имя стыка", // стык между блоками по умолчанию
|
||||
"collections": [ // необязательно: циклы и франшизы из шоу
|
||||
{ "name": "Терминатор", "shows": ["Терминатор", "Терминатор 2"] }
|
||||
],
|
||||
"groups": [ // необязательно: группы, которых ещё нет
|
||||
{
|
||||
"name": "Вечерние сериалы",
|
||||
"shows": ["Симпсоны", "Футурама"], // названия дословно из библиотеки
|
||||
"collections": ["Терминатор"], // целиком, если нужен весь цикл
|
||||
"description": "чем канал занимает прайм"
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
{
|
||||
"name": "Основная сетка",
|
||||
|
||||
@@ -21,6 +21,9 @@ public sealed record GridImportResultDto(
|
||||
int Slots,
|
||||
int Skipped,
|
||||
int Removed,
|
||||
/// <summary>Сколько групп и коллекций файл принёс с собой — существующие не пересоздаются.</summary>
|
||||
int Groups,
|
||||
int Collections,
|
||||
IReadOnlyList<string> Warnings
|
||||
);
|
||||
|
||||
@@ -35,6 +38,12 @@ public sealed class ImportGridCommandValidator : AbstractValidator<ImportGridCom
|
||||
RuleFor(x => x.Config.Layers).NotEmpty();
|
||||
// Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч.
|
||||
RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64);
|
||||
RuleFor(x => x.Config.Groups!.Count)
|
||||
.LessThanOrEqualTo(256)
|
||||
.When(x => x.Config.Groups is not null);
|
||||
RuleFor(x => x.Config.Collections!.Count)
|
||||
.LessThanOrEqualTo(256)
|
||||
.When(x => x.Config.Collections is not null);
|
||||
RuleForEach(x => x.Config.Layers)
|
||||
.ChildRules(layer =>
|
||||
{
|
||||
|
||||
+133
-7
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
@@ -11,10 +13,17 @@ namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
|
||||
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
|
||||
///
|
||||
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
|
||||
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
|
||||
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
|
||||
///
|
||||
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
|
||||
/// </summary>
|
||||
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
|
||||
: ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||
public sealed class ImportGridCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
SlotWriter writer,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||
{
|
||||
public async Task<Result<GridImportResultDto>> Handle(
|
||||
ImportGridCommand command,
|
||||
@@ -29,16 +38,22 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
if (template is null)
|
||||
return Result.Failure<GridImportResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var groups = await NamesAsync(
|
||||
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
var junctions = await NamesAsync(
|
||||
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла
|
||||
// обязаны существовать к моменту, когда до него дойдёт очередь.
|
||||
var collections = await EnsureCollectionsAsync(command.Config, warnings, cancellationToken);
|
||||
var (groups, createdGroups) = await EnsureGroupsAsync(
|
||||
command.Config,
|
||||
collections.Names,
|
||||
warnings,
|
||||
cancellationToken
|
||||
);
|
||||
var removed = command.Replace ? Clear(template) : 0;
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
@@ -79,11 +94,122 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
created,
|
||||
skipped,
|
||||
removed,
|
||||
createdGroups,
|
||||
collections.Created,
|
||||
warnings
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
|
||||
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
|
||||
/// </summary>
|
||||
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureCollectionsAsync(
|
||||
GridConfig config,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var names = await NamesAsync(
|
||||
dbContext.Collections.AsNoTracking().Select(c => new NamedRef(c.Id, c.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
if (config.Collections is not { Count: > 0 } wanted)
|
||||
return (names, 0);
|
||||
|
||||
var shows = await ShowNamesAsync(cancellationToken);
|
||||
var created = 0;
|
||||
|
||||
foreach (var configCollection in wanted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configCollection.Name))
|
||||
continue;
|
||||
if (names.ContainsKey(configCollection.Name.Trim()))
|
||||
continue;
|
||||
|
||||
var collection = Collection.Create(configCollection.Name, configCollection.Description);
|
||||
foreach (var showName in configCollection.Shows ?? [])
|
||||
{
|
||||
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
|
||||
collection.AddShow(showId);
|
||||
}
|
||||
|
||||
dbContext.Collections.Add(collection);
|
||||
names[collection.Name] = collection.Id;
|
||||
created++;
|
||||
}
|
||||
|
||||
return (names, created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов.
|
||||
/// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает,
|
||||
/// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит
|
||||
/// в слотах.
|
||||
/// </summary>
|
||||
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureGroupsAsync(
|
||||
GridConfig config,
|
||||
IReadOnlyDictionary<string, Guid> collections,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var names = await NamesAsync(
|
||||
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
if (config.Groups is not { Count: > 0 } wanted)
|
||||
return (names, 0);
|
||||
|
||||
var shows = await ShowNamesAsync(cancellationToken);
|
||||
var created = 0;
|
||||
|
||||
foreach (var configGroup in wanted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configGroup.Name))
|
||||
continue;
|
||||
if (names.ContainsKey(configGroup.Name.Trim()))
|
||||
continue;
|
||||
|
||||
var group = Group.Create(configGroup.Name, configGroup.Description);
|
||||
|
||||
foreach (var showName in configGroup.Shows ?? [])
|
||||
{
|
||||
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
|
||||
group.AddElement(GroupElementKind.Show, showId);
|
||||
}
|
||||
|
||||
foreach (var collectionName in configGroup.Collections ?? [])
|
||||
{
|
||||
if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id)
|
||||
group.AddElement(GroupElementKind.Collection, id);
|
||||
}
|
||||
|
||||
if (group.Items.Count == 0)
|
||||
warnings.Add(
|
||||
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
|
||||
);
|
||||
|
||||
dbContext.Groups.Add(group);
|
||||
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
|
||||
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
names[group.Name] = group.Id;
|
||||
created++;
|
||||
}
|
||||
|
||||
return (names, created);
|
||||
}
|
||||
|
||||
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
|
||||
private Task<Dictionary<string, Guid>> ShowNamesAsync(CancellationToken cancellationToken) =>
|
||||
NamesAsync(
|
||||
dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
|
||||
private static int Clear(ScheduleTemplate template)
|
||||
{
|
||||
@@ -214,7 +340,7 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
if (known.TryGetValue(name.Trim(), out var id))
|
||||
return id;
|
||||
|
||||
var warning = $"{subject} «{name}» не найдена в библиотеке.";
|
||||
var warning = $"{subject} «{name}»: в библиотеке такого нет.";
|
||||
if (!warnings.Contains(warning, StringComparer.Ordinal))
|
||||
warnings.Add(warning);
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user