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:
@@ -155,6 +155,6 @@ docker compose up -d --build # только api; postgres — внешний,
|
|||||||
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
|
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
|
||||||
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
|
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
|
||||||
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
|
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
|
||||||
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
|
- Соблюдай границы слоёв — главный инвариант проекта
|
||||||
- Не коммить и не пуши без явной просьбы.
|
- Не коммить и не пуши без явной просьбы.
|
||||||
- Отвечай пользователю на русском.
|
- Отвечай пользователю на русском.
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ public sealed class SlotWriter(IAppDbContext dbContext)
|
|||||||
if (input.GroupId is not { } groupId)
|
if (input.GroupId is not { } groupId)
|
||||||
return Result.Failure(TemplateErrors.GroupRequired);
|
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);
|
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -19,7 +19,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
|||||||
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
|
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
|
||||||
/// обрезается, и об обрезке прямо написано в запросе.
|
/// обрезается, и об обрезке прямо написано в запросе.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int ShowLimit = 150;
|
private const int ShowLimit = 300;
|
||||||
|
|
||||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
||||||
|
|
||||||
@@ -116,8 +116,9 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
|||||||
}
|
}
|
||||||
|
|
||||||
text.AppendLine(
|
text.AppendLine(
|
||||||
"Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько "
|
"Эти группы уже собраны — их имена можно ставить в слоты как есть. «Единиц» — сколько "
|
||||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы."
|
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы. "
|
||||||
|
+ "Если нужного разреза библиотеки здесь нет, собери свою группу в разделе groups."
|
||||||
);
|
);
|
||||||
text.AppendLine();
|
text.AppendLine();
|
||||||
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
|
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
|
||||||
@@ -149,8 +150,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
|||||||
}
|
}
|
||||||
|
|
||||||
text.AppendLine(
|
text.AppendLine(
|
||||||
$"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; "
|
$"Всего готового к эфиру: {shows.Count}. Названия — ключ: собирая свои группы, пиши их "
|
||||||
+ "слоты всё равно ссылаются на группы, а не на шоу."
|
+ "дословно отсюда, иначе шоу не найдётся при загрузке."
|
||||||
);
|
);
|
||||||
text.AppendLine();
|
text.AppendLine();
|
||||||
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
|
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
|
||||||
|
|||||||
+105
-3
@@ -3,12 +3,15 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||||
|
|
||||||
public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
public sealed class ExportGridQueryHandler(
|
||||||
: IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
IAppDbContext dbContext,
|
||||||
|
DynamicGroupResolver dynamicResolver
|
||||||
|
) : IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
||||||
{
|
{
|
||||||
public async Task<Result<GridConfig>> Handle(
|
public async Task<Result<GridConfig>> Handle(
|
||||||
ExportGridQuery query,
|
ExportGridQuery query,
|
||||||
@@ -54,6 +57,21 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
|||||||
))
|
))
|
||||||
.ToList();
|
.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(
|
return Result.Success(
|
||||||
new GridConfig(
|
new GridConfig(
|
||||||
layers,
|
layers,
|
||||||
@@ -64,11 +82,95 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
|||||||
),
|
),
|
||||||
Rules: PlanningRules.FromJson(template.RulesJson),
|
Rules: PlanningRules.FromJson(template.RulesJson),
|
||||||
FallbackGroup: Name(template.FallbackGroupId, groups),
|
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(
|
private static GridConfigSlot ToConfig(
|
||||||
Slot slot,
|
Slot slot,
|
||||||
IReadOnlyDictionary<Guid, string> groups,
|
IReadOnlyDictionary<Guid, string> groups,
|
||||||
|
|||||||
@@ -44,6 +44,28 @@ public sealed record GridConfigLayer(
|
|||||||
LayerApplicability? Applicability = null
|
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>
|
/// <summary>Параметры канала — переносятся справочно: импорт их не применяет.</summary>
|
||||||
public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes);
|
public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes);
|
||||||
|
|
||||||
@@ -61,7 +83,11 @@ public sealed record GridConfig(
|
|||||||
/// <summary>Имя аварийной группы — ею закрываются паузы между слотами.</summary>
|
/// <summary>Имя аварийной группы — ею закрываются паузы между слотами.</summary>
|
||||||
string? FallbackGroup = null,
|
string? FallbackGroup = null,
|
||||||
/// <summary>Имя стыка по умолчанию.</summary>
|
/// <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";
|
public const string CurrentFormat = "telewave.grid";
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ public static class GridPromptText
|
|||||||
- Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются
|
- Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются
|
||||||
им же следующего дня. Ночь после полуночи принадлежит предыдущему дню.
|
им же следующего дня. Ночь после полуночи принадлежит предыдущему дню.
|
||||||
- Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик
|
- Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик
|
||||||
из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка
|
из указанной группы. Поэтому группа задаётся именем.
|
||||||
доступных ниже. Придумывать новые группы, шоу и стыки нельзя.
|
- Группы можно собирать самому: перечисли их в разделе groups, набрав из шоу библиотеки —
|
||||||
|
«Вечерние сериалы», «Детское утро», «Кино 90-х». Тех, что уже есть в списке доступных
|
||||||
|
групп, заводить заново не надо: имя оттуда просто ставится в слот. Группы с новым именем
|
||||||
|
будут созданы при загрузке файла.
|
||||||
|
- Коллекция — это цикл или франшиза («Терминатор», «Пираты Карибского моря»); собирается
|
||||||
|
в разделе collections и потом целиком кладётся в группу. Нужна там, где в слот идёт серия
|
||||||
|
фильмов, а не отдельное кино.
|
||||||
|
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||||
|
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||||
|
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||||
@@ -51,6 +60,17 @@ public static class GridPromptText
|
|||||||
},
|
},
|
||||||
"fallbackGroup": "имя группы", // чем закрывать паузы
|
"fallbackGroup": "имя группы", // чем закрывать паузы
|
||||||
"defaultJunction": "имя стыка", // стык между блоками по умолчанию
|
"defaultJunction": "имя стыка", // стык между блоками по умолчанию
|
||||||
|
"collections": [ // необязательно: циклы и франшизы из шоу
|
||||||
|
{ "name": "Терминатор", "shows": ["Терминатор", "Терминатор 2"] }
|
||||||
|
],
|
||||||
|
"groups": [ // необязательно: группы, которых ещё нет
|
||||||
|
{
|
||||||
|
"name": "Вечерние сериалы",
|
||||||
|
"shows": ["Симпсоны", "Футурама"], // названия дословно из библиотеки
|
||||||
|
"collections": ["Терминатор"], // целиком, если нужен весь цикл
|
||||||
|
"description": "чем канал занимает прайм"
|
||||||
|
}
|
||||||
|
],
|
||||||
"layers": [
|
"layers": [
|
||||||
{
|
{
|
||||||
"name": "Основная сетка",
|
"name": "Основная сетка",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public sealed record GridImportResultDto(
|
|||||||
int Slots,
|
int Slots,
|
||||||
int Skipped,
|
int Skipped,
|
||||||
int Removed,
|
int Removed,
|
||||||
|
/// <summary>Сколько групп и коллекций файл принёс с собой — существующие не пересоздаются.</summary>
|
||||||
|
int Groups,
|
||||||
|
int Collections,
|
||||||
IReadOnlyList<string> Warnings
|
IReadOnlyList<string> Warnings
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,6 +38,12 @@ public sealed class ImportGridCommandValidator : AbstractValidator<ImportGridCom
|
|||||||
RuleFor(x => x.Config.Layers).NotEmpty();
|
RuleFor(x => x.Config.Layers).NotEmpty();
|
||||||
// Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч.
|
// Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч.
|
||||||
RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64);
|
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)
|
RuleForEach(x => x.Config.Layers)
|
||||||
.ChildRules(layer =>
|
.ChildRules(layer =>
|
||||||
{
|
{
|
||||||
|
|||||||
+133
-7
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||||
@@ -11,10 +13,17 @@ namespace TeleWave.Application.Programming.Templates.Transfer;
|
|||||||
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
|
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
|
||||||
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
|
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
|
||||||
///
|
///
|
||||||
|
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
|
||||||
|
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
|
||||||
|
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
|
||||||
|
///
|
||||||
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
|
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
|
public sealed class ImportGridCommandHandler(
|
||||||
: ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
IAppDbContext dbContext,
|
||||||
|
SlotWriter writer,
|
||||||
|
GroupStatsService stats
|
||||||
|
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<GridImportResultDto>> Handle(
|
public async Task<Result<GridImportResultDto>> Handle(
|
||||||
ImportGridCommand command,
|
ImportGridCommand command,
|
||||||
@@ -29,16 +38,22 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
|||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<GridImportResultDto>(ChannelErrors.TemplateNotFound);
|
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(
|
var junctions = await NamesAsync(
|
||||||
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
|
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
var warnings = new List<string>();
|
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 removed = command.Replace ? Clear(template) : 0;
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
@@ -79,11 +94,122 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
|||||||
created,
|
created,
|
||||||
skipped,
|
skipped,
|
||||||
removed,
|
removed,
|
||||||
|
createdGroups,
|
||||||
|
collections.Created,
|
||||||
warnings
|
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>
|
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
|
||||||
private static int Clear(ScheduleTemplate template)
|
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))
|
if (known.TryGetValue(name.Trim(), out var id))
|
||||||
return id;
|
return id;
|
||||||
|
|
||||||
var warning = $"{subject} «{name}» не найдена в библиотеке.";
|
var warning = $"{subject} «{name}»: в библиотеке такого нет.";
|
||||||
if (!warnings.Contains(warning, StringComparer.Ordinal))
|
if (!warnings.Contains(warning, StringComparer.Ordinal))
|
||||||
warnings.Add(warning);
|
warnings.Add(warning);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public class GridTransferTests
|
|||||||
private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
|
private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
|
||||||
{
|
{
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new ExportGridQueryHandler(db).Handle(
|
var result = await new ExportGridQueryHandler(db, GroupServices.Dynamic(db)).Handle(
|
||||||
new ExportGridQuery(channelId),
|
new ExportGridQuery(channelId),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -111,7 +111,7 @@ public class GridTransferTests
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle(
|
var result = await new ImportGridCommandHandler(db, new SlotWriter(db), Stats(db)).Handle(
|
||||||
new ImportGridCommand(channelId, config, replace),
|
new ImportGridCommand(channelId, config, replace),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -291,6 +291,179 @@ public class GridTransferTests
|
|||||||
Assert.True(stored.HasPendingChanges);
|
Assert.True(stored.HasPendingChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_CreatesGroupsFromShows()
|
||||||
|
{
|
||||||
|
// Сетка, собранная ИИ под ещё не разобранную библиотеку: групп нет, и положить слоты
|
||||||
|
// некуда — значит файл приносит группы с собой.
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||||
|
|
||||||
|
var config = new GridConfig(
|
||||||
|
[
|
||||||
|
new GridConfigLayer(
|
||||||
|
"Основная сетка",
|
||||||
|
10,
|
||||||
|
[
|
||||||
|
new GridConfigSlot(
|
||||||
|
"Вечерний блок",
|
||||||
|
new TimeOnly(20, 0),
|
||||||
|
120,
|
||||||
|
Daypart.Prime,
|
||||||
|
Group: "Вечерние сериалы"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Groups: [new GridConfigGroup("Вечерние сериалы", Shows: ["Сериал"])]
|
||||||
|
);
|
||||||
|
|
||||||
|
var imported = await ImportAsync(fixture, channelId, config);
|
||||||
|
|
||||||
|
Assert.Equal(1, imported.Groups);
|
||||||
|
Assert.Equal(1, imported.Slots);
|
||||||
|
Assert.Empty(imported.Warnings);
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var group = await db
|
||||||
|
.Groups.Include(g => g.Items)
|
||||||
|
.FirstAsync(g => g.Name == "Вечерние сериалы", CancellationToken.None);
|
||||||
|
Assert.Single(group.Items);
|
||||||
|
// Статистика — кэш карточки группы, и пустой после создания она быть не должна.
|
||||||
|
Assert.Equal(1, group.UnitCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_CreatesCollections_AndPutsThemIntoGroups()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||||
|
|
||||||
|
var config = new GridConfig(
|
||||||
|
[
|
||||||
|
new GridConfigLayer(
|
||||||
|
"Основная сетка",
|
||||||
|
10,
|
||||||
|
[
|
||||||
|
new GridConfigSlot(
|
||||||
|
"Кино",
|
||||||
|
new TimeOnly(20, 0),
|
||||||
|
120,
|
||||||
|
Daypart.Prime,
|
||||||
|
Group: "Циклы"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Collections: [new GridConfigCollection("Франшиза", ["Сериал"])],
|
||||||
|
Groups: [new GridConfigGroup("Циклы", Collections: ["Франшиза"])]
|
||||||
|
);
|
||||||
|
|
||||||
|
var imported = await ImportAsync(fixture, channelId, config);
|
||||||
|
|
||||||
|
Assert.Equal(1, imported.Collections);
|
||||||
|
Assert.Equal(1, imported.Groups);
|
||||||
|
Assert.Empty(imported.Warnings);
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var collection = await db
|
||||||
|
.Collections.Include(c => c.Items)
|
||||||
|
.FirstAsync(c => c.Name == "Франшиза", CancellationToken.None);
|
||||||
|
Assert.Single(collection.Items);
|
||||||
|
|
||||||
|
var group = await db
|
||||||
|
.Groups.Include(g => g.Items)
|
||||||
|
.FirstAsync(g => g.Name == "Циклы", CancellationToken.None);
|
||||||
|
var item = Assert.Single(group.Items);
|
||||||
|
Assert.Equal(GroupElementKind.Collection, item.ElementKind);
|
||||||
|
Assert.Equal(collection.Id, item.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_DoesNotTouchExistingGroup()
|
||||||
|
{
|
||||||
|
// Группа с этим именем уже стоит в слотах соседнего канала: переписать её состав чужим
|
||||||
|
// файлом значит молча перекроить чужой эфир.
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||||
|
|
||||||
|
var config = new GridConfig(
|
||||||
|
[new GridConfigLayer("Основная сетка", 10, [])],
|
||||||
|
Groups: [new GridConfigGroup("Сериалы", Shows: ["Сериал", "Сериал"])]
|
||||||
|
);
|
||||||
|
|
||||||
|
var imported = await ImportAsync(fixture, channelId, config);
|
||||||
|
|
||||||
|
Assert.Equal(0, imported.Groups);
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var group = await db
|
||||||
|
.Groups.Include(g => g.Items)
|
||||||
|
.FirstAsync(g => g.Name == "Сериалы", CancellationToken.None);
|
||||||
|
Assert.Single(group.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_WarnsAboutUnknownShow_AndEmptyGroup()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||||
|
|
||||||
|
var config = new GridConfig(
|
||||||
|
[new GridConfigLayer("Основная сетка", 10, [])],
|
||||||
|
Groups: [new GridConfigGroup("Придуманное", Shows: ["Шоу, которого нет"])]
|
||||||
|
);
|
||||||
|
|
||||||
|
var imported = await ImportAsync(fixture, channelId, config);
|
||||||
|
|
||||||
|
Assert.Equal(1, imported.Groups);
|
||||||
|
Assert.Contains(
|
||||||
|
imported.Warnings,
|
||||||
|
w => w.Contains("Шоу, которого нет", StringComparison.Ordinal)
|
||||||
|
);
|
||||||
|
// Пустая группа — это дыра в эфире, и сказать об этом надо на импорте, а не после эфира.
|
||||||
|
Assert.Contains(
|
||||||
|
imported.Warnings,
|
||||||
|
w => w.Contains("создана пустой", StringComparison.Ordinal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_CarriesCompositionOfUsedGroups()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture);
|
||||||
|
|
||||||
|
var config = await ExportAsync(fixture, channelId);
|
||||||
|
|
||||||
|
// Файл должен быть самодостаточным: на другой установке этих групп нет, а слот без группы
|
||||||
|
// положить некуда.
|
||||||
|
var group = Assert.Single(config.Groups ?? []);
|
||||||
|
Assert.Equal("Сериалы", group.Name);
|
||||||
|
Assert.Equal(["Сериал"], group.Shows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prompt_ExplainsHowToBuildGroups()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channelId = await SeedAsync(fixture);
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
|
||||||
|
new BuildGridPromptQuery(channelId, [], null),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var prompt = result.Value.Prompt;
|
||||||
|
|
||||||
|
// Модель должна знать и что группы можно собрать самой, и что названия шоу берутся дословно.
|
||||||
|
Assert.Contains("\"groups\"", prompt, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"collections\"", prompt, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("дословно", prompt, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Prompt_ListsGroupsAndSchema()
|
public async Task Prompt_ListsGroupsAndSchema()
|
||||||
{
|
{
|
||||||
@@ -375,4 +548,7 @@ public class GridTransferTests
|
|||||||
|
|
||||||
private static GroupCatalog Catalog(IAppDbContext db) =>
|
private static GroupCatalog Catalog(IAppDbContext db) =>
|
||||||
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
|
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
|
||||||
|
|
||||||
|
private static GroupStatsService Stats(IAppDbContext db) =>
|
||||||
|
new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1083,14 +1083,30 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
|||||||
«Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир
|
«Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир
|
||||||
импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения.
|
импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения.
|
||||||
|
|
||||||
|
**Файл приносит группы с собой.** Слот ссылается на группу, а на чужой установке (и на своей, пока
|
||||||
|
библиотека не разобрана) групп нет — поэтому в файле есть разделы `groups` и `collections`, и
|
||||||
|
недостающее импорт заводит сам: группа из шоу по названиям, коллекция как цикл или франшиза, которая
|
||||||
|
потом кладётся в группу целиком. Порядок жёсткий: коллекции → группы → слоты, иначе слоту нечего
|
||||||
|
разрешать. **Шоу импорт не создаёт никогда** — их приносит загрузка медиа, и придуманное моделью
|
||||||
|
название останется замечанием, а не записью в библиотеке. Группа, у которой не нашлось ни одного
|
||||||
|
шоу, всё равно создаётся, но с замечанием: пустая группа — это дыра в эфире, и сказать об этом надо
|
||||||
|
на импорте.
|
||||||
|
|
||||||
|
Существующая по имени группа **не переписывается**: та же группа стоит в слотах других каналов,
|
||||||
|
и молча перекроить её принесённым файлом значит поменять чужой эфир. Экспорт при этом выгружает
|
||||||
|
состав использованных групп именами, чтобы файл был самодостаточным; динамическая группа
|
||||||
|
выгружается снимком вычисленного состава — правило ссылается на жанры и рейтинги этой установки
|
||||||
|
и на чужую переехало бы бессмыслицей.
|
||||||
|
|
||||||
**Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект
|
**Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект
|
||||||
не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2»)
|
не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2»)
|
||||||
и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется,
|
и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется,
|
||||||
и приносит ответ назад вкладкой импорта.
|
и приносит ответ назад вкладкой импорта.
|
||||||
|
|
||||||
В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же
|
В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же
|
||||||
числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека справкой,
|
числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека поимённо
|
||||||
имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом
|
(модель собирает из неё свои группы, поэтому названия нужны дословные), имена стыков и заставок,
|
||||||
|
правила раскладки и схема ответа. Схема лежит рядом с форматом
|
||||||
(`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом
|
(`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом
|
||||||
— ответ модели пришлось бы чинить руками.
|
— ответ модели пришлось бы чинить руками.
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ export function GridTransferDialog({
|
|||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
// Заведённые группы упоминаем отдельно: файл от модели меняет не только сетку, и узнать
|
||||||
|
// об этом из общего «создано слотов» нельзя.
|
||||||
|
if (result.groups > 0 || result.collections > 0)
|
||||||
|
toast.success(
|
||||||
|
t('admin.channels.transfer.importedLibrary', {
|
||||||
|
groups: result.groups,
|
||||||
|
collections: result.collections,
|
||||||
|
}),
|
||||||
|
)
|
||||||
onImported()
|
onImported()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
|
|||||||
@@ -827,6 +827,21 @@ export type GridConfigLayer = {
|
|||||||
applicability?: LayerApplicability | null
|
applicability?: LayerApplicability | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Коллекция в формате обмена: цикл или франшиза из шоу по названиям. */
|
||||||
|
export type GridConfigCollection = {
|
||||||
|
name: string
|
||||||
|
shows: string[]
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Группа в формате обмена: шоу и коллекции по названиям — импорт заводит недостающие. */
|
||||||
|
export type GridConfigGroup = {
|
||||||
|
name: string
|
||||||
|
shows?: string[] | null
|
||||||
|
collections?: string[] | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type GridConfig = {
|
export type GridConfig = {
|
||||||
format: string
|
format: string
|
||||||
version: number
|
version: number
|
||||||
@@ -834,6 +849,8 @@ export type GridConfig = {
|
|||||||
rules?: PlanningRules | null
|
rules?: PlanningRules | null
|
||||||
fallbackGroup?: string | null
|
fallbackGroup?: string | null
|
||||||
defaultJunction?: string | null
|
defaultJunction?: string | null
|
||||||
|
collections?: GridConfigCollection[] | null
|
||||||
|
groups?: GridConfigGroup[] | null
|
||||||
layers: GridConfigLayer[]
|
layers: GridConfigLayer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,6 +860,8 @@ export type GridImportResultDto = {
|
|||||||
slots: number
|
slots: number
|
||||||
skipped: number
|
skipped: number
|
||||||
removed: number
|
removed: number
|
||||||
|
groups: number
|
||||||
|
collections: number
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -439,16 +439,17 @@ export const en = {
|
|||||||
exportAction: 'Download file',
|
exportAction: 'Download file',
|
||||||
exported: 'File exported',
|
exported: 'File exported',
|
||||||
importHint:
|
importHint:
|
||||||
'Load a file or paste JSON — a model answer, for example. Unknown references do not break the import: such slots are skipped and listed below.',
|
'Load a file or paste JSON — a model answer, for example. The file may bring missing groups and collections with it; shows are never created — those come from media uploads. Unknown references do not break the import: such slots are skipped and listed below.',
|
||||||
pickFile: 'Pick a file',
|
pickFile: 'Pick a file',
|
||||||
replace: 'Remove existing slots',
|
replace: 'Remove existing slots',
|
||||||
pastePlaceholder: 'Paste the grid configuration JSON',
|
pastePlaceholder: 'Paste the grid configuration JSON',
|
||||||
importAction: 'Import',
|
importAction: 'Import',
|
||||||
imported: 'Slots created: {{slots}}, skipped: {{skipped}}',
|
imported: 'Slots created: {{slots}}, skipped: {{skipped}}',
|
||||||
|
importedLibrary: 'Groups created: {{groups}}, collections: {{collections}}',
|
||||||
badJson: 'That does not look like JSON — check that the whole answer was copied.',
|
badJson: 'That does not look like JSON — check that the whole answer was copied.',
|
||||||
warnings: 'What could not be resolved',
|
warnings: 'What could not be resolved',
|
||||||
aiHint:
|
aiHint:
|
||||||
'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model returns JSON that loads on the import tab.',
|
'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model may define the groups it needs — the import creates them.',
|
||||||
references: 'Reference channels',
|
references: 'Reference channels',
|
||||||
referencesPlaceholder: 'Cartoon Network, Paramount Comedy',
|
referencesPlaceholder: 'Cartoon Network, Paramount Comedy',
|
||||||
referencesHint:
|
referencesHint:
|
||||||
|
|||||||
@@ -440,16 +440,17 @@ export const ru = {
|
|||||||
exportAction: 'Скачать файл',
|
exportAction: 'Скачать файл',
|
||||||
exported: 'Файл выгружен',
|
exported: 'Файл выгружен',
|
||||||
importHint:
|
importHint:
|
||||||
'Загрузите файл или вставьте JSON — например ответ модели. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.',
|
'Загрузите файл или вставьте JSON — например ответ модели. Недостающие группы и коллекции файл может принести с собой; шоу не создаются никогда — их приносит загрузка медиа. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.',
|
||||||
pickFile: 'Выбрать файл',
|
pickFile: 'Выбрать файл',
|
||||||
replace: 'Снести существующие слоты',
|
replace: 'Снести существующие слоты',
|
||||||
pastePlaceholder: 'Вставьте JSON конфигурации сетки',
|
pastePlaceholder: 'Вставьте JSON конфигурации сетки',
|
||||||
importAction: 'Загрузить',
|
importAction: 'Загрузить',
|
||||||
imported: 'Создано слотов: {{slots}}, пропущено: {{skipped}}',
|
imported: 'Создано слотов: {{slots}}, пропущено: {{skipped}}',
|
||||||
|
importedLibrary: 'Заведено групп: {{groups}}, коллекций: {{collections}}',
|
||||||
badJson: 'Это не похоже на JSON — проверьте, что скопирован весь ответ целиком.',
|
badJson: 'Это не похоже на JSON — проверьте, что скопирован весь ответ целиком.',
|
||||||
warnings: 'Что не удалось разобрать',
|
warnings: 'Что не удалось разобрать',
|
||||||
aiHint:
|
aiHint:
|
||||||
'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель вернёт JSON, который загружается вкладкой импорта.',
|
'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель может собрать нужные ей группы сама — они заведутся при импорте.',
|
||||||
references: 'Референс-каналы',
|
references: 'Референс-каналы',
|
||||||
referencesPlaceholder: '2×2, Paramount Comedy',
|
referencesPlaceholder: '2×2, Paramount Comedy',
|
||||||
referencesHint:
|
referencesHint:
|
||||||
|
|||||||
Reference in New Issue
Block a user