From 4a6991f5d17ec5da878ad468259618bee4173c5a Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Thu, 28 May 2026 01:00:02 +0500 Subject: [PATCH 01/10] page templating design doc --- docs/design/page-templates.md | 1281 +++++++++++++++++++++++++++++++++ 1 file changed, 1281 insertions(+) create mode 100644 docs/design/page-templates.md diff --git a/docs/design/page-templates.md b/docs/design/page-templates.md new file mode 100644 index 00000000..bb6fbb7f --- /dev/null +++ b/docs/design/page-templates.md @@ -0,0 +1,1281 @@ +# Дизайн-документ: Шаблоны страниц (Page Templates) + +## 1. Обзор + +Функционал позволяет создавать, хранить и использовать шаблоны страниц трех типов: +- **System** — системные шаблоны (например "Empty page"), доступны всем, редактируются только глобальными администраторами +- **User** — пользовательские шаблоны, видны только владельцу +- **Space** — шаблоны пространства, видны участникам пространства, редактируются администраторами пространства + +Шаблоны используют Mustache-шаблонизацию (Stubble, существующий `ITemplateRenderer`) с подстановкой параметров. + +--- + +## 2. Domain Model — PageTemplate + +Новая сущность в `Mimisbrunnr.PageTemplates.Contracts`: + +``` +PageTemplate : IHasId + Id : string // MongoDB _id + Name : string // название шаблона + Description : string? // описание + Content : string // тело шаблона (Markdown + Mustache) + Type : TemplateType // System | User | Space + OwnerEmail : string? // владелец (для User/System) + SpaceId : string? // пространство (для Space) + Created : DateTime + Updated : DateTime + CreatedBy : UserInfo + UpdatedBy : UserInfo + +TemplateType : string (enum) + System = "System" + User = "User" + Space = "Space" +``` + +--- + +## 3. Backend + +### 3.1. Новый проект `src/Mimisbrunnr.PageTemplates/` + +Структура: + +``` +Mimisbrunnr.PageTemplates/ +├── Mimisbrunnr.PageTemplates.csproj +├── PageTemplatesModule.cs +├── Contracts/ +│ ├── PageTemplate.cs +│ └── TemplateType.cs +└── Services/ + ├── IPageTemplateManager.cs + └── PageTemplateManager.cs +``` + +**PageTemplatesModule.cs** — регистрирует `IPageTemplateManager` в DI: +```csharp +public class PageTemplatesModule : Module +{ + public override Type[] DependsModules => []; + public override void Configure(IServiceCollection services) + { + services.AddSingleton(); + } +} +``` + +**IPageTemplateManager.cs** — бизнес-логика: +```csharp +public interface IPageTemplateManager +{ + Task GetById(string id); + IQueryable GetAll(); + Task Create(PageTemplate template); + Task Update(PageTemplate template); + Task Delete(string id); +} +``` + +**PageTemplateManager.cs** — имплементация, работающая через `IPageTemplateStore`. + +### 3.2. DTO (Integration) — `src/Integration/Mimisbrunnr.Integration/PageTemplates/` + +``` +PageTemplateModel.cs +PageTemplateCreateModel.cs +PageTemplateUpdateModel.cs +PageTemplateRenderRequest.cs +``` + +```csharp +// PageTemplateModel.cs +public class PageTemplateModel +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Content { get; set; } + public string Type { get; set; } // "System" | "User" | "Space" + public string OwnerEmail { get; set; } + public string SpaceId { get; set; } + public DateTime Created { get; set; } + public DateTime Updated { get; set; } + public UserInfo CreatedBy { get; set; } + public UserInfo UpdatedBy { get; set; } +} + +// PageTemplateCreateModel.cs +public class PageTemplateCreateModel +{ + [Required] public string Name { get; set; } + public string Description { get; set; } + [Required] public string Content { get; set; } + [Required] public string Type { get; set; } // "System" | "User" | "Space" + public string SpaceKey { get; set; } // для Space-типа +} + +// PageTemplateUpdateModel.cs +public class PageTemplateUpdateModel +{ + [Required] public string Name { get; set; } + public string Description { get; set; } + [Required] public string Content { get; set; } +} + +// PageTemplateRenderRequest.cs +public class PageTemplateRenderRequest +{ + [Required] public string TemplateId { get; set; } + [Required] public string SpaceKey { get; set; } // для вычисления параметров +} +``` + +### 3.3. API Controller — `src/Mimisbrunnr.Web/PageTemplates/` + +``` +PageTemplateController.cs +IPageTemplateService.cs +PageTemplateService.cs +HandlePageTemplateErrorsAttribute.cs +PageTemplateMapper.cs (Mapperly) +``` + +**Маршрут:** `[Route("api/[controller]")]` → `/api/pagetemplate` + +**Эндпоинты:** + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/pagetemplate` | Authorize | Получить доступные шаблоны (фильтр по типу, query params: `?type=System&spaceKey=xxx`) | +| GET | `/api/pagetemplate/{id}` | Authorize | Получить шаблон по ID | +| POST | `/api/pagetemplate` | Authorize | Создать шаблон (права проверяются внутри) | +| PUT | `/api/pagetemplate/{id}` | Authorize | Обновить шаблон (права проверяются внутри) | +| DELETE | `/api/pagetemplate/{id}` | Authorize | Удалить шаблон (права проверяются внутри) | +| POST | `/api/pagetemplate/{id}/render` | Authorize | Сгенерировать контент из шаблона | + +**Логика разграничения прав в `PageTemplateService`:** + +``` +Create: + System → только admin (RequiredAdminRole) + User → любой авторизованный (владелец = текущий пользователь) + Space → admin пространства + +GetAll (список доступных): + System → все + User → только свои (OwnerEmail == current.Email) + Space → участники пространства (есть edit permission) + +Update/Delete: + System → только admin + User → только владелец + Space → admin пространства +``` + +**Рендеринг шаблона (`POST /api/pagetemplate/{id}/render`):** + +```csharp +public async Task Render(string id, PageTemplateRenderRequest request) +{ + var template = await _pageTemplateService.GetById(id, user); + var space = await _spaceManager.GetByKey(request.SpaceKey); + + var parameters = new Dictionary + { + ["CurrentDate"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), + ["CurrentTime"] = DateTime.UtcNow.ToString("HH:mm:ss"), + ["CurrentDateTime"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"), + ["UserName"] = user.Name, + ["UserEmail"] = user.Email, + ["UserAvatarUrl"] = user.AvatarUrl, + ["SpaceName"] = space.Name, + ["SpaceKey"] = space.Key + }; + + var rendered = await _templateRenderer.Render(template.Content, parameters); + return Ok(new { content = rendered }); +} +``` + +**Пример Mustache-шаблона:** +```markdown +# {{SpaceName}} + +Создано пользователем {{User.Name}} {{CurrentDate}} + +## Описание + +... +``` + +### 3.4. Storage — MongoDB + +EntityMapClass используется по тому же принципу, что и существующие маппинги (см. `PageMap.cs`, `SpaceMap.cs` и др. в `Mimisbrunnr.Storage.MongoDb.Mappings`). + +**Новая entity map** `src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs`: +```csharp +public class PageTemplateMap : EntityMapClass +{ + public PageTemplateMap() + { + ToCollection("PageTemplates"); + MapId(x => x.Id, BsonType.String); + } +} +``` + +**Новый store interface** `Mimisbrunnr.PageTemplates.Services.IPageTemplateStore`: +```csharp +public interface IPageTemplateStore +{ + Task GetById(string id); + IQueryable GetAll(); + Task Create(PageTemplate template); + Task Update(PageTemplate template); + Task Delete(PageTemplate template); +} +``` + +**Реализация** `Mimisbrunnr.Storage.MongoDb.PageTemplateStore` — через `BaseMongoDbContext` и `IRepository`. + +**Индексы** — добавить метод `CreatePageTemplateIndexes` в `MongoDbStoreModule` по аналогии с существующими `CreateUserIndexes`, `CreatePageIndexes` и т.д. Метод вызывается в `StartAsync`: + +```csharp +private static async Task CreatePageTemplateIndexes(IMongoDbContext mongoContext) +{ + var collection = mongoContext.GetCollection(); + + var typeKeyDefinition = Builders.IndexKeys.Ascending(x => x.Type); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(typeKeyDefinition, new CreateIndexOptions() + { + Background = true + })); + + var ownerEmailKeyDefinition = Builders.IndexKeys.Ascending(x => x.OwnerEmail); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(ownerEmailKeyDefinition, new CreateIndexOptions() + { + Background = true + })); + + var spaceIdKeyDefinition = Builders.IndexKeys.Ascending(x => x.SpaceId); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(spaceIdKeyDefinition, new CreateIndexOptions() + { + Background = true + })); +} +``` + +**Регистрация entity** в `MongoDbStoreModule.Configure`: +```csharp +.AddEntity() +``` + +### 3.5. Регистрация модулей + +**StartupModule.cs** — добавить зависимость: +```csharp +typeof(PageTemplatesModule) +``` + +**WebModule.cs** — добавить сервисы: +```csharp +services.AddSingleton(); +``` + +**MongoDbStoreModule.cs** — добавить store: +```csharp +services.AddSingleton(); +``` + +### 3.6. Обработка ошибок + +Новый фильтр `HandlePageTemplateErrorsAttribute` по аналогии с `HandleWikiErrorsAttribute`: +- `PageTemplateNotFoundException` → 404 +- `UserHasNotPermissionException` → 403 +- `InvalidOperationException` → 400 + +### 3.7. Маппинг (Mapperly) + +`PageTemplateMapper.cs` в `Mimisbrunnr.Web.Mapping`: +```csharp +[Mapper] +public static partial class PageTemplateMapper +{ + public static partial PageTemplateModel ToModel(this PageTemplate template); +} +``` + +--- + +## 4. Frontend + +Все HTTP-запросы на фронте выполняются через нативный **`fetch`**. Использование `axios` не допускается. + +### 4.1. API Service — `ClientApp/src/services/pageTemplateService.js` + +```javascript +async function request(url, options = {}) { + const res = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw { status: res.status, message: err.message, data: err }; + } + return res.json(); +} + +export default { + getAll(type, spaceKey) { + const params = new URLSearchParams(); + if (type) params.set("type", type); + if (spaceKey) params.set("spaceKey", spaceKey); + const qs = params.toString(); + return request(`/api/pagetemplate${qs ? "?" + qs : ""}`); + }, + getById(id) { + return request(`/api/pagetemplate/${id}`); + }, + create(model) { + return request("/api/pagetemplate", { + method: "POST", + body: JSON.stringify(model), + }); + }, + update(id, model) { + return request(`/api/pagetemplate/${id}`, { + method: "PUT", + body: JSON.stringify(model), + }); + }, + delete(id) { + return request(`/api/pagetemplate/${id}`, { method: "DELETE" }); + }, + render(templateId, spaceKey) { + return request(`/api/pagetemplate/${templateId}/render`, { + method: "POST", + body: JSON.stringify({ templateId, spaceKey }), + }); + }, +}; +``` + +### 4.2. Vuex Store — добавления + +В `store.js` новое состояние: +```javascript +state: { + application: { /* ... */ }, + templates: { + system: [], // PageTemplateModel[] + user: [], // PageTemplateModel[] + space: [], // PageTemplateModel[] + } +} +``` + +Мутации: `setSystemTemplates`, `setUserTemplates`, `setSpaceTemplates`. + +### 4.3. Общий компонент управления шаблонами + +**`ClientApp/src/components/templates/PageTemplateManager.vue`** + +Универсальный компонент для CRUD шаблонов. Props: +- `type` — `"System" | "User" | "Space"` +- `spaceKey` — ключ пространства (только для Space) +- `readonly` — если true, скрыть кнопки редактирования (для не-админов в Space) + +Функционал: +- Таблица/список шаблонов с колонками: Name, Description, Created, Actions +- Кнопка "Create template" → модалка создания +- Каждая строка → edit/delete (с подтверждением) +- Модалка создания/редактирования: поля Name, Description, Content (textarea с EasyMDE) + +``` +PageTemplateManager.vue +├── PageTemplateList.vue (таблица) +├── PageTemplateModal.vue (create/edit форма) +└── (использует pageTemplateService.js) +``` + +### 4.4. Интеграция: Админка + +**`src/components/admin/PageTemplatesTab.vue`** + +Новая вкладка в `admin/Menu.vue`. Добавить пункт "Page Templates" в меню. + +```javascript +// В admin/Menu.vue добавить: +{ title: 'admin.templates.title', to: '/admin/templates' } +``` + +**Новый view** `views/Admin/PageTemplates.vue`: +```vue + +``` + +**Роутер** `router.js`: +```javascript +{ path: '/admin/templates', name: 'PageTemplates', component: PageTemplates } +``` + +### 4.5. Интеграция: Профиль пользователя + +В `SettingsModal.vue` добавить новую вкладку: +```vue + +``` + +**`src/components/people/profile/settings/PageTemplatesTab.vue`:** +```vue + +``` + +### 4.6. Интеграция: Пространство + +В `space/Menu.vue` добавить пункт "Page Templates" в список действий (виден админам пространства): + +```html + +   {{ $t("space.actions.templates") }} + +``` + +И модалку `components/space/modal/PageTemplates.vue`: +```vue + +``` + +### 4.7. Создание страницы из шаблона + +**Кнопка в Header.vue** рядом с "Create page": + +После `` добавить: +```html + + + +``` + +```css +.create-button + .create-from-template-button { + margin-left: 2px; + height: 32px; + margin-top: 0.2em; + padding-left: 6px; + padding-right: 6px; +} +``` + +**Модалка выбора шаблона** `components/base/CreateFromTemplateModal.vue`: + +```vue + +``` + +**Логика `createFromTemplate`:** +```javascript +async createFromTemplate(template) { + // 1. Определяем spaceKey (как в create()) + var spaceKey = this.$route.params.key; + if (!spaceKey) { + spaceKey = await ProfileService.getOrCreatePersonalSpace(profile); + } + + // 2. Рендерим шаблон + var renderResult = await pageTemplateService.render(template.id, spaceKey); + var content = renderResult.content; + + // 3. Определяем parentPageId + var pageId = this.$route.params.pageId; + if (!pageId) { + var spaceReq = await fetch("/api/space/" + spaceKey); + var spaceData = await spaceReq.json(); + pageId = spaceData.homePageId; + } + + // 4. Создаём страницу с готовым контентом + var createResult = await fetch("/api/page", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + spaceKey: spaceKey, + parentPageId: pageId, + name: template.name, + content: content, + }), + }); + var newPage = await createResult.json(); + this.$router.push("/space/" + spaceKey + "/" + newPage.id + "/edit"); +} +``` + +### 4.8. Локализация — `assets/lang.json` + +Добавить ключи: +```json +{ + "en": { + "pageTemplates": { + "title": "Page Templates", + "create": "Create Template", + "edit": "Edit Template", + "delete": "Delete Template", + "deleteConfirm": "Are you sure you want to delete this template?", + "name": "Name", + "description": "Description", + "content": "Content", + "source": "Source", + "system": "System", + "user": "Personal", + "space": "Space", + "createFromTemplate": "Create from Template", + "noTemplates": "No templates available", + "createPageFromTemplate": "Create page from template" + }, + "header": { + "pageCreateFromTemplateButton": "Create from template" + }, + "admin": { + "templates": { + "title": "Page Templates" + } + }, + "profile": { + "settings": { + "pageTemplates": "My Templates" + } + }, + "space": { + "actions": { + "templates": "Page Templates" + } + } + }, + "ru": { + "pageTemplates": { + "title": "Шаблоны страниц", + "create": "Создать шаблон", + "edit": "Редактировать шаблон", + "delete": "Удалить шаблон", + "deleteConfirm": "Вы уверены, что хотите удалить этот шаблон?", + "name": "Название", + "description": "Описание", + "content": "Содержимое", + "source": "Источник", + "system": "Системный", + "user": "Личный", + "space": "Пространства", + "createFromTemplate": "Создать из шаблона", + "noTemplates": "Нет доступных шаблонов", + "createPageFromTemplate": "Создать страницу из шаблона" + }, + "header": { + "pageCreateFromTemplateButton": "Создать из шаблона" + }, + "admin": { + "templates": { + "title": "Шаблоны страниц" + } + }, + "profile": { + "settings": { + "pageTemplates": "Мои шаблоны" + } + }, + "space": { + "actions": { + "templates": "Шаблоны страниц" + } + } + } +} +``` + +--- + +## 5. Схема взаимодействия (Sequence) + +``` +User clicks [▼] in Header + → CreateFromTemplateModal opens + → GET /api/pagetemplate (через pageTemplateService.getAll()) + → список шаблонов (System + User + Space, если в пространстве) + → User выбирает шаблон + → POST /api/pagetemplate/{id}/render { spaceKey } + → PageTemplateService.Render() + → ITemplateRenderer.Render(template.Content, parameters) + ← { content: "..." } + → POST /api/page { spaceKey, parentPageId, name, content } + → PageService.Create() + → redirect to /space/{key}/{pageId}/edit +``` + +--- + +## 6. Состав изменений по файлам + +### Backend (C#): +| # | Файл | Действие | +|---|------|----------| +| 1 | `src/Mimisbrunnr.PageTemplates/Mimisbrunnr.PageTemplates.csproj` | создать | +| 2 | `src/Mimisbrunnr.PageTemplates/Contracts/PageTemplate.cs` | создать | +| 3 | `src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs` | создать | +| 4 | `src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs` | создать | +| 5 | `src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs` | создать | +| 6 | `src/Mimisbrunnr.PageTemplates/Services/IPageTemplateStore.cs` | создать | +| 7 | `src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs` | создать | +| 8 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs` | создать | +| 9 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs` | создать | +| 10 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs` | создать | +| 11 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs` | создать | +| 12 | `src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs` | создать | +| 13 | `src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs` | создать | +| 14 | `src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs` | создать | +| 15 | `src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs` | создать | +| 16 | `src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs` | создать | +| 17 | `src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs` | создать | +| 18 | `src/Mimisbrunnr.Storage.MongoDb/PageTemplateStore.cs` | создать | +| 19 | `src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs` | изменить (добавить entity + store + indexes) | +| 20 | `src/Mimisbrunnr.Web.Host/StartupModule.cs` | изменить (добавить PageTemplatesModule) | +| 21 | `src/Mimisbrunnr.Web/WebModule.cs` | изменить (добавить ITemplateService) | + +### Frontend (Vue.js): +| # | Файл | Действие | +|---|------|----------| +| 1 | `ClientApp/src/services/pageTemplateService.js` | создать | +| 2 | `ClientApp/src/components/templates/PageTemplateManager.vue` | создать | +| 3 | `ClientApp/src/components/templates/PageTemplateModal.vue` | создать | +| 4 | `ClientApp/src/components/base/CreateFromTemplateModal.vue` | создать | +| 5 | `ClientApp/src/components/admin/PageTemplatesTab.vue` | создать | +| 6 | `ClientApp/src/components/people/profile/settings/PageTemplatesTab.vue` | создать | +| 7 | `ClientApp/src/components/space/modal/PageTemplates.vue` | создать | +| 8 | `ClientApp/src/components/base/Header.vue` | изменить (добавить кнопку ▼ + модалку) | +| 9 | `ClientApp/src/components/space/Menu.vue` | изменить (добавить пункт меню) | +| 10 | `ClientApp/src/components/people/profile/SettingsModal.vue` | изменить (добавить вкладку) | +| 11 | `ClientApp/src/views/Admin/PageTemplates.vue` | создать | +| 12 | `ClientApp/src/views/Admin/GeneralConfiguration.vue` | изменить (добавить таб) | +| 13 | `ClientApp/src/services/store.js` | изменить (добавить состояние templates) | +| 14 | `ClientApp/src/router.js` | изменить (добавить маршрут) | +| 15 | `ClientApp/src/assets/lang.json` | изменить (добавить переводы) | + +--- + +## 7. Тестирование + +### 7.1. Стек + +- **xUnit** — test framework +- **FakeItEasy** — mocking +- **AwesomeAssertions** — assertions (замена FluentAssertions) + +### 7.2. Тестируемые компоненты + +#### 7.2.1. PageTemplateManager (бизнес-логика, `Mimisbrunnr.PageTemplates`) + +Тесты в `tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateManagerTests.cs`: + +```csharp +public class PageTemplateManagerTests +{ + private readonly IPageTemplateStore _store; + private readonly PageTemplateManager _manager; + + public PageTemplateManagerTests() + { + _store = A.Fake(); + _manager = new PageTemplateManager(_store); + } + + [Fact] + public async Task Create_Should_Set_CreatedAndUpdated() + { + var template = new PageTemplate + { + Name = "Test", + Content = "# Hello", + Type = TemplateType.User, + OwnerEmail = "user@test.com" + }; + + var result = await _manager.Create(template); + + using (new AssertionScope()) + { + result.Created.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + result.Updated.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + result.CreatedBy.Email.Should().Be("user@test.com"); + } + + A.CallTo(() => _store.Create(A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetById_Should_Return_Template_From_Store() + { + var expected = new PageTemplate { Id = "123", Name = "Test" }; + A.CallTo(() => _store.GetById("123")).Returns(expected); + + var result = await _manager.GetById("123"); + + result.Should().BeSameAs(expected); + } + + [Fact] + public async Task GetById_Should_Throw_When_Not_Found() + { + A.CallTo(() => _store.GetById("missing")).Returns((PageTemplate)null!); + + await _manager.Invoking(m => m.GetById("missing")) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Update_Should_Set_Updated_And_UpdatedBy() + { + var existing = new PageTemplate + { + Id = "1", + Name = "Old", + Content = "Old content", + Updated = DateTime.UtcNow.AddDays(-1), + UpdatedBy = new UserInfo { Email = "old@test.com" } + }; + A.CallTo(() => _store.GetById("1")).Returns(existing); + + var updateInfo = new UserInfo { Email = "new@test.com" }; + await _manager.Update("1", "New Name", "New content", updateInfo); + + using (new AssertionScope()) + { + existing.Name.Should().Be("New Name"); + existing.Content.Should().Be("New content"); + existing.UpdatedBy.Email.Should().Be("new@test.com"); + existing.Updated.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + } + + A.CallTo(() => _store.Update(existing)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Delete_Should_Remove_From_Store() + { + var template = new PageTemplate { Id = "1" }; + A.CallTo(() => _store.GetById("1")).Returns(template); + + await _manager.Delete("1"); + + A.CallTo(() => _store.Delete(template)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetAll_Should_Return_From_Store() + { + var templates = new[] + { + new PageTemplate { Name = "A" }, + new PageTemplate { Name = "B" } + }.AsQueryable(); + + A.CallTo(() => _store.GetAll()).Returns(templates); + + var result = _manager.GetAll(); + + result.Should().HaveCount(2); + } +} +``` + +#### 7.2.2. PageTemplateService (Web-слой, `Mimisbrunnr.Web`) + +Тесты в `tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateServiceTests.cs`: + +```csharp +public class PageTemplateServiceTests +{ + private readonly IPageTemplateManager _manager; + private readonly IPermissionService _permissions; + private readonly ISpaceManager _spaceManager; + private readonly ITemplateRenderer _renderer; + private readonly PageTemplateService _service; + + public PageTemplateServiceTests() + { + _manager = A.Fake(); + _permissions = A.Fake(); + _spaceManager = A.Fake(); + _renderer = A.Fake(); + _service = new PageTemplateService(_manager, _permissions, _spaceManager, _renderer); + } + + // --- GetAll --- + + [Fact] + public async Task GetAll_System_Returns_All_System_Templates() + { + SetupSystemTemplates(); + var user = User("user@test.com"); + + var result = await _service.GetAll(TemplateType.System, null, user); + + result.Should().HaveCount(2); + } + + [Fact] + public async Task GetAll_User_Returns_Only_Own_Templates() + { + var user = User("owner@test.com"); + var all = new[] + { + Template("1", TemplateType.User, "owner@test.com"), + Template("2", TemplateType.User, "other@test.com"), + }.AsQueryable(); + A.CallTo(() => _manager.GetAll()).Returns(all); + + var result = await _service.GetAll(TemplateType.User, null, user); + + result.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task GetAll_Space_Returns_Only_When_User_Has_View_Permission() + { + var user = User("user@test.com"); + var space = new Space { Id = "s1", Key = "space1" }; + var all = new[] + { + Template("1", TemplateType.Space, spaceId: "s1"), + Template("2", TemplateType.Space, spaceId: "s2"), + }.AsQueryable(); + A.CallTo(() => _manager.GetAll()).Returns(all); + A.CallTo(() => _spaceManager.GetById("s1")).Returns(space); + A.CallTo(() => _permissions.EnsureViewPermission("space1", user)) + .DoesNothing(); // has permission + A.CallTo(() => _spaceManager.GetById("s2")) + .ThrowsAsync(new SpaceNotFoundException()); + + var result = await _service.GetAll(TemplateType.Space, "space1", user); + + result.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + // --- Create --- + + [Fact] + public async Task Create_System_By_NonAdmin_Throws() + { + var user = User("user@test.com", isAdmin: false); + + await _service.Invoking(s => s.Create( + new PageTemplateCreateModel { Type = "System", Name = "T", Content = "C" }, user)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Create_System_By_Admin_Succeeds() + { + var user = User("admin@test.com", isAdmin: true); + var model = new PageTemplateCreateModel + { + Type = "System", Name = "Sys", Content = "# Hello", Description = "A system template" + }; + + var result = await _service.Create(model, user); + + using (new AssertionScope()) + { + result.Name.Should().Be("Sys"); + result.Type.Should().Be(TemplateType.System.ToString()); + result.CreatedBy.Email.Should().Be("admin@test.com"); + } + } + + [Fact] + public async Task Create_User_Sets_OwnerEmail() + { + var user = User("user@test.com"); + var model = new PageTemplateCreateModel + { + Type = "User", Name = "My", Content = "# My template" + }; + + var result = await _service.Create(model, user); + + result.OwnerEmail.Should().Be("user@test.com"); + } + + [Fact] + public async Task Create_Space_By_NonAdmin_Throws() + { + var user = User("user@test.com"); + var model = new PageTemplateCreateModel + { + Type = "Space", Name = "S", Content = "C", SpaceKey = "space1" + }; + SetupSpace("space1"); + A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)) + .ThrowsAsync(new UserHasNotPermissionException()); + + await _service.Invoking(s => s.Create(model, user)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Create_Space_By_Admin_Succeeds() + { + var user = User("admin@test.com"); + var model = new PageTemplateCreateModel + { + Type = "Space", Name = "S", Content = "C", SpaceKey = "space1" + }; + var space = SetupSpace("space1"); + A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)).DoesNothing(); + + var result = await _service.Create(model, user); + + result.SpaceId.Should().Be(space.Id); + } + + // --- Update --- + + [Fact] + public async Task Update_System_By_NonAdmin_Throws() + { + var user = User("user@test.com", isAdmin: false); + A.CallTo(() => _manager.GetById("1")) + .Returns(Template("1", TemplateType.System)); + + await _service.Invoking(s => s.Update("1", + new PageTemplateUpdateModel { Name = "N", Content = "C" }, user)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Update_User_By_Owner_Succeeds() + { + var user = User("owner@test.com"); + A.CallTo(() => _manager.GetById("1")) + .Returns(Template("1", TemplateType.User, ownerEmail: "owner@test.com")); + + await _service.Update("1", + new PageTemplateUpdateModel { Name = "New", Content = "New content" }, user); + + A.CallTo(() => _manager.Update("1", "New", "New content", user)) + .MustHaveHappenedOnceExactly(); + } + + // --- Delete --- + + [Fact] + public async Task Delete_User_By_NonOwner_Throws() + { + var user = User("other@test.com"); + A.CallTo(() => _manager.GetById("1")) + .Returns(Template("1", TemplateType.User, ownerEmail: "owner@test.com")); + + await _service.Invoking(s => s.Delete("1", user)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Delete_Space_By_SpaceAdmin_Succeeds() + { + var user = User("spaceadmin@test.com"); + var space = new Space { Id = "s1", Key = "space1" }; + A.CallTo(() => _manager.GetById("1")) + .Returns(Template("1", TemplateType.Space, spaceId: "s1")); + A.CallTo(() => _spaceManager.GetById("s1")).Returns(space); + A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)).DoesNothing(); + + await _service.Delete("1", user); + + A.CallTo(() => _manager.Delete("1")).MustHaveHappenedOnceExactly(); + } + + // --- Render --- + + [Fact] + public async Task Render_Returns_Rendered_Content_With_All_Parameters() + { + var user = User("user@test.com"); + var space = new Space { Id = "s1", Key = "space1", Name = "My Space" }; + var template = new PageTemplate + { + Id = "1", + Content = "# {{SpaceName}} by {{UserName}}", + Type = TemplateType.System + }; + + A.CallTo(() => _manager.GetById("1")).Returns(template); + A.CallTo(() => _spaceManager.GetByKey("space1")).Returns(space); + A.CallTo(() => _renderer.Render(template.Content, A>._)) + .ReturnsLazily((string t, IDictionary p) => + $"# {p["SpaceName"]} by {p["UserName"]}"); + + var result = await _service.Render("1", "space1", user); + + result.Content.Should().Be("# My Space by user@test.com"); + } + + [Fact] + public async Task Render_Should_Pass_Correct_Parameters_To_Renderer() + { + var user = User("john@test.com"); + var space = new Space { Id = "s1", Key = "dev", Name = "Development" }; + var template = new PageTemplate { Id = "1", Content = "{{CurrentDate}}", Type = TemplateType.System }; + + A.CallTo(() => _manager.GetById("1")).Returns(template); + A.CallTo(() => _spaceManager.GetByKey("dev")).Returns(space); + + await _service.Render("1", "dev", user); + + A.CallTo(() => _renderer.Render(A._, A>.That + .Matches(d => + d.ContainsKey("CurrentDate") && + d.ContainsKey("CurrentTime") && + d.ContainsKey("CurrentDateTime") && + d.ContainsKey("SpaceName") && (string)d["SpaceName"] == "Development" && + d.ContainsKey("SpaceKey") && (string)d["SpaceKey"] == "dev" && + d.ContainsKey("UserName")))) + .MustHaveHappenedOnceExactly(); + } + + // --- Helper methods --- + + private static UserInfo User(string email, bool isAdmin = false) => + new() { Email = email, Name = email.Split('@')[0] }; + + private static PageTemplate Template(string id, TemplateType type, + string? ownerEmail = null, string? spaceId = null) => + new() + { + Id = id, + Name = "Template " + id, + Content = "# Content", + Type = type, + OwnerEmail = ownerEmail, + SpaceId = spaceId + }; + + private void SetupSystemTemplates() + { + var templates = new[] + { + Template("s1", TemplateType.System), + Template("s2", TemplateType.System), + }.AsQueryable(); + A.CallTo(() => _manager.GetAll()).Returns(templates); + } + + private Space SetupSpace(string key) + { + var space = new Space { Id = key + "_id", Key = key, Name = key }; + A.CallTo(() => _spaceManager.GetByKey(key)).Returns(space); + return space; + } +} +``` + +#### 7.2.3. PageTemplateController (API, `Mimisbrunnr.Web`) + +Тесты в `tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateControllerTests.cs`: + +```csharp +public class PageTemplateControllerTests +{ + // Use the project's existing TestBase or controller test helper pattern + // проверяет routing, валидацию модели, проброс ошибок сервиса + + [Fact] + public async Task GetAll_Should_Call_Service_And_Return_Ok() + { + // Arrange + var service = A.Fake(); + var controller = new PageTemplateController(service); + var user = new UserInfo { Email = "test@test.com" }; + controller.SetupControllerContext(user); + + var expected = new[] { new PageTemplateModel { Id = "1" } }; + A.CallTo(() => service.GetAll(null, null, user)).Returns(expected); + + // Act + var result = await controller.GetAll(null, null); + + // Assert + result.Should().BeOfType() + .Which.Value.Should().BeSameAs(expected); + } + + [Fact] + public async Task Create_InvalidModel_Returns_BadRequest() + { + var service = A.Fake(); + var controller = new PageTemplateController(service); + controller.ModelState.AddModelError("Name", "Required"); + + var result = await controller.Create(new PageTemplateCreateModel()); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Render_Should_Return_Content() + { + var service = A.Fake(); + var controller = new PageTemplateController(service); + var user = new UserInfo { Email = "test@test.com" }; + controller.SetupControllerContext(user); + + A.CallTo(() => service.Render("1", "space1", user)) + .Returns(new PageTemplateRenderResult { Content = "# Rendered" }); + + var result = await controller.Render("1", new PageTemplateRenderRequest + { + TemplateId = "1", + SpaceKey = "space1" + }); + + var okResult = result.Should().BeOfType().Subject; + okResult.Value.Should().BeEquivalentTo(new { content = "# Rendered" }); + } +} +``` + +#### 7.2.4. PageTemplateStore (MongoDB, интеграционные) + +Опционально — интеграционные тесты с тестовой MongoDB (через `Testcontainers.MongoDb` или in-memory): +- Создание, чтение, обновление, удаление записей +- Фильтрация по типу, ownerEmail, spaceId + +### 7.3. Запуск тестов + +```bash +dotnet test tests/Mimisbrunnr.Web.Tests/ +``` + +При реализации тестов следует убедиться, что: +1. Все публичные методы `PageTemplateManager` покрыты unit-тестами +2. Все эндпоинты `PageTemplateController` проверены на корректный HTTP-статус при успехе/ошибке +3. В `PageTemplateService` проверены все сценарии разграничения прав (admin/non-admin, владелец/чужой, участник/не-участник пространства) +4. Рендеринг проверен с корректными значениями всех подставляемых параметров + +--- + +## 8. Порядок реализации + +Все комментарии в коде пишутся на **английском языке**. + +### Этап 1: Domain + Storage (фундамент) + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 1.1 | Создать проект `Mimisbrunnr.PageTemplates` | `.csproj` | +| 1.2 | Определить `TemplateType` enum | `Contracts/TemplateType.cs` | +| 1.3 | Определить `PageTemplate` entity | `Contracts/PageTemplate.cs` | +| 1.4 | Определить `IPageTemplateStore` + `IPageTemplateManager` | `Services/` | +| 1.5 | Реализовать `PageTemplateManager` | `Services/PageTemplateManager.cs` | +| 1.6 | Создать `PageTemplatesModule` | `PageTemplatesModule.cs` | +| 1.7 | Создать `PageTemplateMap` (MongoDB mapping) | `Mimisbrunnr.Storage.MongoDb/Mappings/` | +| 1.8 | Создать `PageTemplateStore` (MongoDB) | `Mimisbrunnr.Storage.MongoDb/` | +| 1.9 | Зарегистрировать entity и store в `MongoDbStoreModule` | `MongoDbStoreModule.cs` | +| 1.10 | Добавить индексы (Type, OwnerEmail, SpaceId) | `MongoDbStoreModule.cs` | +| 1.11 | Добавить `PageTemplatesModule` в `StartupModule` | `StartupModule.cs` | + +**Проверка:** `dotnet build` успешно собирается. + +### Этап 2: Web-слой API + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 2.1 | Создать DTO в `Mimisbrunnr.Integration` | `PageTemplates/` | +| 2.2 | Создать `PageTemplateMapper` (Mapperly) | `Mapping/PageTemplateMapper.cs` | +| 2.3 | Создать `HandlePageTemplateErrorsAttribute` | `Filters/` | +| 2.4 | Создать `IPageTemplateService` | `PageTemplates/` | +| 2.5 | Реализовать `PageTemplateService` с разграничением прав | `PageTemplates/PageTemplateService.cs` | +| 2.6 | Создать `PageTemplateController` | `PageTemplates/PageTemplateController.cs` | +| 2.7 | Зарегистрировать `IPageTemplateService` в `WebModule` | `WebModule.cs` | + +**Проверка:** `dotnet build` успешно. Можно протестировать через Swagger. + +### Этап 3: Тесты (Backend) + +| Шаг | Действие | +|-----|----------| +| 3.1 | Написать тесты `PageTemplateManagerTests` (store mocking) | +| 3.2 | Написать тесты `PageTemplateServiceTests` (все сценарии прав) | +| 3.3 | Написать тесты `PageTemplateControllerTests` (routing, validation, errors) | +| 3.4 | Запустить `dotnet test` — все тесты зелёные | + +### Этап 4: Frontend — инфраструктура + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 4.1 | Создать `pageTemplateService.js` (API client) | `services/` | +| 4.2 | Добавить состояние `templates` в Vuex store | `store.js` | +| 4.3 | Добавить переводы в `lang.json` | `assets/lang.json` | + +### Этап 5: Frontend — общий компонент управления шаблонами + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 5.1 | Создать `PageTemplateModal.vue` (create/edit форма) | `components/templates/` | +| 5.2 | Создать `PageTemplateManager.vue` (список + CRUD) | `components/templates/` | + +### Этап 6: Frontend — интеграция точек входа + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 6.1 | **Админка:** создать `Admin/PageTemplates.vue`, добавить роутер, пункт меню | `views/Admin/`, `router.js`, `admin/Menu.vue` | +| 6.2 | **Профиль:** создать `PageTemplatesTab.vue`, добавить вкладку в `SettingsModal` | `components/people/profile/settings/` | +| 6.3 | **Пространство:** создать `space/modal/PageTemplates.vue`, добавить пункт в `Menu.vue` | `components/space/` | + +### Этап 7: Frontend — создание страницы из шаблона + +| Шаг | Действие | Файлы | +|-----|----------|-------| +| 7.1 | Создать `CreateFromTemplateModal.vue` | `components/base/` | +| 7.2 | Добавить кнопку `▼` в `Header.vue` рядом с кнопкой Create | `components/base/Header.vue` | + +### Этап 8: Финальная проверка + +| Шаг | Действие | +|-----|----------| +| 8.1 | `dotnet build` — без ошибок | +| 8.2 | `dotnet test` — все тесты проходят | +| 8.3 | `npm run lint` — во фронтенде без ошибок | +| 8.4 | Ручная проверка через Swagger всех эндпоинтов API | +| 8.5 | Ручная проверка в браузере: админка, профиль, пространство, создание из шаблона | From 503ce517fd52be07b8783c06d9f1e9b496a1088a Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sat, 13 Jun 2026 18:16:51 +0500 Subject: [PATCH 02/10] update page template design doc --- docs/design/page-templates.md | 572 +++++++++------------------------- docs/stubble-renderer.md | 163 ++++++++++ 2 files changed, 313 insertions(+), 422 deletions(-) create mode 100644 docs/stubble-renderer.md diff --git a/docs/design/page-templates.md b/docs/design/page-templates.md index bb6fbb7f..6618d882 100644 --- a/docs/design/page-templates.md +++ b/docs/design/page-templates.md @@ -79,7 +79,7 @@ public interface IPageTemplateManager } ``` -**PageTemplateManager.cs** — имплементация, работающая через `IPageTemplateStore`. +**PageTemplateManager.cs** — имплементация, работающая напрямую через `IRepository` (без промежуточного Store). ### 3.2. DTO (Integration) — `src/Integration/Mimisbrunnr.Integration/PageTemplates/` @@ -88,6 +88,7 @@ PageTemplateModel.cs PageTemplateCreateModel.cs PageTemplateUpdateModel.cs PageTemplateRenderRequest.cs +PageTemplateRenderResponse.cs ``` ```csharp @@ -131,6 +132,12 @@ public class PageTemplateRenderRequest [Required] public string TemplateId { get; set; } [Required] public string SpaceKey { get; set; } // для вычисления параметров } + +// PageTemplateRenderResponse.cs +public class PageTemplateRenderResponse +{ + public string Content { get; set; } +} ``` ### 3.3. API Controller — `src/Mimisbrunnr.Web/PageTemplates/` @@ -143,6 +150,58 @@ HandlePageTemplateErrorsAttribute.cs PageTemplateMapper.cs (Mapperly) ``` +**Архитектурный принцип: контроллер — только точка входа** + +Вся бизнес-логика, включая рендеринг шаблонов, должна находиться в сервисе (`PageTemplateService`), а не в контроллере. Контроллер выполняет только: +1. Проверку прав (через атрибуты или вызов сервиса) +2. Валидацию контракта (атрибут `[ValidateModel]`) +3. Получение текущего пользователя (если нужно) +4. Вызов соответствующего метода `AppService` +5. Возврат результата (или обработку ошибок через `HandlePageTemplateErrorsAttribute`) + +**Архитектурный принцип: контракты API — только типизированные DTO** + +Контроллеры должны возвращать строго типизированные DTO из сборки Integration (или локальных Contracts), а не anonymous types (`new { ... }`). Это обеспечивает корректную генерацию схемы в Swagger/OpenAPI. Сервис (`AppService`) должен возвращать готовый внешний контракт, который контроллер отдаёт наружу без дополнительной трансформации: + +```csharp +// Сервис возвращает готовый DTO для API +public async Task Render(string templateId, string spaceKey, UserInfo user) +{ + // ... логика ... + return new PageTemplateRenderResponse { Content = rendered }; +} + +// Контроллер просто оборачивает в Ok() +public async Task Render(string id, PageTemplateRenderRequest request) +{ + var user = await _userService.GetCurrentUser(); + var result = await _pageTemplateService.Render(id, request.SpaceKey, user); + return Ok(result); // PageTemplateRenderResponse, НЕ new { content = result.Content } +} +``` + +Запрещено: +```csharp +// НЕЛЬЗЯ — anonymous type, Swagger не видит схему +return Ok(new { content = rendered }); +``` + +**Пример правильной структуры метода контроллера:** + +```csharp +[HttpPost("{id}/render")] +[ValidateModel] +[HandlePageTemplateErrors] +public async Task Render(string id, PageTemplateRenderRequest request) +{ + var user = await _userService.GetCurrentUser(); + var result = await _pageTemplateService.Render(id, request.SpaceKey, user); + return Ok(result); +} +``` + +Логика формирования параметров рендеринга (даты, данные пользователя, данные пространства) и вызов `ITemplateRenderer` — целиком в `PageTemplateService.Render()`. + **Маршрут:** `[Route("api/[controller]")]` → `/api/pagetemplate` **Эндпоинты:** @@ -175,13 +234,13 @@ Update/Delete: Space → admin пространства ``` -**Рендеринг шаблона (`POST /api/pagetemplate/{id}/render`):** +**Рендеринг шаблона — логика в `PageTemplateService.Render()`:** ```csharp -public async Task Render(string id, PageTemplateRenderRequest request) +public async Task Render(string templateId, string spaceKey, UserInfo user) { - var template = await _pageTemplateService.GetById(id, user); - var space = await _spaceManager.GetByKey(request.SpaceKey); + var template = await _pageTemplateManager.GetById(templateId); + var space = await _spaceManager.GetByKey(spaceKey); var parameters = new Dictionary { @@ -196,7 +255,21 @@ public async Task Render(string id, PageTemplateRenderRequest req }; var rendered = await _templateRenderer.Render(template.Content, parameters); - return Ok(new { content = rendered }); + return new PageTemplateRenderResponse { Content = rendered }; +} +``` + +**Контроллер вызывает сервис и возвращает готовый DTO:** + +```csharp +[HttpPost("{id}/render")] +[ValidateModel] +[HandlePageTemplateErrors] +public async Task Render(string id, PageTemplateRenderRequest request) +{ + var user = await _userService.GetCurrentUser(); + var result = await _pageTemplateService.Render(id, request.SpaceKey, user); + return Ok(result); // PageTemplateRenderResponse — схема видна в Swagger } ``` @@ -215,6 +288,20 @@ public async Task Render(string id, PageTemplateRenderRequest req EntityMapClass используется по тому же принципу, что и существующие маппинги (см. `PageMap.cs`, `SpaceMap.cs` и др. в `Mimisbrunnr.Storage.MongoDb.Mappings`). +`PageTemplateManager` работает напрямую через существующую абстракцию `IRepository`, которая уже умеет CRUD-операции: + +```csharp +public interface IRepository where TEntity : class +{ + Task Create(TEntity obj, CancellationToken cancellationToken = default); + Task Update(TEntity obj, CancellationToken cancellationToken = default); + Task Delete(TEntity obj, CancellationToken cancellationToken = default); + IQueryable GetAll(); +} +``` + +Поэтому **не нужно** создавать отдельный `IPageTemplateStore` / `PageTemplateStore` — просто инжектим `IRepository` в `PageTemplateManager`. + **Новая entity map** `src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs`: ```csharp public class PageTemplateMap : EntityMapClass @@ -227,20 +314,6 @@ public class PageTemplateMap : EntityMapClass } ``` -**Новый store interface** `Mimisbrunnr.PageTemplates.Services.IPageTemplateStore`: -```csharp -public interface IPageTemplateStore -{ - Task GetById(string id); - IQueryable GetAll(); - Task Create(PageTemplate template); - Task Update(PageTemplate template); - Task Delete(PageTemplate template); -} -``` - -**Реализация** `Mimisbrunnr.Storage.MongoDb.PageTemplateStore` — через `BaseMongoDbContext` и `IRepository`. - **Индексы** — добавить метод `CreatePageTemplateIndexes` в `MongoDbStoreModule` по аналогии с существующими `CreateUserIndexes`, `CreatePageIndexes` и т.д. Метод вызывается в `StartAsync`: ```csharp @@ -285,10 +358,7 @@ typeof(PageTemplatesModule) services.AddSingleton(); ``` -**MongoDbStoreModule.cs** — добавить store: -```csharp -services.AddSingleton(); -``` +**MongoDbStoreModule.cs** — зарегистрировать `IRepository` (если ещё не зарегистрировано автоматически через `AddEntity`): ### 3.6. Обработка ошибок @@ -484,6 +554,13 @@ PageTemplateManager.vue padding-left: 6px; padding-right: 6px; } + +/* Скрываем кнопку на мобильных — места нет, адаптивность сложная */ +@media (max-width: 576px) { + .create-from-template-button { + display: none !important; + } +} ``` **Модалка выбора шаблона** `components/base/CreateFromTemplateModal.vue`: @@ -634,14 +711,21 @@ async createFromTemplate(template) { User clicks [▼] in Header → CreateFromTemplateModal opens → GET /api/pagetemplate (через pageTemplateService.getAll()) + → PageTemplateController.GetAll() → PageTemplateService.GetAll() → список шаблонов (System + User + Space, если в пространстве) → User выбирает шаблон → POST /api/pagetemplate/{id}/render { spaceKey } - → PageTemplateService.Render() - → ITemplateRenderer.Render(template.Content, parameters) - ← { content: "..." } + → PageTemplateController.Render() + → [ValidateModel] → проверка контракта + → получение UserInfo + → PageTemplateService.Render() + → IPageTemplateManager.GetById() + → ISpaceManager.GetByKey() + → ITemplateRenderer.Render(template.Content, parameters) + ← PageTemplateRenderResponse { Content: "..." } + ← PageTemplateRenderResponse { Content: "..." } → POST /api/page { spaceKey, parentPageId, name, content } - → PageService.Create() + → PageService.Create() → redirect to /space/{key}/{pageId}/edit ``` @@ -657,22 +741,21 @@ User clicks [▼] in Header | 3 | `src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs` | создать | | 4 | `src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs` | создать | | 5 | `src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs` | создать | -| 6 | `src/Mimisbrunnr.PageTemplates/Services/IPageTemplateStore.cs` | создать | -| 7 | `src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs` | создать | -| 8 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs` | создать | -| 9 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs` | создать | -| 10 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs` | создать | -| 11 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs` | создать | +| 6 | `src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs` | создать | +| 7 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs` | создать | +| 8 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs` | создать | +| 9 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs` | создать | +| 10 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs` | создать | +| 11 | `src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs` | создать | | 12 | `src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs` | создать | | 13 | `src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs` | создать | | 14 | `src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs` | создать | | 15 | `src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs` | создать | | 16 | `src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs` | создать | | 17 | `src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs` | создать | -| 18 | `src/Mimisbrunnr.Storage.MongoDb/PageTemplateStore.cs` | создать | -| 19 | `src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs` | изменить (добавить entity + store + indexes) | -| 20 | `src/Mimisbrunnr.Web.Host/StartupModule.cs` | изменить (добавить PageTemplatesModule) | -| 21 | `src/Mimisbrunnr.Web/WebModule.cs` | изменить (добавить ITemplateService) | +| 18 | `src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs` | изменить (добавить entity + indexes) | +| 19 | `src/Mimisbrunnr.Web.Host/StartupModule.cs` | изменить (добавить PageTemplatesModule) | +| 20 | `src/Mimisbrunnr.Web/WebModule.cs` | изменить (добавить ITemplateService) | ### Frontend (Vue.js): | # | Файл | Действие | @@ -712,13 +795,13 @@ User clicks [▼] in Header ```csharp public class PageTemplateManagerTests { - private readonly IPageTemplateStore _store; + private readonly IRepository _repository; private readonly PageTemplateManager _manager; public PageTemplateManagerTests() { - _store = A.Fake(); - _manager = new PageTemplateManager(_store); + _repository = A.Fake>(); + _manager = new PageTemplateManager(_repository); } [Fact] @@ -741,24 +824,29 @@ public class PageTemplateManagerTests result.CreatedBy.Email.Should().Be("user@test.com"); } - A.CallTo(() => _store.Create(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _repository.Create(A._, A._)).MustHaveHappenedOnceExactly(); } [Fact] - public async Task GetById_Should_Return_Template_From_Store() + public async Task GetById_Should_Return_Template_From_Repository() { - var expected = new PageTemplate { Id = "123", Name = "Test" }; - A.CallTo(() => _store.GetById("123")).Returns(expected); + var templates = new[] + { + new PageTemplate { Id = "123", Name = "Test" }, + new PageTemplate { Id = "456", Name = "Other" }, + }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); var result = await _manager.GetById("123"); - result.Should().BeSameAs(expected); + result.Should().NotBeNull(); + result.Id.Should().Be("123"); } [Fact] public async Task GetById_Should_Throw_When_Not_Found() { - A.CallTo(() => _store.GetById("missing")).Returns((PageTemplate)null!); + A.CallTo(() => _repository.GetAll()).Returns(Enumerable.Empty().AsQueryable()); await _manager.Invoking(m => m.GetById("missing")) .Should().ThrowAsync(); @@ -775,7 +863,8 @@ public class PageTemplateManagerTests Updated = DateTime.UtcNow.AddDays(-1), UpdatedBy = new UserInfo { Email = "old@test.com" } }; - A.CallTo(() => _store.GetById("1")).Returns(existing); + var templates = new[] { existing }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); var updateInfo = new UserInfo { Email = "new@test.com" }; await _manager.Update("1", "New Name", "New content", updateInfo); @@ -788,22 +877,23 @@ public class PageTemplateManagerTests existing.Updated.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); } - A.CallTo(() => _store.Update(existing)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _repository.Update(existing, A._)).MustHaveHappenedOnceExactly(); } [Fact] - public async Task Delete_Should_Remove_From_Store() + public async Task Delete_Should_Remove_From_Repository() { var template = new PageTemplate { Id = "1" }; - A.CallTo(() => _store.GetById("1")).Returns(template); + var templates = new[] { template }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); await _manager.Delete("1"); - A.CallTo(() => _store.Delete(template)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _repository.Delete(template, A._)).MustHaveHappenedOnceExactly(); } [Fact] - public async Task GetAll_Should_Return_From_Store() + public async Task GetAll_Should_Return_From_Repository() { var templates = new[] { @@ -811,7 +901,7 @@ public class PageTemplateManagerTests new PageTemplate { Name = "B" } }.AsQueryable(); - A.CallTo(() => _store.GetAll()).Returns(templates); + A.CallTo(() => _repository.GetAll()).Returns(templates); var result = _manager.GetAll(); @@ -820,367 +910,6 @@ public class PageTemplateManagerTests } ``` -#### 7.2.2. PageTemplateService (Web-слой, `Mimisbrunnr.Web`) - -Тесты в `tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateServiceTests.cs`: - -```csharp -public class PageTemplateServiceTests -{ - private readonly IPageTemplateManager _manager; - private readonly IPermissionService _permissions; - private readonly ISpaceManager _spaceManager; - private readonly ITemplateRenderer _renderer; - private readonly PageTemplateService _service; - - public PageTemplateServiceTests() - { - _manager = A.Fake(); - _permissions = A.Fake(); - _spaceManager = A.Fake(); - _renderer = A.Fake(); - _service = new PageTemplateService(_manager, _permissions, _spaceManager, _renderer); - } - - // --- GetAll --- - - [Fact] - public async Task GetAll_System_Returns_All_System_Templates() - { - SetupSystemTemplates(); - var user = User("user@test.com"); - - var result = await _service.GetAll(TemplateType.System, null, user); - - result.Should().HaveCount(2); - } - - [Fact] - public async Task GetAll_User_Returns_Only_Own_Templates() - { - var user = User("owner@test.com"); - var all = new[] - { - Template("1", TemplateType.User, "owner@test.com"), - Template("2", TemplateType.User, "other@test.com"), - }.AsQueryable(); - A.CallTo(() => _manager.GetAll()).Returns(all); - - var result = await _service.GetAll(TemplateType.User, null, user); - - result.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task GetAll_Space_Returns_Only_When_User_Has_View_Permission() - { - var user = User("user@test.com"); - var space = new Space { Id = "s1", Key = "space1" }; - var all = new[] - { - Template("1", TemplateType.Space, spaceId: "s1"), - Template("2", TemplateType.Space, spaceId: "s2"), - }.AsQueryable(); - A.CallTo(() => _manager.GetAll()).Returns(all); - A.CallTo(() => _spaceManager.GetById("s1")).Returns(space); - A.CallTo(() => _permissions.EnsureViewPermission("space1", user)) - .DoesNothing(); // has permission - A.CallTo(() => _spaceManager.GetById("s2")) - .ThrowsAsync(new SpaceNotFoundException()); - - var result = await _service.GetAll(TemplateType.Space, "space1", user); - - result.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - // --- Create --- - - [Fact] - public async Task Create_System_By_NonAdmin_Throws() - { - var user = User("user@test.com", isAdmin: false); - - await _service.Invoking(s => s.Create( - new PageTemplateCreateModel { Type = "System", Name = "T", Content = "C" }, user)) - .Should().ThrowAsync(); - } - - [Fact] - public async Task Create_System_By_Admin_Succeeds() - { - var user = User("admin@test.com", isAdmin: true); - var model = new PageTemplateCreateModel - { - Type = "System", Name = "Sys", Content = "# Hello", Description = "A system template" - }; - - var result = await _service.Create(model, user); - - using (new AssertionScope()) - { - result.Name.Should().Be("Sys"); - result.Type.Should().Be(TemplateType.System.ToString()); - result.CreatedBy.Email.Should().Be("admin@test.com"); - } - } - - [Fact] - public async Task Create_User_Sets_OwnerEmail() - { - var user = User("user@test.com"); - var model = new PageTemplateCreateModel - { - Type = "User", Name = "My", Content = "# My template" - }; - - var result = await _service.Create(model, user); - - result.OwnerEmail.Should().Be("user@test.com"); - } - - [Fact] - public async Task Create_Space_By_NonAdmin_Throws() - { - var user = User("user@test.com"); - var model = new PageTemplateCreateModel - { - Type = "Space", Name = "S", Content = "C", SpaceKey = "space1" - }; - SetupSpace("space1"); - A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)) - .ThrowsAsync(new UserHasNotPermissionException()); - - await _service.Invoking(s => s.Create(model, user)) - .Should().ThrowAsync(); - } - - [Fact] - public async Task Create_Space_By_Admin_Succeeds() - { - var user = User("admin@test.com"); - var model = new PageTemplateCreateModel - { - Type = "Space", Name = "S", Content = "C", SpaceKey = "space1" - }; - var space = SetupSpace("space1"); - A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)).DoesNothing(); - - var result = await _service.Create(model, user); - - result.SpaceId.Should().Be(space.Id); - } - - // --- Update --- - - [Fact] - public async Task Update_System_By_NonAdmin_Throws() - { - var user = User("user@test.com", isAdmin: false); - A.CallTo(() => _manager.GetById("1")) - .Returns(Template("1", TemplateType.System)); - - await _service.Invoking(s => s.Update("1", - new PageTemplateUpdateModel { Name = "N", Content = "C" }, user)) - .Should().ThrowAsync(); - } - - [Fact] - public async Task Update_User_By_Owner_Succeeds() - { - var user = User("owner@test.com"); - A.CallTo(() => _manager.GetById("1")) - .Returns(Template("1", TemplateType.User, ownerEmail: "owner@test.com")); - - await _service.Update("1", - new PageTemplateUpdateModel { Name = "New", Content = "New content" }, user); - - A.CallTo(() => _manager.Update("1", "New", "New content", user)) - .MustHaveHappenedOnceExactly(); - } - - // --- Delete --- - - [Fact] - public async Task Delete_User_By_NonOwner_Throws() - { - var user = User("other@test.com"); - A.CallTo(() => _manager.GetById("1")) - .Returns(Template("1", TemplateType.User, ownerEmail: "owner@test.com")); - - await _service.Invoking(s => s.Delete("1", user)) - .Should().ThrowAsync(); - } - - [Fact] - public async Task Delete_Space_By_SpaceAdmin_Succeeds() - { - var user = User("spaceadmin@test.com"); - var space = new Space { Id = "s1", Key = "space1" }; - A.CallTo(() => _manager.GetById("1")) - .Returns(Template("1", TemplateType.Space, spaceId: "s1")); - A.CallTo(() => _spaceManager.GetById("s1")).Returns(space); - A.CallTo(() => _permissions.EnsureAdminPermission("space1", user)).DoesNothing(); - - await _service.Delete("1", user); - - A.CallTo(() => _manager.Delete("1")).MustHaveHappenedOnceExactly(); - } - - // --- Render --- - - [Fact] - public async Task Render_Returns_Rendered_Content_With_All_Parameters() - { - var user = User("user@test.com"); - var space = new Space { Id = "s1", Key = "space1", Name = "My Space" }; - var template = new PageTemplate - { - Id = "1", - Content = "# {{SpaceName}} by {{UserName}}", - Type = TemplateType.System - }; - - A.CallTo(() => _manager.GetById("1")).Returns(template); - A.CallTo(() => _spaceManager.GetByKey("space1")).Returns(space); - A.CallTo(() => _renderer.Render(template.Content, A>._)) - .ReturnsLazily((string t, IDictionary p) => - $"# {p["SpaceName"]} by {p["UserName"]}"); - - var result = await _service.Render("1", "space1", user); - - result.Content.Should().Be("# My Space by user@test.com"); - } - - [Fact] - public async Task Render_Should_Pass_Correct_Parameters_To_Renderer() - { - var user = User("john@test.com"); - var space = new Space { Id = "s1", Key = "dev", Name = "Development" }; - var template = new PageTemplate { Id = "1", Content = "{{CurrentDate}}", Type = TemplateType.System }; - - A.CallTo(() => _manager.GetById("1")).Returns(template); - A.CallTo(() => _spaceManager.GetByKey("dev")).Returns(space); - - await _service.Render("1", "dev", user); - - A.CallTo(() => _renderer.Render(A._, A>.That - .Matches(d => - d.ContainsKey("CurrentDate") && - d.ContainsKey("CurrentTime") && - d.ContainsKey("CurrentDateTime") && - d.ContainsKey("SpaceName") && (string)d["SpaceName"] == "Development" && - d.ContainsKey("SpaceKey") && (string)d["SpaceKey"] == "dev" && - d.ContainsKey("UserName")))) - .MustHaveHappenedOnceExactly(); - } - - // --- Helper methods --- - - private static UserInfo User(string email, bool isAdmin = false) => - new() { Email = email, Name = email.Split('@')[0] }; - - private static PageTemplate Template(string id, TemplateType type, - string? ownerEmail = null, string? spaceId = null) => - new() - { - Id = id, - Name = "Template " + id, - Content = "# Content", - Type = type, - OwnerEmail = ownerEmail, - SpaceId = spaceId - }; - - private void SetupSystemTemplates() - { - var templates = new[] - { - Template("s1", TemplateType.System), - Template("s2", TemplateType.System), - }.AsQueryable(); - A.CallTo(() => _manager.GetAll()).Returns(templates); - } - - private Space SetupSpace(string key) - { - var space = new Space { Id = key + "_id", Key = key, Name = key }; - A.CallTo(() => _spaceManager.GetByKey(key)).Returns(space); - return space; - } -} -``` - -#### 7.2.3. PageTemplateController (API, `Mimisbrunnr.Web`) - -Тесты в `tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateControllerTests.cs`: - -```csharp -public class PageTemplateControllerTests -{ - // Use the project's existing TestBase or controller test helper pattern - // проверяет routing, валидацию модели, проброс ошибок сервиса - - [Fact] - public async Task GetAll_Should_Call_Service_And_Return_Ok() - { - // Arrange - var service = A.Fake(); - var controller = new PageTemplateController(service); - var user = new UserInfo { Email = "test@test.com" }; - controller.SetupControllerContext(user); - - var expected = new[] { new PageTemplateModel { Id = "1" } }; - A.CallTo(() => service.GetAll(null, null, user)).Returns(expected); - - // Act - var result = await controller.GetAll(null, null); - - // Assert - result.Should().BeOfType() - .Which.Value.Should().BeSameAs(expected); - } - - [Fact] - public async Task Create_InvalidModel_Returns_BadRequest() - { - var service = A.Fake(); - var controller = new PageTemplateController(service); - controller.ModelState.AddModelError("Name", "Required"); - - var result = await controller.Create(new PageTemplateCreateModel()); - - result.Should().BeOfType(); - } - - [Fact] - public async Task Render_Should_Return_Content() - { - var service = A.Fake(); - var controller = new PageTemplateController(service); - var user = new UserInfo { Email = "test@test.com" }; - controller.SetupControllerContext(user); - - A.CallTo(() => service.Render("1", "space1", user)) - .Returns(new PageTemplateRenderResult { Content = "# Rendered" }); - - var result = await controller.Render("1", new PageTemplateRenderRequest - { - TemplateId = "1", - SpaceKey = "space1" - }); - - var okResult = result.Should().BeOfType().Subject; - okResult.Value.Should().BeEquivalentTo(new { content = "# Rendered" }); - } -} -``` - -#### 7.2.4. PageTemplateStore (MongoDB, интеграционные) - -Опционально — интеграционные тесты с тестовой MongoDB (через `Testcontainers.MongoDb` или in-memory): -- Создание, чтение, обновление, удаление записей -- Фильтрация по типу, ownerEmail, spaceId - ### 7.3. Запуск тестов ```bash @@ -1206,14 +935,13 @@ dotnet test tests/Mimisbrunnr.Web.Tests/ | 1.1 | Создать проект `Mimisbrunnr.PageTemplates` | `.csproj` | | 1.2 | Определить `TemplateType` enum | `Contracts/TemplateType.cs` | | 1.3 | Определить `PageTemplate` entity | `Contracts/PageTemplate.cs` | -| 1.4 | Определить `IPageTemplateStore` + `IPageTemplateManager` | `Services/` | -| 1.5 | Реализовать `PageTemplateManager` | `Services/PageTemplateManager.cs` | +| 1.4 | Определить `IPageTemplateManager` | `Services/` | +| 1.5 | Реализовать `PageTemplateManager` (через `IRepository`) | `Services/PageTemplateManager.cs` | | 1.6 | Создать `PageTemplatesModule` | `PageTemplatesModule.cs` | | 1.7 | Создать `PageTemplateMap` (MongoDB mapping) | `Mimisbrunnr.Storage.MongoDb/Mappings/` | -| 1.8 | Создать `PageTemplateStore` (MongoDB) | `Mimisbrunnr.Storage.MongoDb/` | -| 1.9 | Зарегистрировать entity и store в `MongoDbStoreModule` | `MongoDbStoreModule.cs` | -| 1.10 | Добавить индексы (Type, OwnerEmail, SpaceId) | `MongoDbStoreModule.cs` | -| 1.11 | Добавить `PageTemplatesModule` в `StartupModule` | `StartupModule.cs` | +| 1.8 | Зарегистрировать entity в `MongoDbStoreModule` | `MongoDbStoreModule.cs` | +| 1.9 | Добавить индексы (Type, OwnerEmail, SpaceId) | `MongoDbStoreModule.cs` | +| 1.10 | Добавить `PageTemplatesModule` в `StartupModule` | `StartupModule.cs` | **Проверка:** `dotnet build` успешно собирается. @@ -1235,7 +963,7 @@ dotnet test tests/Mimisbrunnr.Web.Tests/ | Шаг | Действие | |-----|----------| -| 3.1 | Написать тесты `PageTemplateManagerTests` (store mocking) | +| 3.1 | Написать тесты `PageTemplateManagerTests` (repository mocking) | | 3.2 | Написать тесты `PageTemplateServiceTests` (все сценарии прав) | | 3.3 | Написать тесты `PageTemplateControllerTests` (routing, validation, errors) | | 3.4 | Запустить `dotnet test` — все тесты зелёные | diff --git a/docs/stubble-renderer.md b/docs/stubble-renderer.md new file mode 100644 index 00000000..6d48a718 --- /dev/null +++ b/docs/stubble-renderer.md @@ -0,0 +1,163 @@ +# Stubble Template Renderer + +The Stubble Template Renderer is a Mustache-based templating engine used in Mimisbrunnr for rendering dynamic content. It is built on the [Stubble.Core](https://github.com/StubbleOrg/Stubble) library. + +## Current Usage + +Currently, the template renderer is used **exclusively in plugin macros** for rendering macro templates with user-provided parameters and predefined context variables. + +> **Coming Soon:** The template renderer will also be available for **page templates** when the page templates feature is implemented. + +## Basic Syntax + +### Variable Substitution + +Replace `{{variable}}` with the value from the parameters: + +``` +{{name}} +{{PageName}} +``` + +### Sections (Conditionals / Iteration) + +Sections render their content only if the value is truthy (non-empty, non-null): + +``` +{{#title}}

