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/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/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 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/design/page-templates.md b/docs/design/page-templates.md new file mode 100644 index 00000000..6618d882 --- /dev/null +++ b/docs/design/page-templates.md @@ -0,0 +1,1009 @@ +# Дизайн-документ: Шаблоны страниц (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** — имплементация, работающая напрямую через `IRepository` (без промежуточного Store). + +### 3.2. DTO (Integration) — `src/Integration/Mimisbrunnr.Integration/PageTemplates/` + +``` +PageTemplateModel.cs +PageTemplateCreateModel.cs +PageTemplateUpdateModel.cs +PageTemplateRenderRequest.cs +PageTemplateRenderResponse.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; } // для вычисления параметров +} + +// PageTemplateRenderResponse.cs +public class PageTemplateRenderResponse +{ + public string Content { get; set; } +} +``` + +### 3.3. API Controller — `src/Mimisbrunnr.Web/PageTemplates/` + +``` +PageTemplateController.cs +IPageTemplateService.cs +PageTemplateService.cs +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` + +**Эндпоинты:** + +| 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 пространства +``` + +**Рендеринг шаблона — логика в `PageTemplateService.Render()`:** + +```csharp +public async Task Render(string templateId, string spaceKey, UserInfo user) +{ + var template = await _pageTemplateManager.GetById(templateId); + var space = await _spaceManager.GetByKey(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 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 +} +``` + +**Пример Mustache-шаблона:** +```markdown +# {{SpaceName}} + +Создано пользователем {{User.Name}} {{CurrentDate}} + +## Описание + +... +``` + +### 3.4. Storage — MongoDB + +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 +{ + public PageTemplateMap() + { + ToCollection("PageTemplates"); + MapId(x => x.Id, BsonType.String); + } +} +``` + +**Индексы** — добавить метод `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** — зарегистрировать `IRepository` (если ещё не зарегистрировано автоматически через `AddEntity`): + +### 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; +} + +/* Скрываем кнопку на мобильных — места нет, адаптивность сложная */ +@media (max-width: 576px) { + .create-from-template-button { + display: none !important; + } +} +``` + +**Модалка выбора шаблона** `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()) + → PageTemplateController.GetAll() → PageTemplateService.GetAll() + → список шаблонов (System + User + Space, если в пространстве) + → User выбирает шаблон + → POST /api/pagetemplate/{id}/render { spaceKey } + → 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() + → 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/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/MongoDbStoreModule.cs` | изменить (добавить entity + indexes) | +| 19 | `src/Mimisbrunnr.Web.Host/StartupModule.cs` | изменить (добавить PageTemplatesModule) | +| 20 | `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 IRepository _repository; + private readonly PageTemplateManager _manager; + + public PageTemplateManagerTests() + { + _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); + } +} +``` + +### 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 | Определить `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 | Зарегистрировать entity в `MongoDbStoreModule` | `MongoDbStoreModule.cs` | +| 1.9 | Добавить индексы (Type, OwnerEmail, SpaceId) | `MongoDbStoreModule.cs` | +| 1.10 | Добавить `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` (repository 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 | Ручная проверка в браузере: админка, профиль, пространство, создание из шаблона | diff --git a/docs/stubble-renderer.md b/docs/stubble-renderer.md new file mode 100644 index 00000000..8ff44342 --- /dev/null +++ b/docs/stubble-renderer.md @@ -0,0 +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. + +## 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/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs new file mode 100644 index 00000000..77653d62 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateCreateModel.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +/// +/// Request model for creating a new page template +/// +public class PageTemplateCreateModel +{ + /// + /// Template name + /// + [Required] + public string Name { get; set; } + + /// + /// Template description + /// + public string Description { 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 new file mode 100644 index 00000000..6a52f04c --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateModel.cs @@ -0,0 +1,64 @@ +using Mimisbrunnr.Integration.User; + +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 new file mode 100644 index 00000000..18c639d5 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +/// +/// Request model for rendering a page template +/// +public class PageTemplateRenderRequest +{ + /// + /// 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 new file mode 100644 index 00000000..7cef7127 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateRenderResponse.cs @@ -0,0 +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 new file mode 100644 index 00000000..dee8c5c4 --- /dev/null +++ b/src/Integration/Mimisbrunnr.Integration/PageTemplates/PageTemplateUpdateModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mimisbrunnr.Integration.PageTemplates; + +/// +/// Request model for updating an existing page template +/// +public class PageTemplateUpdateModel +{ + /// + /// Template name + /// + [Required] + public string Name { get; set; } + + /// + /// Template description + /// + public string Description { get; set; } + + /// + /// Template content (Mustache/Markdown) + /// + [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..041a4588 --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Services/IPageTemplateManager.cs @@ -0,0 +1,14 @@ +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 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 new file mode 100644 index 00000000..19e0253f --- /dev/null +++ b/src/Mimisbrunnr.PageTemplates/Services/PageTemplateManager.cs @@ -0,0 +1,63 @@ +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 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; + await _repository.Update(template); + } + + 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.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..c8e0330a 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,24 @@ "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", + "markdownGuide": "Markdown guide" } }, "ru": { @@ -507,6 +531,7 @@ }, "people": "Пользователи", "pageCreateButton": "Создать", + "pageCreateFromTemplateButton": "Создать из шаблона", "search": "Поиск", "login": "Вход", "lang": "Язык", @@ -753,6 +778,9 @@ "ok": "Да", "cancel": "Нет" } + }, + "templates": { + "title": "Шаблоны страниц" } }, "profile": { @@ -790,7 +818,8 @@ }, "other": { "title": "Прочее" - } + }, + "pageTemplates": "Мои шаблоны" }, "favorites": "Избранное", "additional": { @@ -940,7 +969,8 @@ "title": "Действия в пространстве", "profile": "Профиль", "permissions": "Разрешения", - "settings": "Настройки" + "settings": "Настройки", + "templates": "Шаблоны страниц" }, "tree": "Древо страниц", "emptyTree": "Пусто", @@ -996,6 +1026,24 @@ "newPage": { "defaultTitle": "Новая страница", "defaultContent": "_Добавьте свою информацию сюда_" + }, + "pageTemplates": { + "title": "Шаблоны страниц", + "create": "Создать", + "edit": "Редактировать", + "delete": "Удалить", + "deleteConfirm": "Вы уверены, что хотите удалить шаблон?", + "name": "Имя", + "description": "Описание", + "content": "Шаблон", + "source": "Тип", + "system": "Системный", + "user": "Личный", + "space": "Пространства", + "createFromTemplate": "Создать из шаблона", + "noTemplates": "Нет доступных шаблонов", + "createPageFromTemplate": "Создать страницу из шаблона", + "markdownGuide": "Справка по Markdown" } } } \ 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..b4b374e6 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/base/CreateFromTemplateModal.vue @@ -0,0 +1,80 @@ + + + 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/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/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..4474cc94 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateManager.vue @@ -0,0 +1,106 @@ + + + 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..826d87f9 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/components/templates/PageTemplateModal.vue @@ -0,0 +1,101 @@ + + + + \ No newline at end of file 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..03bce032 --- /dev/null +++ b/src/Mimisbrunnr.Web.Host/ClientApp/src/services/pageTemplateService.js @@ -0,0 +1,80 @@ +import { showToast } from "@/services/Utils"; + +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(() => ({})); + 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; + } + return res.json(); +} + +export default { + 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 []; + } + }, + async getById(id) { + try { + return await request("/api/pagetemplate/" + id); + } catch (e) { + return null; + } + }, + 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; + } + }, + 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; + } + }, + async delete(id) { + try { + await request("/api/pagetemplate/" + id, { method: "DELETE" }); + showToast("Template deleted", "Success", "success"); + } catch (e) { + return null; + } + }, + async render(templateId, spaceKey) { + try { + return await request("/api/pagetemplate/" + templateId + "/render", { + method: "POST", + body: JSON.stringify({ templateId, spaceKey }), + }); + } catch (e) { + return null; + } + }, +}; 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.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/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..e61a84eb --- /dev/null +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateController.cs @@ -0,0 +1,88 @@ +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); + } +} diff --git a/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs new file mode 100644 index 00000000..c854e8a9 --- /dev/null +++ b/src/Mimisbrunnr.Web/PageTemplates/PageTemplateService.cs @@ -0,0 +1,225 @@ +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.Description, 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"); + await _permissionService.EnsureViewPermission(spaceKey, user); + + 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/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); } 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); + } +}