{{title}}

{{/title}} +``` + +## Predefined Context Variables + +These variables are automatically available in all macro templates: + +| Variable | Description | +|----------|-------------| +| `{{PageId}}` | Current page ID | +| `{{PageName}}` | Current page name | +| `{{SpaceKey}}` | Current space key | +| `{{SpaceName}}` | Current space name | +| `{{UserEmail}}` | Current user's email | +| `{{UserName}}` | Current user's display name | +| `{{MacroIdOnPage}}` | Unique macro instance identifier on the page | + +## Built-in Helpers + +Helpers are functions that transform values. They use the section syntax `{{#Helper}}...{{/Helper}}`. + +### String Transformations + +#### ToLower + +Converts the rendered content to lowercase: + +```mustache +{{#ToLower}}{{SomeParam}}{{/ToLower}} +``` + +#### ToUpper + +Converts the rendered content to uppercase: + +```mustache +{{#ToUpper}}{{title}}{{/ToUpper}} +``` + +### Encoding + +#### UrlEncode + +URL-encodes the rendered content (useful for query parameters and URLs): + +```mustache + +``` + +#### HtmlEncode + +HTML-encodes the rendered content (useful for preventing XSS): + +```mustache + +``` + +## Examples + +### Simple Variable Substitution + +```json +{ + "Template": "

Hello {{name}} {{surname}}

" +} +``` + +### Using Predefined Variables + +```json +{ + "Template": "
Page: {{PageName}} in space {{SpaceKey}}
" +} +``` + +### Chaining Helpers + +Helpers can be chained — the inner helper is applied first: + +```json +{ + "Template": "

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

" +} +``` + +This converts `title` to uppercase first, then HTML-encodes the result. + +### Conditional Rendering + +```json +{ + "Template": "
{{#title}}

{{title}}

{{/title}}

{{text}}

" +} +``` + +The `

` tag is only rendered if `title` has a value. + +### Complete Example: Warning Block Macro + +```json +{ + "Template": "
\n{{#title}}

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

{{/title}}\n{{text}}\n
" +} +``` + +## Limitations + +1. **Mustache spec subset**: Only basic Mustache features are supported (variables, sections). Partials, inverted sections (`{{^var}}`), and set-delimiters are not tested and may not work as expected. + +2. **Helpers require content**: Due to Stubble's lambda signature, helpers must wrap some content between tags — `{{#Helper}}value{{/Helper}}`. Empty helper calls like `{{#Helper}}{{/Helper}}` work only for helpers that don't need input (e.g., `Now`, `Today`, `CurrentTime`). + +3. **No custom helper registration**: Built-in helpers are hardcoded in `StubbleTemplateRenderer`. Users cannot register their own helpers. + +4. **Case-insensitive keys**: Key lookup is case-insensitive by default (`{{name}}` and `{{Name}}` resolve to the same value). + +5. **No template inheritance**: Mustache's block inheritance feature is not supported. + +6. **Async-only rendering**: Templates are rendered asynchronously via `RenderAsync`. Synchronous rendering is not available. + +## Configuration + +The renderer is registered as a singleton in the DI container: + +```csharp +services.AddSingleton(); +``` + +Case-insensitive key lookup is enabled by default: + +```csharp +settings.SetIgnoreCaseOnKeyLookup(true); +``` + +## Source + +- Implementation: `src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs` +- Interface: `src/Mimisbrunnr.Web.Infrastructure/ITemplateRenderer.cs` From 1aaff7d7fc4b667cc047bcf89e97b99f122395cb Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sat, 13 Jun 2026 20:40:32 +0500 Subject: [PATCH 03/10] Page template Implementation --- .vscode/settings.json | 5 +- .../PageTemplates/PageTemplateCreateModel.cs | 12 + .../PageTemplates/PageTemplateModel.cs | 18 ++ .../PageTemplateRenderRequest.cs | 9 + .../PageTemplateRenderResponse.cs | 6 + .../PageTemplates/PageTemplateUpdateModel.cs | 10 + .../Contracts/PageTemplate.cs | 19 ++ .../PageTemplateNotFoundException.cs | 6 + .../Contracts/TemplateType.cs | 8 + .../Mimisbrunnr.PageTemplates.csproj | 14 ++ .../PageTemplatesModule.cs | 15 ++ .../Services/IPageTemplateManager.cs | 13 + .../Services/PageTemplateManager.cs | 53 ++++ .../Mappings/PageTemplateMap.cs | 14 ++ .../Mimisbrunnr.Storage.MongoDb.csproj | 1 + .../MongoDbStoreModule.cs | 26 ++ .../ClientApp/src/App.vue | 3 + .../ClientApp/src/assets/lang.json | 54 +++- .../ClientApp/src/components/admin/Menu.vue | 31 +-- .../base/CreateFromTemplateModal.vue | 79 ++++++ .../ClientApp/src/components/base/Header.vue | 23 ++ .../people/profile/SettingsModal.vue | 3 + .../profile/settings/PageTemplatesTab.vue | 16 ++ .../ClientApp/src/components/space/Menu.vue | 8 + .../components/space/modal/PageTemplates.vue | 17 ++ .../templates/PageTemplateManager.vue | 104 ++++++++ .../templates/PageTemplateModal.vue | 85 +++++++ .../ClientApp/src/router.js | 5 + .../src/services/pageTemplateService.js | 48 ++++ .../ClientApp/src/services/store.js | 14 ++ .../src/views/Admin/GeneralConfiguration.vue | 2 +- .../ClientApp/src/views/Admin/Groups.vue | 2 +- .../src/views/Admin/PageTemplates.vue | 41 ++++ .../ClientApp/src/views/Admin/Users.vue | 2 +- .../ClientApp/src/views/Wiki/Space.vue | 6 + src/Mimisbrunnr.Web.Host/StartupModule.cs | 2 + .../HandlePageTemplateErrorsAttribute.cs | 35 +++ .../Mapping/PageTemplateMapper.cs | 11 + src/Mimisbrunnr.Web/Mimisbrunnr.Web.csproj | 1 + .../PageTemplates/IPageTemplateService.cs | 14 ++ .../PageTemplates/PageTemplateController.cs | 87 +++++++ .../PageTemplates/PageTemplateService.cs | 230 ++++++++++++++++++ src/Mimisbrunnr.Web/WebModule.cs | 2 + .../Mimisbrunnr.Web.Tests.csproj | 23 ++ .../PageTemplates/PageTemplateManagerTests.cs | 127 ++++++++++ 45 files changed, 1271 insertions(+), 33 deletions(-) create mode 100644 src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs create mode 100644 src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs create mode 100644 src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs create mode 100644 src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs create mode 100644 src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Contracts/PageTemplate.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Contracts/PageTemplateNotFoundException.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Mimisbrunnr.PageTemplates.csproj create mode 100644 src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs create mode 100644 src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs create mode 100644 src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/components/people/profile/settings/PageTemplatesTab.vue create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/modal/PageTemplates.vue create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js create mode 100644 src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/PageTemplates.vue create mode 100644 src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs create mode 100644 src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs create mode 100644 src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs create mode 100644 src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs create mode 100644 src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs create mode 100644 tests/Mimisbrunnr.Web.Tests/Mimisbrunnr.Web.Tests.csproj create mode 100644 tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateManagerTests.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 594e63ff..4d558baf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,8 @@ "Childs", "Mimisbrunnr" ], - "cSpell.language": "en,ru,ru-RU" + "cSpell.language": "en,ru,ru-RU", + "dotrush.roslyn.projectOrSolutionFiles": [ + "Mimisbrunnr.sln" + ] } \ No newline at end of file diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs new file mode 100644 index 00000000..8482c71b --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +public class PageTemplateCreateModel +{ + [Required] public string Name { get; set; } + public string Description { get; set; } + [Required] public string Content { get; set; } + [Required] public string Type { get; set; } + public string SpaceKey { get; set; } +} diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs new file mode 100644 index 00000000..864d398f --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs @@ -0,0 +1,18 @@ +using Mimisbrunnr.Integration.User; + +namespace Mimisbrunnr.Integration.PageTemplates; + +public class PageTemplateModel +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Content { get; set; } + public string Type { get; set; } + public string OwnerEmail { get; set; } + public string SpaceId { get; set; } + public DateTime Created { get; set; } + public DateTime Updated { get; set; } + public UserModel CreatedBy { get; set; } + public UserModel UpdatedBy { get; set; } +} diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs new file mode 100644 index 00000000..3f12fe77 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +public class PageTemplateRenderRequest +{ + [Required] public string TemplateId { get; set; } + [Required] public string SpaceKey { get; set; } +} diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs new file mode 100644 index 00000000..c9e82ffd --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs @@ -0,0 +1,6 @@ +namespace Mimisbrunnr.Integration.PageTemplates; + +public class PageTemplateRenderResponse +{ + public string Content { get; set; } +} diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs new file mode 100644 index 00000000..f2ddf197 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +public class PageTemplateUpdateModel +{ + [Required] public string Name { get; set; } + public string Description { get; set; } + [Required] public string Content { get; set; } +} diff --git a/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplate.cs b/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplate.cs new file mode 100644 index 00000000..93b5f6df --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplate.cs @@ -0,0 +1,19 @@ +using Mimisbrunnr.Wiki.Contracts; +using Skidbladnir.Repository.Abstractions; + +namespace Mimisbrunnr.PageTemplates.Contracts; + +public class PageTemplate : IHasId +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Content { get; set; } + public string Type { get; set; } + public string OwnerEmail { get; set; } + public string SpaceId { get; set; } + public DateTime Created { get; set; } + public DateTime Updated { get; set; } + public UserInfo CreatedBy { get; set; } + public UserInfo UpdatedBy { get; set; } +} diff --git a/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplateNotFoundException.cs b/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplateNotFoundException.cs new file mode 100644 index 00000000..40cbef4b --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Contracts/PageTemplateNotFoundException.cs @@ -0,0 +1,6 @@ +namespace Mimisbrunnr.PageTemplates.Contracts; + +public class PageTemplateNotFoundException : Exception +{ + public PageTemplateNotFoundException(string message = "Page template not found") : base(message) { } +} diff --git a/src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs b/src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs new file mode 100644 index 00000000..57c68029 --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Contracts/TemplateType.cs @@ -0,0 +1,8 @@ +namespace Mimisbrunnr.PageTemplates.Contracts; + +public static class TemplateType +{ + public const string System = "System"; + public const string User = "User"; + public const string Space = "Space"; +} diff --git a/src/Mimisbrunnr.PageTemplates/Mimisbrunnr.PageTemplates.csproj b/src/Mimisbrunnr.PageTemplates/Mimisbrunnr.PageTemplates.csproj new file mode 100644 index 00000000..b9b60b69 --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Mimisbrunnr.PageTemplates.csproj @@ -0,0 +1,14 @@ + + + net8.0 + enable + false + + + + + + + + + diff --git a/src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs b/src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs new file mode 100644 index 00000000..dc47d30b --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/PageTemplatesModule.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Mimisbrunnr.PageTemplates.Services; +using Skidbladnir.Modules; + +namespace Mimisbrunnr.PageTemplates; + +public class PageTemplatesModule : Module +{ + public override Type[] DependsModules => []; + + public override void Configure(IServiceCollection services) + { + services.AddSingleton(); + } +} diff --git a/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs new file mode 100644 index 00000000..6e7716a6 --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs @@ -0,0 +1,13 @@ +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.Wiki.Contracts; + +namespace Mimisbrunnr.PageTemplates.Services; + +public interface IPageTemplateManager +{ + Task GetById(string id); + IQueryable GetAll(); + Task Create(PageTemplate template); + Task Update(string id, string name, string content, UserInfo updatedBy); + Task Delete(string id); +} diff --git a/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs new file mode 100644 index 00000000..e90fc94f --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs @@ -0,0 +1,53 @@ +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.Wiki.Contracts; +using Skidbladnir.Repository.Abstractions; + +namespace Mimisbrunnr.PageTemplates.Services; + +public class PageTemplateManager : IPageTemplateManager +{ + private readonly IRepository _repository; + + public PageTemplateManager(IRepository repository) + { + _repository = repository; + } + + public async Task GetById(string id) + { + var template = await _repository.GetAll().FirstOrDefaultAsync(x => x.Id == id); + if (template == null) + throw new PageTemplateNotFoundException($"Page template with id '{id}' not found"); + return template; + } + + public IQueryable GetAll() => _repository.GetAll(); + + public async Task Create(PageTemplate template) + { + var now = DateTime.UtcNow; + template.Id = Guid.NewGuid().ToString(); + template.Created = now; + template.Updated = now; + template.CreatedBy = new UserInfo { Email = template.OwnerEmail }; + template.UpdatedBy = new UserInfo { Email = template.OwnerEmail }; + await _repository.Create(template); + return template; + } + + public async Task Update(string id, string name, string content, UserInfo updatedBy) + { + var template = await GetById(id); + template.Name = name; + template.Content = content; + template.Updated = DateTime.UtcNow; + template.UpdatedBy = updatedBy; + await _repository.Update(template); + } + + public async Task Delete(string id) + { + var template = await GetById(id); + await _repository.Delete(template); + } +} diff --git a/src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs b/src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs new file mode 100644 index 00000000..eaa7a106 --- /dev/null +++ b/src/Mimisbrunnr.Storage.MongoDb/Mappings/PageTemplateMap.cs @@ -0,0 +1,14 @@ +using Mimisbrunnr.PageTemplates.Contracts; +using MongoDB.Bson; +using Skidbladnir.Repository.MongoDB; + +namespace Mimisbrunnr.Storage.MongoDb.Mappings; + +public class PageTemplateMap : EntityMapClass +{ + public PageTemplateMap() + { + ToCollection("PageTemplates"); + MapId(x => x.Id, BsonType.String); + } +} diff --git a/src/Mimisbrunnr.Storage.MongoDb/Mimisbrunnr.Storage.MongoDb.csproj b/src/Mimisbrunnr.Storage.MongoDb/Mimisbrunnr.Storage.MongoDb.csproj index d76084f5..ba3515ba 100644 --- a/src/Mimisbrunnr.Storage.MongoDb/Mimisbrunnr.Storage.MongoDb.csproj +++ b/src/Mimisbrunnr.Storage.MongoDb/Mimisbrunnr.Storage.MongoDb.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs b/src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs index 36a8e9b0..235c9f9b 100644 --- a/src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs +++ b/src/Mimisbrunnr.Storage.MongoDb/MongoDbStoreModule.cs @@ -13,6 +13,7 @@ using Mimisbrunnr.Favorites.Contracts; using MongoDB.Bson.Serialization; using Mimisbrunnr.Favorites.Services; +using Mimisbrunnr.PageTemplates.Contracts; namespace Mimisbrunnr.Storage.MongoDb; @@ -52,6 +53,7 @@ public override void Configure(IServiceCollection services) .AddEntity() .AddEntity() .AddEntity() + .AddEntity() ); BsonClassMap.RegisterClassMap(new FavoriteUserMap()); BsonClassMap.RegisterClassMap(new FavoriteSpaceMap()); @@ -81,6 +83,7 @@ public override async Task StartAsync(IServiceProvider provider, CancellationTok await CreateFavoriteIndexes(baseMongoContext); await CreateCommentIndexes(baseMongoContext); await CreatePluginIndexes(baseMongoContext); + await CreatePageTemplateIndexes(baseMongoContext); } catch (Exception e) { @@ -415,4 +418,27 @@ private static async Task CreatePluginIndexes(BaseMongoDbContext mongoDbContext) })); } + + private static async Task CreatePageTemplateIndexes(IMongoDbContext mongoContext) + { + var collection = mongoContext.GetCollection(); + + var typeKeyDefinition = Builders.IndexKeys.Ascending(x => x.Type); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(typeKeyDefinition, new CreateIndexOptions() + { + Background = true + })); + + var ownerEmailKeyDefinition = Builders.IndexKeys.Ascending(x => x.OwnerEmail); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(ownerEmailKeyDefinition, new CreateIndexOptions() + { + Background = true + })); + + var spaceIdKeyDefinition = Builders.IndexKeys.Ascending(x => x.SpaceId); + await collection.Indexes.CreateOneAsync(new CreateIndexModel(spaceIdKeyDefinition, new CreateIndexOptions() + { + Background = true + })); + } } \ No newline at end of file diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/App.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/App.vue index 3dc1798b..c14d3a61 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/App.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/App.vue @@ -4,6 +4,7 @@
+
@@ -17,6 +18,7 @@ import Header from "@/components/base/Header.vue"; const Quickstart = () => import(/* webpackChunkName: "quicstart-component" */"@/components/quickstart/Quickstart.vue"); const SpaceCreateModal = () => import(/* webpackChunkName: "space-create-modal" */"@/components/base/SpaceCreateModal.vue"); const SearchBar = () => import(/* webpackChunkName: "search-bar-component" */"@/components/search/SearchBar.vue"); +const CreateFromTemplateModal = () => import(/* webpackChunkName: "base-modals" */"@/components/base/CreateFromTemplateModal.vue"); import axios from "axios"; import ProfileService from "@/services/profileService"; export default { @@ -25,6 +27,7 @@ export default { SpaceCreateModal, Quickstart, SearchBar, + CreateFromTemplateModal, }, data: () => ({ loaded: false, diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json b/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json index faf83874..7d38f0f6 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json @@ -8,6 +8,7 @@ }, "people": "People", "pageCreateButton": "Create", + "pageCreateFromTemplateButton": "Create from template", "search": "Search", "login": "Log in", "lang": "Language", @@ -253,6 +254,9 @@ "ok": "Yes", "cancel": "No" } + }, + "templates": { + "title": "Page templates" } }, "profile": { @@ -290,7 +294,8 @@ }, "other": { "title": "Other" - } + }, + "pageTemplates": "My templates" }, "favorites": "Favorites", "additional": { @@ -440,7 +445,8 @@ "title": "Space actions", "profile": "Profile", "permissions": "Permissions", - "settings": "Settings" + "settings": "Settings", + "templates": "Page templates" }, "tree": "Page tree", "emptyTree": "Empty", @@ -496,6 +502,23 @@ "newPage": { "defaultTitle": "New page", "defaultContent": "_Add your information here_" + }, + "pageTemplates": { + "title": "Page Templates", + "create": "Create", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete this template?", + "name": "Name", + "description": "Description", + "content": "Content", + "source": "Source", + "system": "System", + "user": "Personal", + "space": "Space", + "createFromTemplate": "Create from Template", + "noTemplates": "No templates available", + "createPageFromTemplate": "Create page from template" } }, "ru": { @@ -507,6 +530,7 @@ }, "people": "Пользователи", "pageCreateButton": "Создать", + "pageCreateFromTemplateButton": "Создать из шаблона", "search": "Поиск", "login": "Вход", "lang": "Язык", @@ -753,6 +777,9 @@ "ok": "Да", "cancel": "Нет" } + }, + "templates": { + "title": "Шаблоны страниц" } }, "profile": { @@ -790,7 +817,8 @@ }, "other": { "title": "Прочее" - } + }, + "pageTemplates": "Мои шаблоны" }, "favorites": "Избранное", "additional": { @@ -940,7 +968,8 @@ "title": "Действия в пространстве", "profile": "Профиль", "permissions": "Разрешения", - "settings": "Настройки" + "settings": "Настройки", + "templates": "Шаблоны страниц" }, "tree": "Древо страниц", "emptyTree": "Пусто", @@ -996,6 +1025,23 @@ "newPage": { "defaultTitle": "Новая страница", "defaultContent": "_Добавьте свою информацию сюда_" + }, + "pageTemplates": { + "title": "Шаблоны страниц", + "create": "Создать", + "edit": "Редактировать", + "delete": "Удалить", + "deleteConfirm": "Вы уверены, что хотите удалить шаблон?", + "name": "Имя", + "description": "Описание", + "content": "Шаблон", + "source": "Тип", + "system": "Системный", + "user": "Личный", + "space": "Пространства", + "createFromTemplate": "Создать из шаблона", + "noTemplates": "Нет доступных шаблонов", + "createPageFromTemplate": "Создать страницу из шаблона" } } } \ No newline at end of file diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/admin/Menu.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/admin/Menu.vue index bd30dacb..7200259b 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/admin/Menu.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/admin/Menu.vue @@ -12,31 +12,12 @@ export default { data() { return { menuItems: [ - { - name: "General", - to: "/admin", - active: false, - disabled: false - }, - { - name: "Users", - to: "/admin/users", - active: false, - disabled: false - }, - { - name: "Groups", - to: "/admin/groups", - active: false, - disabled: false - }, - { - name: "Plugins", - to: "/admin/plugins", - active: false, - disabled: false - }, - ] + { name: "General", to: "/admin", active: false, disabled: false }, + { name: "Users", to: "/admin/users", active: false, disabled: false }, + { name: "Groups", to: "/admin/groups", active: false, disabled: false }, + { name: "Plugins", to: "/admin/plugins", active: false, disabled: false }, + { name: "Templates", to: "/admin/templates", active: false, disabled: false }, + ] } }, props: { diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue new file mode 100644 index 00000000..dce21c57 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/Header.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/Header.vue index 90a3fd05..3c4a4c13 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/Header.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/Header.vue @@ -43,6 +43,15 @@ {{ $t("header.pageCreateButton") }} + + + @@ -151,6 +160,7 @@ import { BIconPlus, BIconSearch, BIconGear, + BIconChevronDown, } from "bootstrap-vue"; import axios from "axios"; import ProfileService from "@/services/profileService"; @@ -171,6 +181,7 @@ export default { BIconPlus, BIconSearch, BIconGear, + BIconChevronDown, }, watch: { // eslint-disable-next-line @@ -252,6 +263,18 @@ export default { .create-button span { padding-top: 0.5em; } +.create-button + .create-from-template-button { + margin-left: 2px; + height: 32px; + margin-top: 0.2em; + padding-left: 6px; + padding-right: 6px; +} +@media (max-width: 576px) { + .create-from-template-button { + display: none !important; + } +} @media (max-width: 834px) { .create-button .create-icon { display: unset; diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/people/profile/SettingsModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/people/profile/SettingsModal.vue index f145d82b..cde9e677 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/people/profile/SettingsModal.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/people/profile/SettingsModal.vue @@ -9,6 +9,7 @@ + @@ -18,11 +19,13 @@ diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/Menu.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/Menu.vue index 02264032..ca4a99ad 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/Menu.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/Menu.vue @@ -39,6 +39,12 @@ >   {{ $t("space.actions.settings") }} + +   {{ $t("space.actions.templates") }} +
@@ -76,6 +82,7 @@ import { BIconPersonFill, BIconShieldLockFill, BIconGearFill, + BIconFiles, } from "bootstrap-vue"; import axios from "axios"; import { VueTreeList, Tree } from "vue-tree-list"; @@ -87,6 +94,7 @@ export default { BIconPersonFill, BIconShieldLockFill, BIconGearFill, + BIconFiles, }, data() { return { diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/modal/PageTemplates.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/modal/PageTemplates.vue new file mode 100644 index 00000000..e8fda692 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/space/modal/PageTemplates.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue new file mode 100644 index 00000000..05361396 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue new file mode 100644 index 00000000..1070ac8a --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/router.js b/src/Mimisbrunnr.Web.Host/ClientApp/src/router.js index 63606278..6d2a264c 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/router.js +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/router.js @@ -93,6 +93,11 @@ export default new Router({ path: '/admin/plugins', name: 'PluginsAdministration', component: () => import(/* webpackChunkName: "admin" */ './views/Admin/Plugins.vue') + }, + { + path: '/admin/templates', + name: 'PageTemplates', + component: () => import(/* webpackChunkName: "admin" */ './views/Admin/PageTemplates.vue') } ] }) diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js new file mode 100644 index 00000000..eb00543a --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js @@ -0,0 +1,48 @@ +async function request(url, options = {}) { + const res = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw { status: res.status, message: err.message, data: err }; + } + if (res.status === 200 && (res.headers.get("content-length") === "0" || res.headers.get("content-type") === null)) { + return null; + } + return res.json(); +} + +export default { + getAll(type, spaceKey) { + const params = new URLSearchParams(); + if (type) params.set("type", type); + if (spaceKey) params.set("spaceKey", spaceKey); + const qs = params.toString(); + return request("/api/pagetemplate" + (qs ? "?" + qs : "")); + }, + getById(id) { + return request("/api/pagetemplate/" + id); + }, + create(model) { + return request("/api/pagetemplate", { + method: "POST", + body: JSON.stringify(model), + }); + }, + update(id, model) { + return request("/api/pagetemplate/" + id, { + method: "PUT", + body: JSON.stringify(model), + }); + }, + delete(id) { + return request("/api/pagetemplate/" + id, { method: "DELETE" }); + }, + render(templateId, spaceKey) { + return request("/api/pagetemplate/" + templateId + "/render", { + method: "POST", + body: JSON.stringify({ templateId, spaceKey }), + }); + }, +}; diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/services/store.js b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/store.js index e901d61e..d974241f 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/services/store.js +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/store.js @@ -21,6 +21,11 @@ export default new Vuex.Store({ history: [], theme: '', version: '' + }, + templates: { + system: [], + user: [], + space: [], } }, getters: {}, @@ -62,6 +67,15 @@ export default new Vuex.Store({ // eslint-disable-next-line changeVersion(state, value) { state.application.version = value + }, + setSystemTemplates(state, payload) { + state.templates.system = payload + }, + setUserTemplates(state, payload) { + state.templates.user = payload + }, + setSpaceTemplates(state, payload) { + state.templates.space = payload } }, actions: {} diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/GeneralConfiguration.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/GeneralConfiguration.vue index 9b183028..1e3f7957 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/GeneralConfiguration.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/GeneralConfiguration.vue @@ -152,7 +152,7 @@ export default { @media (min-width: 440px) { .admin-general-card .card-body { - margin: 2.25rem 2.25rem 2.25rem 2.25rem; + margin: 1.25rem 1.25rem 1.25rem 1.25rem; } } \ No newline at end of file diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Groups.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Groups.vue index 45c0c930..6a6814bd 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Groups.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Groups.vue @@ -143,7 +143,7 @@ export default { @media (min-width: 440px) { .admin-group-card .card-body { - margin: 2.25rem 2.25rem 2.25rem 2.25rem; + margin: 1.25rem 1.25rem 1.25rem 1.25rem; } } diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/PageTemplates.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/PageTemplates.vue new file mode 100644 index 00000000..bc459955 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/PageTemplates.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Users.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Users.vue index bf79393c..a883dc89 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Users.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Admin/Users.vue @@ -210,7 +210,7 @@ export default { @media (min-width: 440px) { .admin-user-card .card-body { - margin: 2.25rem 2.25rem 2.25rem 2.25rem; + margin: 1.25rem 1.25rem 1.25rem 1.25rem; } } diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Wiki/Space.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Wiki/Space.vue index a2545009..3d6dd44d 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Wiki/Space.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/views/Wiki/Space.vue @@ -31,6 +31,7 @@ :spaceUpdateCallback="init" :space="space" /> + @@ -68,6 +69,10 @@ const Permissions = () => import( /* webpackChunkName: "space-modals-component" */ "@/components/space/modal/Permissions.vue" ); +const SpacePageTemplates = () => + import( + /* webpackChunkName: "space-modals-component" */ "@/components/space/modal/PageTemplates.vue" + ); import SpaceService from "@/services/spaceService"; import PageService from "../../services/pageService"; export default { @@ -82,6 +87,7 @@ export default { Settings, Attachments, Versions, + SpacePageTemplates, }, data() { return { diff --git a/src/Mimisbrunnr.Web.Host/StartupModule.cs b/src/Mimisbrunnr.Web.Host/StartupModule.cs index ef499c93..858e20ea 100644 --- a/src/Mimisbrunnr.Web.Host/StartupModule.cs +++ b/src/Mimisbrunnr.Web.Host/StartupModule.cs @@ -3,6 +3,7 @@ using Mimisbrunnr.DataImport; using Mimisbrunnr.DataImport.Confluence; using Mimisbrunnr.Persistent; +using Mimisbrunnr.PageTemplates; using Mimisbrunnr.Storage.MongoDb; using Mimisbrunnr.Web.Host.Configuration; using Mimisbrunnr.Web.Host.Services; @@ -24,6 +25,7 @@ internal class StartupModule : Module typeof(AspNetModule), typeof(MongoDbStoreModule), typeof(WebModule), typeof(PersistentModule), typeof(MetricsModule), typeof(ConfluenceDataImportModule), typeof(WebCacheModule), + typeof(PageTemplatesModule), ]; public override void Configure(IServiceCollection services) diff --git a/src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs b/src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs new file mode 100644 index 00000000..869c36cb --- /dev/null +++ b/src/Mimisbrunnr.Web/Filters/HandlePageTemplateErrorsAttribute.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Mimisbrunnr.Integration.Wiki; +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.Web.Infrastructure; + +namespace Mimisbrunnr.Web.Filters; + +public class HandlePageTemplateErrorsAttribute : ExceptionFilterAttribute +{ + public override Task OnExceptionAsync(ExceptionContext context) + { + switch (context.Exception) + { + case PageTemplateNotFoundException notFoundEx: + context.Result = new ObjectResult(new { message = notFoundEx.Message }) { StatusCode = 404 }; + context.ExceptionHandled = true; + break; + case UserHasNotPermissionException userHasNotPermissionEx: + context.Result = new ObjectResult(new { message = userHasNotPermissionEx.Message }) { StatusCode = 403 }; + context.ExceptionHandled = true; + break; + case SpaceNotFoundException spaceNotFoundEx: + context.Result = new ObjectResult(new { message = spaceNotFoundEx.Message }) { StatusCode = 404 }; + context.ExceptionHandled = true; + break; + case InvalidOperationException invalidOperationException: + context.Result = new ObjectResult(new { message = invalidOperationException.Message }) { StatusCode = 400 }; + context.ExceptionHandled = true; + break; + } + + return base.OnExceptionAsync(context); + } +} diff --git a/src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs b/src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs new file mode 100644 index 00000000..f68ca7f5 --- /dev/null +++ b/src/Mimisbrunnr.Web/Mapping/PageTemplateMapper.cs @@ -0,0 +1,11 @@ +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.PageTemplates.Contracts; +using Riok.Mapperly.Abstractions; + +namespace Mimisbrunnr.Web.Mapping; + +[Mapper] +public static partial class PageTemplateMapper +{ + public static partial PageTemplateModel ToModel(this PageTemplate template); +} diff --git a/src/Mimisbrunnr.Web/Mimisbrunnr.Web.csproj b/src/Mimisbrunnr.Web/Mimisbrunnr.Web.csproj index 32e596b3..09c02d4c 100644 --- a/src/Mimisbrunnr.Web/Mimisbrunnr.Web.csproj +++ b/src/Mimisbrunnr.Web/Mimisbrunnr.Web.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs new file mode 100644 index 00000000..2b6692cf --- /dev/null +++ b/src/Mimisbrunnr.Web/PageTemplates/IPageTemplateService.cs @@ -0,0 +1,14 @@ +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.Wiki.Contracts; + +namespace Mimisbrunnr.Web.PageTemplates; + +public interface IPageTemplateService +{ + Task GetAll(string type, string spaceKey, UserInfo user); + Task GetById(string id, UserInfo user); + Task Create(PageTemplateCreateModel model, UserInfo user); + Task Update(string id, PageTemplateUpdateModel model, UserInfo user); + Task Delete(string id, UserInfo user); + Task Render(string templateId, string spaceKey, UserInfo user); +} diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs new file mode 100644 index 00000000..bb0bc9c6 --- /dev/null +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Mvc; +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.Web.Filters; +using Mimisbrunnr.Web.Mapping; + +namespace Mimisbrunnr.Web.PageTemplates; + +[Route("api/[controller]")] +[ApiController] +[Microsoft.AspNetCore.Authorization.Authorize] +[HandlePageTemplateErrors] +public class PageTemplateController : ControllerBase +{ + private readonly IPageTemplateService _pageTemplateService; + + public PageTemplateController(IPageTemplateService pageTemplateService) + { + _pageTemplateService = pageTemplateService; + } + + [HttpGet] + [ProducesResponseType(typeof(PageTemplateModel[]), 200)] + [ProducesResponseType(401)] + public async Task GetAll([FromQuery] string? type = null, [FromQuery] string? spaceKey = null) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.GetAll(type, spaceKey, user); + return Ok(result); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(PageTemplateModel), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(404)] + public async Task GetById(string id) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.GetById(id, user); + return Ok(result); + } + + [HttpPost] + [ProducesResponseType(typeof(PageTemplateModel), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + public async Task Create([FromBody] PageTemplateCreateModel model) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.Create(model, user); + return Ok(result); + } + + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task Update(string id, [FromBody] PageTemplateUpdateModel model) + { + var user = User?.ToInfo(); + await _pageTemplateService.Update(id, model, user); + return Ok(); + } + + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task Delete(string id) + { + var user = User?.ToInfo(); + await _pageTemplateService.Delete(id, user); + return Ok(); + } + + [HttpPost("{id}/render")] + [ProducesResponseType(typeof(PageTemplateRenderResponse), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(404)] + public async Task Render(string id, [FromBody] PageTemplateRenderRequest request) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.Render(id, request.SpaceKey, user); + return Ok(result); + } +} diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs new file mode 100644 index 00000000..5aa7f4f2 --- /dev/null +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs @@ -0,0 +1,230 @@ +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.Integration.Wiki; +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.PageTemplates.Services; +using Mimisbrunnr.Users; +using Mimisbrunnr.Web.Infrastructure; +using Mimisbrunnr.Web.Mapping; +using Mimisbrunnr.Web.Services; +using Mimisbrunnr.Wiki.Contracts; +using Mimisbrunnr.Wiki.Services; + +namespace Mimisbrunnr.Web.PageTemplates; + +internal class PageTemplateService : IPageTemplateService +{ + private readonly IPageTemplateManager _pageTemplateManager; + private readonly ISpaceManager _spaceManager; + private readonly IPermissionService _permissionService; + private readonly IUserManager _userManager; + private readonly ITemplateRenderer _templateRenderer; + + public PageTemplateService( + IPageTemplateManager pageTemplateManager, + ISpaceManager spaceManager, + IPermissionService permissionService, + IUserManager userManager, + ITemplateRenderer templateRenderer) + { + _pageTemplateManager = pageTemplateManager; + _spaceManager = spaceManager; + _permissionService = permissionService; + _userManager = userManager; + _templateRenderer = templateRenderer; + } + + public async Task GetAll(string type, string spaceKey, UserInfo user) + { + var query = _pageTemplateManager.GetAll(); + + var templates = new List(); + + if (string.IsNullOrEmpty(type) || type == TemplateType.System) + { + var systemTemplates = query.Where(x => x.Type == TemplateType.System).ToArray(); + templates.AddRange(systemTemplates); + } + + if (string.IsNullOrEmpty(type) || type == TemplateType.User) + { + if (user != null) + { + var userTemplates = query.Where(x => x.Type == TemplateType.User && x.OwnerEmail == user.Email).ToArray(); + templates.AddRange(userTemplates); + } + } + + if (string.IsNullOrEmpty(type) || type == TemplateType.Space) + { + if (!string.IsNullOrEmpty(spaceKey)) + { + await _permissionService.EnsureEditPermission(spaceKey, user); + var space = await _spaceManager.GetByKey(spaceKey); + var spaceTemplates = query.Where(x => x.Type == TemplateType.Space && x.SpaceId == space.Id).ToArray(); + templates.AddRange(spaceTemplates); + } + } + + return templates.Select(x => x.ToModel()).ToArray(); + } + + public async Task GetById(string id, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureReadPermission(template, user); + return template.ToModel(); + } + + public async Task Create(PageTemplateCreateModel model, UserInfo user) + { + await EnsureCreatePermission(model.Type, model.SpaceKey, user); + + var spaceId = default(string); + var ownerEmail = default(string); + + switch (model.Type) + { + case TemplateType.System: + ownerEmail = user.Email; + break; + case TemplateType.User: + ownerEmail = user.Email; + break; + case TemplateType.Space: + var space = await _spaceManager.GetByKey(model.SpaceKey); + if (space == null) + throw new SpaceNotFoundException($"Space with key '{model.SpaceKey}' not found"); + spaceId = space.Id; + ownerEmail = user.Email; + break; + default: + throw new InvalidOperationException($"Unknown template type: {model.Type}"); + } + + var template = new PageTemplate + { + Name = model.Name, + Description = model.Description, + Content = model.Content, + Type = model.Type, + OwnerEmail = ownerEmail, + SpaceId = spaceId + }; + + var result = await _pageTemplateManager.Create(template); + return result.ToModel(); + } + + public async Task Update(string id, PageTemplateUpdateModel model, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureModifyPermission(template, user); + + await _pageTemplateManager.Update(id, model.Name, model.Content, user); + } + + public async Task Delete(string id, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureModifyPermission(template, user); + + await _pageTemplateManager.Delete(id); + } + + public async Task Render(string templateId, string spaceKey, UserInfo user) + { + var template = await _pageTemplateManager.GetById(templateId); + await EnsureReadPermission(template, user); + + var space = await _spaceManager.GetByKey(spaceKey); + if (space == null) + throw new SpaceNotFoundException($"Space with key '{spaceKey}' not found"); + + var parameters = new Dictionary + { + ["CurrentDate"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), + ["CurrentTime"] = DateTime.UtcNow.ToString("HH:mm:ss"), + ["CurrentDateTime"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"), + ["UserName"] = user?.Name ?? string.Empty, + ["UserEmail"] = user?.Email ?? string.Empty, + ["UserAvatarUrl"] = user?.AvatarUrl ?? string.Empty, + ["SpaceName"] = space.Name, + ["SpaceKey"] = space.Key + }; + + var rendered = await _templateRenderer.Render(template.Content, parameters); + return new PageTemplateRenderResponse { Content = rendered }; + } + + private async Task EnsureCreatePermission(string type, string spaceKey, UserInfo user) + { + switch (type) + { + case TemplateType.System: + await EnsureGlobalAdmin(user); + break; + case TemplateType.User: + break; + case TemplateType.Space: + if (string.IsNullOrEmpty(spaceKey)) + throw new InvalidOperationException("SpaceKey is required for Space templates"); + await _permissionService.EnsureAdminPermission(spaceKey, user); + break; + default: + throw new InvalidOperationException($"Unknown template type: {type}"); + } + } + + private async Task EnsureReadPermission(PageTemplate template, UserInfo user) + { + switch (template.Type) + { + case TemplateType.System: + break; + case TemplateType.User: + if (user == null || template.OwnerEmail != user.Email) + throw new UserHasNotPermissionException(); + break; + case TemplateType.Space: + if (!string.IsNullOrEmpty(template.SpaceId)) + { + var space = await _spaceManager.GetById(template.SpaceId); + if (space != null) + await _permissionService.EnsureEditPermission(space.Key, user); + } + break; + } + } + + private async Task EnsureModifyPermission(PageTemplate template, UserInfo user) + { + switch (template.Type) + { + case TemplateType.System: + await EnsureGlobalAdmin(user); + break; + case TemplateType.User: + if (user == null || template.OwnerEmail != user.Email) + throw new UserHasNotPermissionException(); + break; + case TemplateType.Space: + if (!string.IsNullOrEmpty(template.SpaceId)) + { + var space = await _spaceManager.GetById(template.SpaceId); + if (space != null) + await _permissionService.EnsureAdminPermission(space.Key, user); + } + break; + } + } + + private async Task EnsureGlobalAdmin(UserInfo user) + { + if (user == null) + throw new UserHasNotPermissionException(); + + var dbUser = await _userManager.GetByEmail(user.Email); + if (dbUser == null || dbUser.Role != UserRole.Admin) + throw new UserHasNotPermissionException("Only global administrators can manage system templates"); + } +} diff --git a/src/Mimisbrunnr.Web/WebModule.cs b/src/Mimisbrunnr.Web/WebModule.cs index e842c147..033484d2 100644 --- a/src/Mimisbrunnr.Web/WebModule.cs +++ b/src/Mimisbrunnr.Web/WebModule.cs @@ -14,6 +14,7 @@ using Mimisbrunnr.Web.Services; using Mimisbrunnr.Web.User; using Mimisbrunnr.Web.Wiki; +using Mimisbrunnr.Web.PageTemplates; using Mimisbrunnr.Wiki; using Skidbladnir.Modules; @@ -42,5 +43,6 @@ public override void Configure(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } \ No newline at end of file diff --git a/tests/Mimisbrunnr.Web.Tests/Mimisbrunnr.Web.Tests.csproj b/tests/Mimisbrunnr.Web.Tests/Mimisbrunnr.Web.Tests.csproj new file mode 100644 index 00000000..ab283162 --- /dev/null +++ b/tests/Mimisbrunnr.Web.Tests/Mimisbrunnr.Web.Tests.csproj @@ -0,0 +1,23 @@ + + + net8.0 + enable + disable + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateManagerTests.cs b/tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateManagerTests.cs new file mode 100644 index 00000000..416226a8 --- /dev/null +++ b/tests/Mimisbrunnr.Web.Tests/PageTemplates/PageTemplateManagerTests.cs @@ -0,0 +1,127 @@ +using Xunit; +using FakeItEasy; +using FluentAssertions; +using FluentAssertions.Execution; +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.PageTemplates.Services; +using Mimisbrunnr.Wiki.Contracts; +using Skidbladnir.Repository.Abstractions; + +namespace Mimisbrunnr.Web.Tests.PageTemplates; + +public class PageTemplateManagerTests +{ + private readonly IRepository _repository; + private readonly PageTemplateManager _manager; + + public PageTemplateManagerTests() + { + QueryableAsyncExtensions.EnableFallback(); + _repository = A.Fake>(); + _manager = new PageTemplateManager(_repository); + } + + [Fact] + public async Task Create_Should_Set_CreatedAndUpdated() + { + var template = new PageTemplate + { + Name = "Test", + Content = "# Hello", + Type = TemplateType.User, + OwnerEmail = "user@test.com" + }; + + var result = await _manager.Create(template); + + using (new AssertionScope()) + { + result.Created.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + result.Updated.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + result.CreatedBy.Email.Should().Be("user@test.com"); + } + + A.CallTo(() => _repository.Create(A._, A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetById_Should_Return_Template_From_Repository() + { + var templates = new[] + { + new PageTemplate { Id = "123", Name = "Test" }, + new PageTemplate { Id = "456", Name = "Other" }, + }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); + + var result = await _manager.GetById("123"); + + result.Should().NotBeNull(); + result.Id.Should().Be("123"); + } + + [Fact] + public async Task GetById_Should_Throw_When_Not_Found() + { + A.CallTo(() => _repository.GetAll()).Returns(Enumerable.Empty().AsQueryable()); + + await _manager.Invoking(m => m.GetById("missing")) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Update_Should_Set_Updated_And_UpdatedBy() + { + var existing = new PageTemplate + { + Id = "1", + Name = "Old", + Content = "Old content", + Updated = DateTime.UtcNow.AddDays(-1), + UpdatedBy = new UserInfo { Email = "old@test.com" } + }; + var templates = new[] { existing }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); + + var updateInfo = new UserInfo { Email = "new@test.com" }; + await _manager.Update("1", "New Name", "New content", updateInfo); + + using (new AssertionScope()) + { + existing.Name.Should().Be("New Name"); + existing.Content.Should().Be("New content"); + existing.UpdatedBy.Email.Should().Be("new@test.com"); + existing.Updated.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1)); + } + + A.CallTo(() => _repository.Update(existing, A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Delete_Should_Remove_From_Repository() + { + var template = new PageTemplate { Id = "1" }; + var templates = new[] { template }.AsQueryable(); + A.CallTo(() => _repository.GetAll()).Returns(templates); + + await _manager.Delete("1"); + + A.CallTo(() => _repository.Delete(template, A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetAll_Should_Return_From_Repository() + { + var templates = new[] + { + new PageTemplate { Name = "A" }, + new PageTemplate { Name = "B" } + }.AsQueryable(); + + A.CallTo(() => _repository.GetAll()).Returns(templates); + + var result = _manager.GetAll(); + + result.Should().HaveCount(2); + } +} From 7001c2398a610bf47f23b8b309dabc2810f8d3cf Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sat, 13 Jun 2026 21:10:00 +0500 Subject: [PATCH 04/10] Fix getting current space templates --- .../base/CreateFromTemplateModal.vue | 3 +- .../PageTemplates/PageTemplateService.cs | 454 +++++++++--------- 2 files changed, 226 insertions(+), 231 deletions(-) diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue index dce21c57..b4b374e6 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue @@ -38,7 +38,8 @@ export default { methods: { async loadTemplates() { try { - this.templates = await pageTemplateService.getAll(); + var spaceKey = this.$route.params.key || ""; + this.templates = await pageTemplateService.getAll("", spaceKey); } catch (e) { this.templates = []; } diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs index 5aa7f4f2..959d1e4c 100644 --- a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs @@ -1,230 +1,224 @@ -using Mimisbrunnr.Integration.PageTemplates; -using Mimisbrunnr.Integration.Wiki; -using Mimisbrunnr.PageTemplates.Contracts; -using Mimisbrunnr.PageTemplates.Services; -using Mimisbrunnr.Users; -using Mimisbrunnr.Web.Infrastructure; -using Mimisbrunnr.Web.Mapping; -using Mimisbrunnr.Web.Services; -using Mimisbrunnr.Wiki.Contracts; -using Mimisbrunnr.Wiki.Services; - -namespace Mimisbrunnr.Web.PageTemplates; - -internal class PageTemplateService : IPageTemplateService -{ - private readonly IPageTemplateManager _pageTemplateManager; - private readonly ISpaceManager _spaceManager; - private readonly IPermissionService _permissionService; - private readonly IUserManager _userManager; - private readonly ITemplateRenderer _templateRenderer; - - public PageTemplateService( - IPageTemplateManager pageTemplateManager, - ISpaceManager spaceManager, - IPermissionService permissionService, - IUserManager userManager, - ITemplateRenderer templateRenderer) - { - _pageTemplateManager = pageTemplateManager; - _spaceManager = spaceManager; - _permissionService = permissionService; - _userManager = userManager; - _templateRenderer = templateRenderer; - } - - public async Task GetAll(string type, string spaceKey, UserInfo user) - { - var query = _pageTemplateManager.GetAll(); - - var templates = new List(); - - if (string.IsNullOrEmpty(type) || type == TemplateType.System) - { - var systemTemplates = query.Where(x => x.Type == TemplateType.System).ToArray(); - templates.AddRange(systemTemplates); - } - - if (string.IsNullOrEmpty(type) || type == TemplateType.User) - { - if (user != null) - { - var userTemplates = query.Where(x => x.Type == TemplateType.User && x.OwnerEmail == user.Email).ToArray(); - templates.AddRange(userTemplates); - } - } - - if (string.IsNullOrEmpty(type) || type == TemplateType.Space) - { - if (!string.IsNullOrEmpty(spaceKey)) - { - await _permissionService.EnsureEditPermission(spaceKey, user); - var space = await _spaceManager.GetByKey(spaceKey); - var spaceTemplates = query.Where(x => x.Type == TemplateType.Space && x.SpaceId == space.Id).ToArray(); - templates.AddRange(spaceTemplates); - } - } - - return templates.Select(x => x.ToModel()).ToArray(); - } - - public async Task GetById(string id, UserInfo user) - { - var template = await _pageTemplateManager.GetById(id); - await EnsureReadPermission(template, user); - return template.ToModel(); - } - - public async Task Create(PageTemplateCreateModel model, UserInfo user) - { - await EnsureCreatePermission(model.Type, model.SpaceKey, user); - - var spaceId = default(string); - var ownerEmail = default(string); - - switch (model.Type) - { - case TemplateType.System: - ownerEmail = user.Email; - break; - case TemplateType.User: - ownerEmail = user.Email; - break; - case TemplateType.Space: - var space = await _spaceManager.GetByKey(model.SpaceKey); - if (space == null) - throw new SpaceNotFoundException($"Space with key '{model.SpaceKey}' not found"); - spaceId = space.Id; - ownerEmail = user.Email; - break; - default: - throw new InvalidOperationException($"Unknown template type: {model.Type}"); - } - - var template = new PageTemplate - { - Name = model.Name, - Description = model.Description, - Content = model.Content, - Type = model.Type, - OwnerEmail = ownerEmail, - SpaceId = spaceId - }; - - var result = await _pageTemplateManager.Create(template); - return result.ToModel(); - } - - public async Task Update(string id, PageTemplateUpdateModel model, UserInfo user) - { - var template = await _pageTemplateManager.GetById(id); - await EnsureModifyPermission(template, user); - - await _pageTemplateManager.Update(id, model.Name, model.Content, user); - } - - public async Task Delete(string id, UserInfo user) - { - var template = await _pageTemplateManager.GetById(id); - await EnsureModifyPermission(template, user); - - await _pageTemplateManager.Delete(id); - } - - public async Task Render(string templateId, string spaceKey, UserInfo user) - { - var template = await _pageTemplateManager.GetById(templateId); - await EnsureReadPermission(template, user); - - var space = await _spaceManager.GetByKey(spaceKey); - if (space == null) - throw new SpaceNotFoundException($"Space with key '{spaceKey}' not found"); - - var parameters = new Dictionary - { - ["CurrentDate"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), - ["CurrentTime"] = DateTime.UtcNow.ToString("HH:mm:ss"), - ["CurrentDateTime"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"), - ["UserName"] = user?.Name ?? string.Empty, - ["UserEmail"] = user?.Email ?? string.Empty, - ["UserAvatarUrl"] = user?.AvatarUrl ?? string.Empty, - ["SpaceName"] = space.Name, - ["SpaceKey"] = space.Key - }; - - var rendered = await _templateRenderer.Render(template.Content, parameters); - return new PageTemplateRenderResponse { Content = rendered }; - } - - private async Task EnsureCreatePermission(string type, string spaceKey, UserInfo user) - { - switch (type) - { - case TemplateType.System: - await EnsureGlobalAdmin(user); - break; - case TemplateType.User: - break; - case TemplateType.Space: - if (string.IsNullOrEmpty(spaceKey)) - throw new InvalidOperationException("SpaceKey is required for Space templates"); - await _permissionService.EnsureAdminPermission(spaceKey, user); - break; - default: - throw new InvalidOperationException($"Unknown template type: {type}"); - } - } - - private async Task EnsureReadPermission(PageTemplate template, UserInfo user) - { - switch (template.Type) - { - case TemplateType.System: - break; - case TemplateType.User: - if (user == null || template.OwnerEmail != user.Email) - throw new UserHasNotPermissionException(); - break; - case TemplateType.Space: - if (!string.IsNullOrEmpty(template.SpaceId)) - { - var space = await _spaceManager.GetById(template.SpaceId); - if (space != null) - await _permissionService.EnsureEditPermission(space.Key, user); - } - break; - } - } - - private async Task EnsureModifyPermission(PageTemplate template, UserInfo user) - { - switch (template.Type) - { - case TemplateType.System: - await EnsureGlobalAdmin(user); - break; - case TemplateType.User: - if (user == null || template.OwnerEmail != user.Email) - throw new UserHasNotPermissionException(); - break; - case TemplateType.Space: - if (!string.IsNullOrEmpty(template.SpaceId)) - { - var space = await _spaceManager.GetById(template.SpaceId); - if (space != null) - await _permissionService.EnsureAdminPermission(space.Key, user); - } - break; - } - } - - private async Task EnsureGlobalAdmin(UserInfo user) - { - if (user == null) - throw new UserHasNotPermissionException(); - - var dbUser = await _userManager.GetByEmail(user.Email); - if (dbUser == null || dbUser.Role != UserRole.Admin) - throw new UserHasNotPermissionException("Only global administrators can manage system templates"); - } -} +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.Integration.Wiki; +using Mimisbrunnr.PageTemplates.Contracts; +using Mimisbrunnr.PageTemplates.Services; +using Mimisbrunnr.Users; +using Mimisbrunnr.Web.Infrastructure; +using Mimisbrunnr.Web.Mapping; +using Mimisbrunnr.Web.Services; +using Mimisbrunnr.Wiki.Contracts; +using Mimisbrunnr.Wiki.Services; + +namespace Mimisbrunnr.Web.PageTemplates; + +internal class PageTemplateService : IPageTemplateService +{ + private readonly IPageTemplateManager _pageTemplateManager; + private readonly ISpaceManager _spaceManager; + private readonly IPermissionService _permissionService; + private readonly IUserManager _userManager; + private readonly ITemplateRenderer _templateRenderer; + + public PageTemplateService( + IPageTemplateManager pageTemplateManager, + ISpaceManager spaceManager, + IPermissionService permissionService, + IUserManager userManager, + ITemplateRenderer templateRenderer) + { + _pageTemplateManager = pageTemplateManager; + _spaceManager = spaceManager; + _permissionService = permissionService; + _userManager = userManager; + _templateRenderer = templateRenderer; + } + + public async Task GetAll(string type, string spaceKey, UserInfo user) + { + var query = _pageTemplateManager.GetAll(); + + var templates = new List(); + + if (string.IsNullOrEmpty(type) || type == TemplateType.System) + { + var systemTemplates = query.Where(x => x.Type == TemplateType.System).ToArray(); + templates.AddRange(systemTemplates); + } + + if (string.IsNullOrEmpty(type) || type == TemplateType.User) + { + if (user != null) + { + var userTemplates = query.Where(x => x.Type == TemplateType.User && x.OwnerEmail == user.Email).ToArray(); + templates.AddRange(userTemplates); + } + } + + if (string.IsNullOrEmpty(type) || type == TemplateType.Space) + { + if (!string.IsNullOrEmpty(spaceKey)) + { + try { + await _permissionService.EnsureEditPermission(spaceKey, user); + var space = await _spaceManager.GetByKey(spaceKey); + var spaceTemplates = query.Where(x => x.Type == TemplateType.Space && x.SpaceId == space.Id).ToArray(); + templates.AddRange(spaceTemplates); + } + catch (UserHasNotPermissionException) + { + //do nothing + } + } + } + + return templates.Select(x => x.ToModel()).ToArray(); + } + + public async Task GetById(string id, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureReadPermission(template, user); + return template.ToModel(); + } + + public async Task Create(PageTemplateCreateModel model, UserInfo user) + { + await EnsureCreatePermission(model.Type, model.SpaceKey, user); + + var spaceId = default(string); + var ownerEmail = user.Email; + if (model.Type == TemplateType.Space) + { + var space = await _spaceManager.GetByKey(model.SpaceKey); + if (space == null) + throw new SpaceNotFoundException($"Space with key '{model.SpaceKey}' not found"); + spaceId = space.Id; + } + + var template = new PageTemplate + { + Name = model.Name, + Description = model.Description, + Content = model.Content, + Type = model.Type, + OwnerEmail = ownerEmail, + SpaceId = spaceId + }; + + var result = await _pageTemplateManager.Create(template); + return result.ToModel(); + } + + public async Task Update(string id, PageTemplateUpdateModel model, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureModifyPermission(template, user); + + await _pageTemplateManager.Update(id, model.Name, model.Content, user); + } + + public async Task Delete(string id, UserInfo user) + { + var template = await _pageTemplateManager.GetById(id); + await EnsureModifyPermission(template, user); + + await _pageTemplateManager.Delete(id); + } + + public async Task Render(string templateId, string spaceKey, UserInfo user) + { + var template = await _pageTemplateManager.GetById(templateId); + await EnsureReadPermission(template, user); + + var space = await _spaceManager.GetByKey(spaceKey); + if (space == null) + throw new SpaceNotFoundException($"Space with key '{spaceKey}' not found"); + + var parameters = new Dictionary + { + ["CurrentDate"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), + ["CurrentTime"] = DateTime.UtcNow.ToString("HH:mm:ss"), + ["CurrentDateTime"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"), + ["UserName"] = user?.Name ?? string.Empty, + ["UserEmail"] = user?.Email ?? string.Empty, + ["UserAvatarUrl"] = user?.AvatarUrl ?? string.Empty, + ["SpaceName"] = space.Name, + ["SpaceKey"] = space.Key + }; + + var rendered = await _templateRenderer.Render(template.Content, parameters); + return new PageTemplateRenderResponse { Content = rendered }; + } + + private async Task EnsureCreatePermission(string type, string spaceKey, UserInfo user) + { + switch (type) + { + case TemplateType.System: + await EnsureGlobalAdmin(user); + break; + case TemplateType.User: + break; + case TemplateType.Space: + if (string.IsNullOrEmpty(spaceKey)) + throw new InvalidOperationException("SpaceKey is required for Space templates"); + await _permissionService.EnsureAdminPermission(spaceKey, user); + break; + default: + throw new InvalidOperationException($"Unknown template type: {type}"); + } + } + + private async Task EnsureReadPermission(PageTemplate template, UserInfo user) + { + switch (template.Type) + { + case TemplateType.System: + break; + case TemplateType.User: + if (user == null || template.OwnerEmail != user.Email) + throw new UserHasNotPermissionException(); + break; + case TemplateType.Space: + if (!string.IsNullOrEmpty(template.SpaceId)) + { + var space = await _spaceManager.GetById(template.SpaceId); + if (space != null) + await _permissionService.EnsureEditPermission(space.Key, user); + } + break; + } + } + + private async Task EnsureModifyPermission(PageTemplate template, UserInfo user) + { + switch (template.Type) + { + case TemplateType.System: + await EnsureGlobalAdmin(user); + break; + case TemplateType.User: + if (user == null || template.OwnerEmail != user.Email) + throw new UserHasNotPermissionException(); + break; + case TemplateType.Space: + if (!string.IsNullOrEmpty(template.SpaceId)) + { + var space = await _spaceManager.GetById(template.SpaceId); + if (space != null) + await _permissionService.EnsureAdminPermission(space.Key, user); + } + break; + } + } + + private async Task EnsureGlobalAdmin(UserInfo user) + { + if (user == null) + throw new UserHasNotPermissionException(); + + var dbUser = await _userManager.GetByEmail(user.Email); + if (dbUser == null || dbUser.Role != UserRole.Admin) + throw new UserHasNotPermissionException("Only global administrators can manage system templates"); + } +} From d2abe95ff160012032e93b90c3a18bca7038cfbc Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sat, 13 Jun 2026 22:46:49 +0500 Subject: [PATCH 05/10] * Open markdown guide in page template modal editor * Update Readme and Agents * Add uuid generation helper for stubble --- AGENTS.md | 6 + README.md | 6 + docs/stubble-renderer.md | 388 ++++++++++-------- .../ClientApp/src/assets/lang.json | 6 +- .../src/components/pageEditor/GuideModal.vue | 5 +- .../templates/PageTemplateManager.vue | 208 +++++----- .../templates/PageTemplateModal.vue | 186 +++++---- .../StubbleTemplateRenderer.cs | 6 + .../PageTemplates/PageTemplateController.cs | 175 ++++---- 9 files changed, 544 insertions(+), 442 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82f51075..b1c2ee55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ A wiki system for storing knowledge (Atlassian Confluence alternative). │ │ │ ├── Mimisbrunnr.Persistent/ # File storage (Local/GridFS/WebDAV/S3) │ │ +│ ├── Mimisbrunnr.PageTemplates/ # Page templates (entities, manager, contracts) +│ │ │ ├── Mimisbrunnr.Favorites/ # Favorites │ │ │ ├── Mimisbrunnr.Json/ # JSON utilities @@ -63,6 +65,8 @@ A wiki system for storing knowledge (Atlassian Confluence alternative). | **Backend API** | `src/Mimisbrunnr.Web/` | | **Models/Business Logic** | `src/Mimisbrunnr.Wiki/` | | **MongoDB Repositories** | `src/Mimisbrunnr.Storage.MongoDb/` | +| **Page Templates** | `src/Mimisbrunnr.PageTemplates/`, `src/Mimisbrunnr.Web/PageTemplates/` | +| **Template Renderer** | `src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs` | | **Entry Point** | `src/Mimisbrunnr.Web.Host/Program.cs` | ## Frontend Details @@ -132,3 +136,5 @@ For details: see README.md Configuration section. 3. **Mapperly**: Mapper generation via Riok.Mapperly (NET 8 source generators) 4. **Swagger**: API documented via Swashbuckle.AspNetCore 5. **Prometheus Metrics**: Metrics collection via prometheus-net +6. **Page Templates**: Three template types (System, User, Space) with Mustache rendering via `ITemplateRenderer` +7. **Stubble Template Renderer**: Mustache-based engine used for page templates and plugin macros. Built-in helpers: `UrlEncode`, `HtmlEncode`, `ToLower`, `ToUpper`, `Uuid` (generates unique IDs without hyphens). See `docs/stubble-renderer.md` for details. diff --git a/README.md b/README.md index c728bf8f..bf275c70 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ Support for the following features: - Favorites - Plugins - Macros for pages (additional functional for pages) +- Page Templates + - Create and manage reusable page templates (System, User, Space) + - Mustache-based templating (via Stubble) with variables for date/time, user info, and space context + - Create new pages from templates with rendered content + - Template access control: System templates (global admin), User templates (owner only), Space templates (space admin) - Administration - Change wiki instance title - Enabling anonymous access (permission to read public spaces and access to wiki without authentication) @@ -94,6 +99,7 @@ Support for the following features: - Enable - Remove (with clean all plugin data) - Link to simple plugin editor + - Manage page templates (system-wide templates) - Hosting - Simple scalable - Three types of caching (one for single node and two for multiple nodes) diff --git a/docs/stubble-renderer.md b/docs/stubble-renderer.md index 6d48a718..8ff44342 100644 --- a/docs/stubble-renderer.md +++ b/docs/stubble-renderer.md @@ -1,163 +1,225 @@ -# Stubble Template Renderer - -The Stubble Template Renderer is a Mustache-based templating engine used in Mimisbrunnr for rendering dynamic content. It is built on the [Stubble.Core](https://github.com/StubbleOrg/Stubble) library. - -## Current Usage - -Currently, the template renderer is used **exclusively in plugin macros** for rendering macro templates with user-provided parameters and predefined context variables. - -> **Coming Soon:** The template renderer will also be available for **page templates** when the page templates feature is implemented. - -## Basic Syntax - -### Variable Substitution - -Replace `{{variable}}` with the value from the parameters: - -``` -{{name}} -{{PageName}} -``` - -### Sections (Conditionals / Iteration) - -Sections render their content only if the value is truthy (non-empty, non-null): - -``` -{{#title}}

{{title}}

{{/title}} -``` - -## Predefined Context Variables - -These variables are automatically available in all macro templates: - -| Variable | Description | -|----------|-------------| -| `{{PageId}}` | Current page ID | -| `{{PageName}}` | Current page name | -| `{{SpaceKey}}` | Current space key | -| `{{SpaceName}}` | Current space name | -| `{{UserEmail}}` | Current user's email | -| `{{UserName}}` | Current user's display name | -| `{{MacroIdOnPage}}` | Unique macro instance identifier on the page | - -## Built-in Helpers - -Helpers are functions that transform values. They use the section syntax `{{#Helper}}...{{/Helper}}`. - -### String Transformations - -#### ToLower - -Converts the rendered content to lowercase: - -```mustache -{{#ToLower}}{{SomeParam}}{{/ToLower}} -``` - -#### ToUpper - -Converts the rendered content to uppercase: - -```mustache -{{#ToUpper}}{{title}}{{/ToUpper}} -``` - -### Encoding - -#### UrlEncode - -URL-encodes the rendered content (useful for query parameters and URLs): - -```mustache - -``` - -#### HtmlEncode - -HTML-encodes the rendered content (useful for preventing XSS): - -```mustache - -``` - -## Examples - -### Simple Variable Substitution - -```json -{ - "Template": "

Hello {{name}} {{surname}}

" -} -``` - -### Using Predefined Variables - -```json -{ - "Template": "
Page: {{PageName}} in space {{SpaceKey}}
" -} -``` - -### Chaining Helpers - -Helpers can be chained — the inner helper is applied first: - -```json -{ - "Template": "

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

" -} -``` - -This converts `title` to uppercase first, then HTML-encodes the result. - -### Conditional Rendering - -```json -{ - "Template": "
{{#title}}

{{title}}

{{/title}}

{{text}}

" -} -``` - -The `

` tag is only rendered if `title` has a value. - -### Complete Example: Warning Block Macro - -```json -{ - "Template": "
\n{{#title}}

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

{{/title}}\n{{text}}\n
" -} -``` - -## Limitations - -1. **Mustache spec subset**: Only basic Mustache features are supported (variables, sections). Partials, inverted sections (`{{^var}}`), and set-delimiters are not tested and may not work as expected. - -2. **Helpers require content**: Due to Stubble's lambda signature, helpers must wrap some content between tags — `{{#Helper}}value{{/Helper}}`. Empty helper calls like `{{#Helper}}{{/Helper}}` work only for helpers that don't need input (e.g., `Now`, `Today`, `CurrentTime`). - -3. **No custom helper registration**: Built-in helpers are hardcoded in `StubbleTemplateRenderer`. Users cannot register their own helpers. - -4. **Case-insensitive keys**: Key lookup is case-insensitive by default (`{{name}}` and `{{Name}}` resolve to the same value). - -5. **No template inheritance**: Mustache's block inheritance feature is not supported. - -6. **Async-only rendering**: Templates are rendered asynchronously via `RenderAsync`. Synchronous rendering is not available. - -## Configuration - -The renderer is registered as a singleton in the DI container: - -```csharp -services.AddSingleton(); -``` - -Case-insensitive key lookup is enabled by default: - -```csharp -settings.SetIgnoreCaseOnKeyLookup(true); -``` - -## Source - -- Implementation: `src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs` -- Interface: `src/Mimisbrunnr.Web.Infrastructure/ITemplateRenderer.cs` +# Stubble Template Renderer + +The Stubble Template Renderer is a Mustache-based templating engine used in Mimisbrunnr for rendering dynamic content. It is built on the [Stubble.Core](https://github.com/StubbleOrg/Stubble) library. + +## Usage Areas + +The template renderer is used in two places: + +1. **Plugin macros** — for rendering macro templates with user-provided parameters and predefined context variables +2. **Page templates** — for creating new pages from pre-defined templates (System, User, and Space templates) + +## Basic Syntax + +### Variable Substitution + +Replace `{{variable}}` with the value from the parameters: + +``` +{{name}} +{{PageName}} +``` + +### Sections (Conditionals / Iteration) + +Sections render their content only if the value is truthy (non-empty, non-null): + +``` +{{#title}}

{{title}}

{{/title}} +``` + +## Predefined Context Variables + +### Page Templates + +These variables are automatically available when rendering a page template via the `/api/pagetemplate/{id}/render` endpoint: + +| Variable | Description | +|----------|-------------| +| `{{CurrentDate}}` | Current UTC date (`yyyy-MM-dd`) | +| `{{CurrentTime}}` | Current UTC time (`HH:mm:ss`) | +| `{{CurrentDateTime}}` | Current UTC date and time (`yyyy-MM-dd HH:mm:ss`) | +| `{{UserName}}` | Current user's display name | +| `{{UserEmail}}` | Current user's email | +| `{{UserAvatarUrl}}` | Current user's avatar URL | +| `{{SpaceName}}` | Current space name | +| `{{SpaceKey}}` | Current space key | + +### Plugin Macros + +These variables are automatically available when rendering a plugin macro: + +| Variable | Description | +|----------|-------------| +| `{{MacroIdOnPage}}` | Unique macro instance identifier on the page | +| `{{PageId}}` | Current page ID | +| `{{PageName}}` | Current page name | +| `{{SpaceKey}}` | Current space key | +| `{{SpaceName}}` | Current space name | +| `{{UserName}}` | Current user's display name | +| `{{UserEmail}}` | Current user's email | +| `{{UserRole}}` | Current user's role (e.g., `Admin`, `User`) | + +In addition to the predefined variables, plugin macros receive **user-defined parameters** configured in the macro settings. + +## Built-in Helpers + +Helpers are functions that transform values. They use the section syntax `{{#Helper}}...{{/Helper}}`. + +The following helpers are available in **both** page templates and plugin macros: + +### Generators + +#### Uuid + +Generates a new UUID (v4) without hyphens on each invocation. Useful for unique element IDs in generated HTML: + +```mustache +
+

Content

+
+``` + +Each call produces a different UUID, so multiple macros on the same page won't have ID collisions: + +```mustache +
Tab 1
+
Tab 2
+``` + +Renders to: +```html +
Tab 1
+
Tab 2
+``` + +### String Transformations + +#### ToLower + +Converts the rendered content to lowercase: + +```mustache +{{#ToLower}}{{SomeParam}}{{/ToLower}} +``` + +#### ToUpper + +Converts the rendered content to uppercase: + +```mustache +{{#ToUpper}}{{title}}{{/ToUpper}} +``` + +### Encoding + +#### UrlEncode + +URL-encodes the rendered content (useful for query parameters and URLs): + +```mustache + +``` + +#### HtmlEncode + +HTML-encodes the rendered content (useful for preventing XSS): + +```mustache + +``` + +### Chaining Helpers + +Helpers can be chained — the inner helper is applied first: + +```json +{ + "Template": "

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

" +} +``` + +This converts `title` to uppercase first, then HTML-encodes the result. + +## Examples + +### Simple Variable Substitution + +```json +{ + "Template": "

Hello {{name}} {{surname}}

" +} +``` + +### Using Predefined Variables (Plugin Macro) + +```json +{ + "Template": "
Page: {{PageName}} in space {{SpaceKey}}
" +} +``` + +### Using Predefined Variables (Page Template) + +```mustache +# Meeting notes — {{CurrentDate}} + +Author: {{UserName}} ({{UserEmail}}) +Space: {{SpaceName}} + +## Agenda + +- Item 1 +- Item 2 +``` + +### Conditional Rendering + +```json +{ + "Template": "
{{#title}}

{{title}}

{{/title}}

{{text}}

" +} +``` + +The `

` tag is only rendered if `title` has a value. + +### Complete Example: Warning Block Macro + +```json +{ + "Template": "
\n{{#title}}

{{#HtmlEncode}}{{#ToUpper}}{{title}}{{/ToUpper}}{{/HtmlEncode}}

{{/title}}\n{{text}}\n
" +} +``` + +## Configuration + +The renderer is registered as a singleton in the DI container: + +```csharp +services.AddSingleton(); +``` + +Case-insensitive key lookup is enabled by default: + +```csharp +settings.SetIgnoreCaseOnKeyLookup(true); +``` + +## Limitations + +1. **Mustache spec subset**: Only basic Mustache features are supported (variables, sections). Partials, inverted sections (`{{^var}}`), and set-delimiters are not tested and may not work as expected. + +2. **Helpers require content**: Due to Stubble's lambda signature, transformation helpers must wrap some content — `{{#Helper}}value{{/Helper}}`. Helpers that don't need input (like `Uuid`) can be called with empty content: `{{#Uuid}}{{/Uuid}}`. + +3. **No custom helper registration**: Built-in helpers are hardcoded in `StubbleTemplateRenderer`. Users cannot register their own helpers. + +4. **Case-insensitive keys**: Key lookup is case-insensitive by default (`{{name}}` and `{{Name}}` resolve to the same value). + +5. **No template inheritance**: Mustache's block inheritance feature is not supported. + +6. **Async-only rendering**: Templates are rendered asynchronously via `RenderAsync`. Synchronous rendering is not available. + +## Source + +- Implementation: `src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs` +- Interface: `src/Mimisbrunnr.Web.Infrastructure/ITemplateRenderer.cs` diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json b/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json index 7d38f0f6..c8e0330a 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/assets/lang.json @@ -518,7 +518,8 @@ "space": "Space", "createFromTemplate": "Create from Template", "noTemplates": "No templates available", - "createPageFromTemplate": "Create page from template" + "createPageFromTemplate": "Create page from template", + "markdownGuide": "Markdown guide" } }, "ru": { @@ -1041,7 +1042,8 @@ "space": "Пространства", "createFromTemplate": "Создать из шаблона", "noTemplates": "Нет доступных шаблонов", - "createPageFromTemplate": "Создать страницу из шаблона" + "createPageFromTemplate": "Создать страницу из шаблона", + "markdownGuide": "Справка по Markdown" } } } \ No newline at end of file diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/pageEditor/GuideModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/pageEditor/GuideModal.vue index 537d1f84..4dec340e 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/pageEditor/GuideModal.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/pageEditor/GuideModal.vue @@ -1,5 +1,5 @@ @@ -11,6 +11,9 @@ export default { components: { VueMarkdown, }, + props: { + modalId: { type: String, default: "guide-modal" }, + }, data() { return { guide: ` diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue index 05361396..5f5771c0 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue @@ -1,104 +1,104 @@ - - - + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue index 1070ac8a..826d87f9 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue @@ -1,85 +1,101 @@ - - - + + + + \ No newline at end of file diff --git a/src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs b/src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs index 3de8c9c6..85644301 100644 --- a/src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs +++ b/src/Mimisbrunnr.Web.Infrastructure/StubbleTemplateRenderer.cs @@ -25,9 +25,15 @@ public async Task Render(string template, IDictionary pa parameters.Add(nameof(HtmlEncode), HtmlEncode); parameters.Add(nameof(ToLower), ToLower); parameters.Add(nameof(ToUpper), ToUpper); + parameters.Add(nameof(Uuid), Uuid); return await _stubble.RenderAsync(template, parameters); } + private static string Uuid(string _, Func __) + { + return Guid.NewGuid().ToString("N"); + } + private static string UrlEncode(string str, Func render) { return HttpUtility.UrlEncode(render(str)); diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs index bb0bc9c6..e61a84eb 100644 --- a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs @@ -1,87 +1,88 @@ -using Microsoft.AspNetCore.Mvc; -using Mimisbrunnr.Integration.PageTemplates; -using Mimisbrunnr.Web.Filters; -using Mimisbrunnr.Web.Mapping; - -namespace Mimisbrunnr.Web.PageTemplates; - -[Route("api/[controller]")] -[ApiController] -[Microsoft.AspNetCore.Authorization.Authorize] -[HandlePageTemplateErrors] -public class PageTemplateController : ControllerBase -{ - private readonly IPageTemplateService _pageTemplateService; - - public PageTemplateController(IPageTemplateService pageTemplateService) - { - _pageTemplateService = pageTemplateService; - } - - [HttpGet] - [ProducesResponseType(typeof(PageTemplateModel[]), 200)] - [ProducesResponseType(401)] - public async Task GetAll([FromQuery] string? type = null, [FromQuery] string? spaceKey = null) - { - var user = User?.ToInfo(); - var result = await _pageTemplateService.GetAll(type, spaceKey, user); - return Ok(result); - } - - [HttpGet("{id}")] - [ProducesResponseType(typeof(PageTemplateModel), 200)] - [ProducesResponseType(401)] - [ProducesResponseType(404)] - public async Task GetById(string id) - { - var user = User?.ToInfo(); - var result = await _pageTemplateService.GetById(id, user); - return Ok(result); - } - - [HttpPost] - [ProducesResponseType(typeof(PageTemplateModel), 200)] - [ProducesResponseType(401)] - [ProducesResponseType(403)] - public async Task Create([FromBody] PageTemplateCreateModel model) - { - var user = User?.ToInfo(); - var result = await _pageTemplateService.Create(model, user); - return Ok(result); - } - - [HttpPut("{id}")] - [ProducesResponseType(200)] - [ProducesResponseType(401)] - [ProducesResponseType(403)] - [ProducesResponseType(404)] - public async Task Update(string id, [FromBody] PageTemplateUpdateModel model) - { - var user = User?.ToInfo(); - await _pageTemplateService.Update(id, model, user); - return Ok(); - } - - [HttpDelete("{id}")] - [ProducesResponseType(200)] - [ProducesResponseType(401)] - [ProducesResponseType(403)] - [ProducesResponseType(404)] - public async Task Delete(string id) - { - var user = User?.ToInfo(); - await _pageTemplateService.Delete(id, user); - return Ok(); - } - - [HttpPost("{id}/render")] - [ProducesResponseType(typeof(PageTemplateRenderResponse), 200)] - [ProducesResponseType(401)] - [ProducesResponseType(404)] - public async Task Render(string id, [FromBody] PageTemplateRenderRequest request) - { - var user = User?.ToInfo(); - var result = await _pageTemplateService.Render(id, request.SpaceKey, user); - return Ok(result); - } -} +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mimisbrunnr.Integration.PageTemplates; +using Mimisbrunnr.Web.Filters; +using Mimisbrunnr.Web.Mapping; + +namespace Mimisbrunnr.Web.PageTemplates; + +[Route("api/[controller]")] +[ApiController] +[Authorize] +[HandlePageTemplateErrors] +public class PageTemplateController : ControllerBase +{ + private readonly IPageTemplateService _pageTemplateService; + + public PageTemplateController(IPageTemplateService pageTemplateService) + { + _pageTemplateService = pageTemplateService; + } + + [HttpGet] + [ProducesResponseType(typeof(PageTemplateModel[]), 200)] + [ProducesResponseType(401)] + public async Task GetAll([FromQuery] string? type = null, [FromQuery] string? spaceKey = null) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.GetAll(type, spaceKey, user); + return Ok(result); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(PageTemplateModel), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(404)] + public async Task GetById(string id) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.GetById(id, user); + return Ok(result); + } + + [HttpPost] + [ProducesResponseType(typeof(PageTemplateModel), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + public async Task Create([FromBody] PageTemplateCreateModel model) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.Create(model, user); + return Ok(result); + } + + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task Update(string id, [FromBody] PageTemplateUpdateModel model) + { + var user = User?.ToInfo(); + await _pageTemplateService.Update(id, model, user); + return Ok(); + } + + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task Delete(string id) + { + var user = User?.ToInfo(); + await _pageTemplateService.Delete(id, user); + return Ok(); + } + + [HttpPost("{id}/render")] + [ProducesResponseType(typeof(PageTemplateRenderResponse), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(404)] + public async Task Render(string id, [FromBody] PageTemplateRenderRequest request) + { + var user = User?.ToInfo(); + var result = await _pageTemplateService.Render(id, request.SpaceKey, user); + return Ok(result); + } +} From e6017d42a774dd2479fb9b977ec37c44a8618272 Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sun, 14 Jun 2026 10:52:54 +0500 Subject: [PATCH 06/10] add PageTemplates project to sln --- Mimisbrunnr.sln | 407 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 273 insertions(+), 134 deletions(-) diff --git a/Mimisbrunnr.sln b/Mimisbrunnr.sln index a00adb47..24ac0c4e 100644 --- a/Mimisbrunnr.sln +++ b/Mimisbrunnr.sln @@ -1,134 +1,273 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web.Host", "src\Mimisbrunnr.Web.Host\Mimisbrunnr.Web.Host.csproj", "{84B0F1E6-567E-4136-8D51-E469D460C261}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web", "src\Mimisbrunnr.Web\Mimisbrunnr.Web.csproj", "{70CF2A02-DE33-4E98-9B79-101FE55E9E6F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Wiki", "src\Mimisbrunnr.Wiki\Mimisbrunnr.Wiki.csproj", "{C1294ED4-1559-40C1-A140-E0743156C664}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Storage.MongoDb", "src\Mimisbrunnr.Storage.MongoDb\Mimisbrunnr.Storage.MongoDb.csproj", "{08E5C5D6-4920-4016-89B8-9BD83022580C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Users", "src\Mimisbrunnr.Users\Mimisbrunnr.Users.csproj", "{C69EC431-9F8E-4368-AC2C-D1DC8B930573}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web.Infrastructure", "src\Mimisbrunnr.Web.Infrastructure\Mimisbrunnr.Web.Infrastructure.csproj", "{425CCCC9-EDF8-49A5-86CD-B5E268B47C36}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Persistent", "src\Mimisbrunnr.Persistent\Mimisbrunnr.Persistent.csproj", "{4FAC90B1-53E3-4CA2-9640-7E5177AA526C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Integration", "src\Integration\Mimisbrunnr.Integration\Mimisbrunnr.Integration.csproj", "{BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{A67956CF-DC77-4961-877A-140A2C47E19D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Integration.Client", "src\Integration\Mimisbrunnr.Integration.Client\Mimisbrunnr.Integration.Client.csproj", "{194C1349-E7B7-4066-949E-C71F71238481}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataImport", "DataImport", "{FB23071D-A3D3-4519-8A7F-BF76826A8BE7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport", "src\DataImport\Mimisbrunnr.DataImport\Mimisbrunnr.DataImport.csproj", "{F5A0057B-E96E-422D-9CC2-E4568A4775B2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport.Confluence", "src\DataImport\Mimisbrunnr.DataImport.Confluence\Mimisbrunnr.DataImport.Confluence.csproj", "{F5D75103-4E53-4514-B228-CA98923B78DC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport.Cli", "src\DataImport\Mimisbrunnr.DataImport.Cli\Mimisbrunnr.DataImport.Cli.csproj", "{9584A06E-3865-446C-9534-FB917583A75B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Favorites", "src\Mimisbrunnr.Favorites\Mimisbrunnr.Favorites.csproj", "{7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Json", "src\Mimisbrunnr.Json\Mimisbrunnr.Json.csproj", "{C18BCA77-92BC-4653-A4E9-860E5A80B099}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migration", "Migration", "{04570E9A-30E7-4D6C-B395-2831D86AA665}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Migration.Persistent.Cli", "src\Migration\Mimisbrunnr.Migration.Persistent.Cli\Mimisbrunnr.Migration.Persistent.Cli.csproj", "{441CD216-C6A9-467A-957D-94CD0AE8FC0A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {84B0F1E6-567E-4136-8D51-E469D460C261} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {70CF2A02-DE33-4E98-9B79-101FE55E9E6F} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {C1294ED4-1559-40C1-A140-E0743156C664} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {08E5C5D6-4920-4016-89B8-9BD83022580C} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {C69EC431-9F8E-4368-AC2C-D1DC8B930573} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {425CCCC9-EDF8-49A5-86CD-B5E268B47C36} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {4FAC90B1-53E3-4CA2-9640-7E5177AA526C} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {A67956CF-DC77-4961-877A-140A2C47E19D} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {194C1349-E7B7-4066-949E-C71F71238481} = {A67956CF-DC77-4961-877A-140A2C47E19D} - {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {F5A0057B-E96E-422D-9CC2-E4568A4775B2} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} - {F5D75103-4E53-4514-B228-CA98923B78DC} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} - {9584A06E-3865-446C-9534-FB917583A75B} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} - {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29} = {A67956CF-DC77-4961-877A-140A2C47E19D} - {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {C18BCA77-92BC-4653-A4E9-860E5A80B099} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {04570E9A-30E7-4D6C-B395-2831D86AA665} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} - {441CD216-C6A9-467A-957D-94CD0AE8FC0A} = {04570E9A-30E7-4D6C-B395-2831D86AA665} - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|Any CPU.Build.0 = Debug|Any CPU - {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|Any CPU.ActiveCfg = Release|Any CPU - {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|Any CPU.Build.0 = Release|Any CPU - {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|Any CPU.Build.0 = Release|Any CPU - {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C1294ED4-1559-40C1-A140-E0743156C664}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C1294ED4-1559-40C1-A140-E0743156C664}.Release|Any CPU.Build.0 = Release|Any CPU - {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|Any CPU.Build.0 = Release|Any CPU - {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|Any CPU.Build.0 = Release|Any CPU - {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|Any CPU.Build.0 = Debug|Any CPU - {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|Any CPU.ActiveCfg = Release|Any CPU - {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|Any CPU.Build.0 = Release|Any CPU - {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|Any CPU.Build.0 = Release|Any CPU - {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|Any CPU.Build.0 = Release|Any CPU - {194C1349-E7B7-4066-949E-C71F71238481}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {194C1349-E7B7-4066-949E-C71F71238481}.Debug|Any CPU.Build.0 = Debug|Any CPU - {194C1349-E7B7-4066-949E-C71F71238481}.Release|Any CPU.ActiveCfg = Release|Any CPU - {194C1349-E7B7-4066-949E-C71F71238481}.Release|Any CPU.Build.0 = Release|Any CPU - {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|Any CPU.Build.0 = Release|Any CPU - {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|Any CPU.Build.0 = Release|Any CPU - {9584A06E-3865-446C-9534-FB917583A75B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9584A06E-3865-446C-9534-FB917583A75B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9584A06E-3865-446C-9534-FB917583A75B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9584A06E-3865-446C-9534-FB917583A75B}.Release|Any CPU.Build.0 = Release|Any CPU - {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|Any CPU.Build.0 = Release|Any CPU - {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|Any CPU.Build.0 = Release|Any CPU - {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web.Host", "src\Mimisbrunnr.Web.Host\Mimisbrunnr.Web.Host.csproj", "{84B0F1E6-567E-4136-8D51-E469D460C261}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web", "src\Mimisbrunnr.Web\Mimisbrunnr.Web.csproj", "{70CF2A02-DE33-4E98-9B79-101FE55E9E6F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Wiki", "src\Mimisbrunnr.Wiki\Mimisbrunnr.Wiki.csproj", "{C1294ED4-1559-40C1-A140-E0743156C664}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Storage.MongoDb", "src\Mimisbrunnr.Storage.MongoDb\Mimisbrunnr.Storage.MongoDb.csproj", "{08E5C5D6-4920-4016-89B8-9BD83022580C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Users", "src\Mimisbrunnr.Users\Mimisbrunnr.Users.csproj", "{C69EC431-9F8E-4368-AC2C-D1DC8B930573}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Web.Infrastructure", "src\Mimisbrunnr.Web.Infrastructure\Mimisbrunnr.Web.Infrastructure.csproj", "{425CCCC9-EDF8-49A5-86CD-B5E268B47C36}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Persistent", "src\Mimisbrunnr.Persistent\Mimisbrunnr.Persistent.csproj", "{4FAC90B1-53E3-4CA2-9640-7E5177AA526C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Integration", "src\Integration\Mimisbrunnr.Integration\Mimisbrunnr.Integration.csproj", "{BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{A67956CF-DC77-4961-877A-140A2C47E19D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Integration.Client", "src\Integration\Mimisbrunnr.Integration.Client\Mimisbrunnr.Integration.Client.csproj", "{194C1349-E7B7-4066-949E-C71F71238481}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataImport", "DataImport", "{FB23071D-A3D3-4519-8A7F-BF76826A8BE7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport", "src\DataImport\Mimisbrunnr.DataImport\Mimisbrunnr.DataImport.csproj", "{F5A0057B-E96E-422D-9CC2-E4568A4775B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport.Confluence", "src\DataImport\Mimisbrunnr.DataImport.Confluence\Mimisbrunnr.DataImport.Confluence.csproj", "{F5D75103-4E53-4514-B228-CA98923B78DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.DataImport.Cli", "src\DataImport\Mimisbrunnr.DataImport.Cli\Mimisbrunnr.DataImport.Cli.csproj", "{9584A06E-3865-446C-9534-FB917583A75B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Favorites", "src\Mimisbrunnr.Favorites\Mimisbrunnr.Favorites.csproj", "{7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Json", "src\Mimisbrunnr.Json\Mimisbrunnr.Json.csproj", "{C18BCA77-92BC-4653-A4E9-860E5A80B099}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migration", "Migration", "{04570E9A-30E7-4D6C-B395-2831D86AA665}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.Migration.Persistent.Cli", "src\Migration\Mimisbrunnr.Migration.Persistent.Cli\Mimisbrunnr.Migration.Persistent.Cli.csproj", "{441CD216-C6A9-467A-957D-94CD0AE8FC0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mimisbrunnr.PageTemplates", "src\Mimisbrunnr.PageTemplates\Mimisbrunnr.PageTemplates.csproj", "{DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|x64.ActiveCfg = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|x64.Build.0 = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|x86.ActiveCfg = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Debug|x86.Build.0 = Debug|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|Any CPU.Build.0 = Release|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|x64.ActiveCfg = Release|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|x64.Build.0 = Release|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|x86.ActiveCfg = Release|Any CPU + {84B0F1E6-567E-4136-8D51-E469D460C261}.Release|x86.Build.0 = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|x64.ActiveCfg = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|x64.Build.0 = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|x86.ActiveCfg = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Debug|x86.Build.0 = Debug|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|Any CPU.Build.0 = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|x64.ActiveCfg = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|x64.Build.0 = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|x86.ActiveCfg = Release|Any CPU + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F}.Release|x86.Build.0 = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|x64.Build.0 = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Debug|x86.Build.0 = Debug|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|Any CPU.Build.0 = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|x64.ActiveCfg = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|x64.Build.0 = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|x86.ActiveCfg = Release|Any CPU + {C1294ED4-1559-40C1-A140-E0743156C664}.Release|x86.Build.0 = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|x64.ActiveCfg = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|x64.Build.0 = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|x86.ActiveCfg = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Debug|x86.Build.0 = Debug|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|Any CPU.Build.0 = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|x64.ActiveCfg = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|x64.Build.0 = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|x86.ActiveCfg = Release|Any CPU + {08E5C5D6-4920-4016-89B8-9BD83022580C}.Release|x86.Build.0 = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|x64.ActiveCfg = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|x64.Build.0 = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|x86.ActiveCfg = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Debug|x86.Build.0 = Debug|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|Any CPU.Build.0 = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|x64.ActiveCfg = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|x64.Build.0 = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|x86.ActiveCfg = Release|Any CPU + {C69EC431-9F8E-4368-AC2C-D1DC8B930573}.Release|x86.Build.0 = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|x64.ActiveCfg = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|x64.Build.0 = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|x86.ActiveCfg = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Debug|x86.Build.0 = Debug|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|Any CPU.Build.0 = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|x64.ActiveCfg = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|x64.Build.0 = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|x86.ActiveCfg = Release|Any CPU + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36}.Release|x86.Build.0 = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|x64.Build.0 = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Debug|x86.Build.0 = Debug|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|Any CPU.Build.0 = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|x64.ActiveCfg = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|x64.Build.0 = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|x86.ActiveCfg = Release|Any CPU + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C}.Release|x86.Build.0 = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|x64.Build.0 = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Debug|x86.Build.0 = Debug|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|Any CPU.Build.0 = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|x64.ActiveCfg = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|x64.Build.0 = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|x86.ActiveCfg = Release|Any CPU + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29}.Release|x86.Build.0 = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|Any CPU.Build.0 = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|x64.ActiveCfg = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|x64.Build.0 = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|x86.ActiveCfg = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Debug|x86.Build.0 = Debug|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|Any CPU.ActiveCfg = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|Any CPU.Build.0 = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|x64.ActiveCfg = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|x64.Build.0 = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|x86.ActiveCfg = Release|Any CPU + {194C1349-E7B7-4066-949E-C71F71238481}.Release|x86.Build.0 = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|x64.Build.0 = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Debug|x86.Build.0 = Debug|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|Any CPU.Build.0 = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|x64.ActiveCfg = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|x64.Build.0 = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|x86.ActiveCfg = Release|Any CPU + {F5A0057B-E96E-422D-9CC2-E4568A4775B2}.Release|x86.Build.0 = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|x64.Build.0 = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Debug|x86.Build.0 = Debug|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|Any CPU.Build.0 = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|x64.ActiveCfg = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|x64.Build.0 = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|x86.ActiveCfg = Release|Any CPU + {F5D75103-4E53-4514-B228-CA98923B78DC}.Release|x86.Build.0 = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|x64.Build.0 = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Debug|x86.Build.0 = Debug|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|Any CPU.Build.0 = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|x64.ActiveCfg = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|x64.Build.0 = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|x86.ActiveCfg = Release|Any CPU + {9584A06E-3865-446C-9534-FB917583A75B}.Release|x86.Build.0 = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|x64.Build.0 = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Debug|x86.Build.0 = Debug|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|Any CPU.Build.0 = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|x64.ActiveCfg = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|x64.Build.0 = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|x86.ActiveCfg = Release|Any CPU + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D}.Release|x86.Build.0 = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|x64.ActiveCfg = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|x64.Build.0 = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|x86.ActiveCfg = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Debug|x86.Build.0 = Debug|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|Any CPU.Build.0 = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|x64.ActiveCfg = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|x64.Build.0 = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|x86.ActiveCfg = Release|Any CPU + {C18BCA77-92BC-4653-A4E9-860E5A80B099}.Release|x86.Build.0 = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|x64.Build.0 = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|x86.ActiveCfg = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Debug|x86.Build.0 = Debug|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|Any CPU.Build.0 = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|x64.ActiveCfg = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|x64.Build.0 = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|x86.ActiveCfg = Release|Any CPU + {441CD216-C6A9-467A-957D-94CD0AE8FC0A}.Release|x86.Build.0 = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|x64.Build.0 = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Debug|x86.Build.0 = Debug|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|Any CPU.Build.0 = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|x64.ActiveCfg = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|x64.Build.0 = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|x86.ActiveCfg = Release|Any CPU + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {84B0F1E6-567E-4136-8D51-E469D460C261} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {70CF2A02-DE33-4E98-9B79-101FE55E9E6F} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {C1294ED4-1559-40C1-A140-E0743156C664} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {08E5C5D6-4920-4016-89B8-9BD83022580C} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {C69EC431-9F8E-4368-AC2C-D1DC8B930573} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {425CCCC9-EDF8-49A5-86CD-B5E268B47C36} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {4FAC90B1-53E3-4CA2-9640-7E5177AA526C} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {BF40E4BA-5F8B-4C4F-967C-4CB7A728BC29} = {A67956CF-DC77-4961-877A-140A2C47E19D} + {A67956CF-DC77-4961-877A-140A2C47E19D} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {194C1349-E7B7-4066-949E-C71F71238481} = {A67956CF-DC77-4961-877A-140A2C47E19D} + {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {F5A0057B-E96E-422D-9CC2-E4568A4775B2} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} + {F5D75103-4E53-4514-B228-CA98923B78DC} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} + {9584A06E-3865-446C-9534-FB917583A75B} = {FB23071D-A3D3-4519-8A7F-BF76826A8BE7} + {7B25CC4B-BE5D-4AD8-A80F-88CCE928480D} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {C18BCA77-92BC-4653-A4E9-860E5A80B099} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {04570E9A-30E7-4D6C-B395-2831D86AA665} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + {441CD216-C6A9-467A-957D-94CD0AE8FC0A} = {04570E9A-30E7-4D6C-B395-2831D86AA665} + {DBF1A34F-BDFE-4C00-8265-AAF6BE865EF2} = {7B5B41ED-0FD9-4F24-9BA1-5D1C0D476611} + EndGlobalSection +EndGlobal From 1f78beb68da70c035ae57453a3f3652d366e247d Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sun, 14 Jun 2026 11:07:05 +0500 Subject: [PATCH 07/10] xml docs for api dto --- .../PageTemplates/PageTemplateCreateModel.cs | 31 +++++++++++-- .../PageTemplates/PageTemplateModel.cs | 46 +++++++++++++++++++ .../PageTemplateRenderRequest.cs | 16 ++++++- .../PageTemplateRenderResponse.cs | 6 +++ .../PageTemplates/PageTemplateUpdateModel.cs | 20 +++++++- 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs index 8482c71b..77653d62 100644 --- a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs @@ -2,11 +2,36 @@ namespace Mimisbrunnr.Integration.PageTemplates; +/// +/// Request model for creating a new page template +/// public class PageTemplateCreateModel { - [Required] public string Name { get; set; } + /// + /// Template name + /// + [Required] + public string Name { get; set; } + + /// + /// Template description + /// public string Description { get; set; } - [Required] public string Content { get; set; } - [Required] public string Type { get; set; } + + /// + /// Template content (Mustache/Markdown) + /// + [Required] + public string Content { get; set; } + + /// + /// Template type: System, User, or Space + /// + [Required] + public string Type { get; set; } + + /// + /// Space key (required for Space-type templates) + /// public string SpaceKey { get; set; } } diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs index 864d398f..6a52f04c 100644 --- a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs @@ -2,17 +2,63 @@ namespace Mimisbrunnr.Integration.PageTemplates; +/// +/// Page template model returned by the API +/// public class PageTemplateModel { + /// + /// Unique template identifier + /// public string Id { get; set; } + + /// + /// Template name + /// public string Name { get; set; } + + /// + /// Template description + /// public string Description { get; set; } + + /// + /// Template content (Mustache/Markdown) + /// public string Content { get; set; } + + /// + /// Template type: System, User, or Space + /// public string Type { get; set; } + + /// + /// Email of the template owner + /// public string OwnerEmail { get; set; } + + /// + /// Space ID (for Space-type templates) + /// public string SpaceId { get; set; } + + /// + /// Template creation date + /// public DateTime Created { get; set; } + + /// + /// Template last update date + /// public DateTime Updated { get; set; } + + /// + /// User who created the template + /// public UserModel CreatedBy { get; set; } + + /// + /// User who last updated the template + /// public UserModel UpdatedBy { get; set; } } diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs index 3f12fe77..18c639d5 100644 --- a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs @@ -2,8 +2,20 @@ namespace Mimisbrunnr.Integration.PageTemplates; +/// +/// Request model for rendering a page template +/// public class PageTemplateRenderRequest { - [Required] public string TemplateId { get; set; } - [Required] public string SpaceKey { get; set; } + /// + /// Template ID to render + /// + [Required] + public string TemplateId { get; set; } + + /// + /// Space key used for context variables during rendering + /// + [Required] + public string SpaceKey { get; set; } } diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs index c9e82ffd..7cef7127 100644 --- a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs @@ -1,6 +1,12 @@ namespace Mimisbrunnr.Integration.PageTemplates; +/// +/// Response model containing rendered template content +/// public class PageTemplateRenderResponse { + /// + /// Rendered template content (HTML/Markdown) + /// public string Content { get; set; } } diff --git a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs index f2ddf197..dee8c5c4 100644 --- a/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs @@ -2,9 +2,25 @@ namespace Mimisbrunnr.Integration.PageTemplates; +/// +/// Request model for updating an existing page template +/// public class PageTemplateUpdateModel { - [Required] public string Name { get; set; } + /// + /// Template name + /// + [Required] + public string Name { get; set; } + + /// + /// Template description + /// public string Description { get; set; } - [Required] public string Content { get; set; } + + /// + /// Template content (Mustache/Markdown) + /// + [Required] + public string Content { get; set; } } From 042b24544b5a25f6d93936d44cb37215f646ec62 Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sun, 14 Jun 2026 11:12:45 +0500 Subject: [PATCH 08/10] Change default confirm to msgBoxConfirm (bootsrtap) --- .../templates/PageTemplateManager.vue | 210 +++++++++--------- .../src/services/pageTemplateService.js | 84 ++++--- 2 files changed, 164 insertions(+), 130 deletions(-) diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue index 5f5771c0..4474cc94 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue @@ -1,104 +1,106 @@ - - - + + + diff --git a/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js index eb00543a..03bce032 100644 --- a/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js @@ -1,3 +1,5 @@ +import { showToast } from "@/services/Utils"; + async function request(url, options = {}) { const res = await fetch(url, { headers: { "Content-Type": "application/json" }, @@ -5,7 +7,9 @@ async function request(url, options = {}) { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw { status: res.status, message: err.message, data: err }; + const msg = err.message || "Request failed"; + showToast(`Status: ${res.status}. ${msg}`, "Error", "danger"); + throw { status: res.status, message: msg, data: err }; } if (res.status === 200 && (res.headers.get("content-length") === "0" || res.headers.get("content-type") === null)) { return null; @@ -14,35 +18,63 @@ async function request(url, options = {}) { } export default { - getAll(type, spaceKey) { - const params = new URLSearchParams(); - if (type) params.set("type", type); - if (spaceKey) params.set("spaceKey", spaceKey); - const qs = params.toString(); - return request("/api/pagetemplate" + (qs ? "?" + qs : "")); + async getAll(type, spaceKey) { + try { + const params = new URLSearchParams(); + if (type) params.set("type", type); + if (spaceKey) params.set("spaceKey", spaceKey); + const qs = params.toString(); + return await request("/api/pagetemplate" + (qs ? "?" + qs : "")); + } catch (e) { + return []; + } }, - getById(id) { - return request("/api/pagetemplate/" + id); + async getById(id) { + try { + return await request("/api/pagetemplate/" + id); + } catch (e) { + return null; + } }, - create(model) { - return request("/api/pagetemplate", { - method: "POST", - body: JSON.stringify(model), - }); + async create(model) { + try { + const result = await request("/api/pagetemplate", { + method: "POST", + body: JSON.stringify(model), + }); + showToast("Template created", "Success", "success"); + return result; + } catch (e) { + return null; + } }, - update(id, model) { - return request("/api/pagetemplate/" + id, { - method: "PUT", - body: JSON.stringify(model), - }); + async update(id, model) { + try { + await request("/api/pagetemplate/" + id, { + method: "PUT", + body: JSON.stringify(model), + }); + showToast("Template updated", "Success", "success"); + } catch (e) { + return null; + } }, - delete(id) { - return request("/api/pagetemplate/" + id, { method: "DELETE" }); + async delete(id) { + try { + await request("/api/pagetemplate/" + id, { method: "DELETE" }); + showToast("Template deleted", "Success", "success"); + } catch (e) { + return null; + } }, - render(templateId, spaceKey) { - return request("/api/pagetemplate/" + templateId + "/render", { - method: "POST", - body: JSON.stringify({ templateId, spaceKey }), - }); + async render(templateId, spaceKey) { + try { + return await request("/api/pagetemplate/" + templateId + "/render", { + method: "POST", + body: JSON.stringify({ templateId, spaceKey }), + }); + } catch (e) { + return null; + } }, }; From 6aff20e66ea7ab48fd80d054d50a97f203b096ec Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sun, 14 Jun 2026 17:42:46 +0500 Subject: [PATCH 09/10] Also updating description when update page template --- src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs | 2 +- src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs | 3 ++- src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs index 6e7716a6..ed97e770 100644 --- a/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs +++ b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs @@ -8,6 +8,6 @@ public interface IPageTemplateManager Task GetById(string id); IQueryable GetAll(); Task Create(PageTemplate template); - Task Update(string id, string name, string content, UserInfo updatedBy); + Task Update(string id, string name, string description, string content, UserInfo updatedBy); Task Delete(string id); } diff --git a/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs index e90fc94f..46661e51 100644 --- a/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs +++ b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs @@ -35,10 +35,11 @@ public async Task Create(PageTemplate template) return template; } - public async Task Update(string id, string name, string content, UserInfo updatedBy) + public async Task Update(string id, string name, string description, string content, UserInfo updatedBy) { var template = await GetById(id); template.Name = name; + template.Description = description; template.Content = content; template.Updated = DateTime.UtcNow; template.UpdatedBy = updatedBy; diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs index 959d1e4c..8106b09f 100644 --- a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs @@ -114,7 +114,7 @@ public async Task Update(string id, PageTemplateUpdateModel model, UserInfo user var template = await _pageTemplateManager.GetById(id); await EnsureModifyPermission(template, user); - await _pageTemplateManager.Update(id, model.Name, model.Content, user); + await _pageTemplateManager.Update(id, model.Name, model.Description, model.Content, user); } public async Task Delete(string id, UserInfo user) From 869ca60602e6a766e81eb332c1492200af1881e5 Mon Sep 17 00:00:00 2001 From: Klabukov Erik Date: Sun, 14 Jun 2026 18:17:36 +0500 Subject: [PATCH 10/10] * Add check space permission when render template * Add removing space templates when deleting space --- .../Services/IPageTemplateManager.cs | 1 + .../Services/PageTemplateManager.cs | 9 +++++++++ src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs | 1 + src/Mimisbrunnr.Web/Wiki/SpaceService.cs | 7 ++++++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs index ed97e770..041a4588 100644 --- a/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs +++ b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs @@ -10,4 +10,5 @@ public interface IPageTemplateManager Task Create(PageTemplate template); Task Update(string id, string name, string description, string content, UserInfo updatedBy); Task Delete(string id); + Task DeleteBySpaceId(string spaceId); } diff --git a/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs index 46661e51..19e0253f 100644 --- a/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs +++ b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs @@ -51,4 +51,13 @@ public async Task Delete(string id) var template = await GetById(id); await _repository.Delete(template); } + + public async Task DeleteBySpaceId(string spaceId) + { + var templates = _repository.GetAll().Where(x => x.Type == TemplateType.Space && x.SpaceId == spaceId).ToArray(); + foreach (var template in templates) + { + await _repository.Delete(template); + } + } } diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs index 8106b09f..c854e8a9 100644 --- a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs @@ -133,6 +133,7 @@ public async Task Render(string templateId, string s var space = await _spaceManager.GetByKey(spaceKey); if (space == null) throw new SpaceNotFoundException($"Space with key '{spaceKey}' not found"); + await _permissionService.EnsureViewPermission(spaceKey, user); var parameters = new Dictionary { diff --git a/src/Mimisbrunnr.Web/Wiki/SpaceService.cs b/src/Mimisbrunnr.Web/Wiki/SpaceService.cs index bf31f044..996e4b60 100644 --- a/src/Mimisbrunnr.Web/Wiki/SpaceService.cs +++ b/src/Mimisbrunnr.Web/Wiki/SpaceService.cs @@ -8,6 +8,7 @@ using Mimisbrunnr.Wiki.Services; using Mimisbrunnr.Integration.Wiki; using Mimisbrunnr.Integration.Group; +using Mimisbrunnr.PageTemplates.Services; namespace Mimisbrunnr.Web.Wiki; @@ -19,13 +20,15 @@ internal class SpaceService : ISpaceService, ISpaceDisplayService private readonly IUserGroupManager _userGroupManager; private readonly IApplicationConfigurationManager _applicationConfigurationManager; private readonly ILogger _logger; + private readonly IPageTemplateManager _pageTemplateManager; public SpaceService(IPermissionService permissionService, ISpaceManager spaceManager, IUserManager userManager, IUserGroupManager userGroupManager, IApplicationConfigurationManager applicationConfigurationManager, - ILogger logger + ILogger logger, + IPageTemplateManager pageTemplateManager ) { _permissionService = permissionService; @@ -34,6 +37,7 @@ ILogger logger _userGroupManager = userGroupManager; _applicationConfigurationManager = applicationConfigurationManager; _logger = logger; + _pageTemplateManager = pageTemplateManager; } public async Task GetAll(UserInfo requestedBy, int? take = null, int? skip = null) @@ -222,6 +226,7 @@ public async Task Remove(string key, UserInfo removedBy) if (space.Status != SpaceStatus.Archived) throw new InvalidOperationException("Only archived spaces allowed for removing"); + await _pageTemplateManager.DeleteBySpaceId(space.Id); await _spaceManager.Remove(space); _logger.LogInformation("User `{User}` remove space `{SpaceKey}`", removedBy.Email, key); }