release/1.0.8-rc1 #42

Merged
notazof merged 47 commits from release/1.0.8-rc1 into main 2026-08-23 23:19:07 +01:00
17 changed files with 177 additions and 203 deletions
Showing only changes of commit e35bc273a3 - Show all commits

refacto BlogPost

Paul Schneider 2026-08-19 14:09:31 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -15,7 +15,7 @@
<PackageVersion Include="Material.Avalonia" Version="3.17.0" /> <PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" /> <PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0.11" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -31,6 +31,6 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -6,8 +6,10 @@ using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication; using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices; using Microsoft.Maui.Devices;
using PostIt.Services;
using System.Linq;
namespace PostIt.Services; namespace PostIt.Android.Services;
/// <summary> /// <summary>
/// Mobile implementation backed by MAUI Essentials /// Mobile implementation backed by MAUI Essentials
@ -49,7 +51,7 @@ public sealed class ContactService : IContactService
// shape is intentionally richer than the Yavsc // shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows // directory's single-Email shape — the two flows
// answer different questions. // answer different questions.
var result = new List<ContactDto>(contacts.Count); var result = new List<ContactDto>(contacts.Count());
foreach (var c in contacts) foreach (var c in contacts)
{ {
var emails = ExtractEmails(c.Emails); var emails = ExtractEmails(c.Emails);
@ -67,7 +69,7 @@ public sealed class ContactService : IContactService
} }
} }
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails) private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
{ {
if (emails is null) return Array.Empty<string>(); if (emails is null) return Array.Empty<string>();
var list = new List<string>(); var list = new List<string>();

View file

@ -48,71 +48,11 @@ public partial class App : Application
// build is ever reconfigured to skip the early check. // build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
var settings = new Settings(); var serviceProvider = BuildServices();
settings.Load(); AttachServiceProvider(serviceProvider);
var settings = serviceProvider.GetRequiredService<Settings>();
var tokenStore = new TokenStore(System.IO.Path.Combine( var sessionStatus = serviceProvider.GetRequiredService<SessionStatusViewModel>();
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), var api = serviceProvider.GetRequiredService<YavscApiClient>();
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(ServiceProvider);
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider)); DataTemplates.Add(new ViewLocator(ServiceProvider));
@ -219,6 +159,93 @@ public partial class App : Application
} }
} }
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> boot
/// without going through the full Avalonia desktop lifetime
/// (which never runs in a unit test). The container returned is
/// the exact one production uses — no test-only fakes, no
/// trimmed service list — so a test that exercises a VM, page,
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices()
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
}
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
{ {
Application.Current!.RequestedThemeVariant = Application.Current!.RequestedThemeVariant =

View file

@ -28,6 +28,9 @@ public class ViewLocator : IDataTemplate
Settings => _services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -118,6 +118,18 @@ public partial class MainPageViewModel : ViewModelBase
CurrentViewModel = this; CurrentViewModel = this;
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary> /// <summary>
/// Test-friendly constructor: caller supplies a pre-built /// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the /// <see cref="BlogApiClient"/>. Production code uses the
@ -303,6 +315,11 @@ public partial class MainPageViewModel : ViewModelBase
CurrentViewModel = SettingsModel; CurrentViewModel = SettingsModel;
} }
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost)
{
throw new NotImplementedException();
}
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
{ {
var posts = await BlogClient.GetPostsAsync(); var posts = await BlogClient.GetPostsAsync();
@ -363,33 +380,13 @@ public partial class MainPageViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary>
/// Raised when the user asks to open the "manage ACL" dialog for
/// the currently selected post. The <c>MainPage</c> code-behind
/// listens to this event and pushes a <c>PostAclDialog</c> on the
/// navigation stack. The VM itself can't navigate directly
/// because the navigation surface (<c>NavigationPage</c>) lives
/// in the View layer.
/// </summary>
public event EventHandler<BlogPostDto>? ManageAclRequested;
[RelayCommand(CanExecute = nameof(CanManageAcl))] [RelayCommand(CanExecute = nameof(CanManageAcl))]
public void ManageAcl() public void ManageAcl()
{ {
if (SelectedPost is null) return; if (SelectedPost is null) return;
ManageAclRequested?.Invoke(this, SelectedPost); CurrentViewModel = GetACLViewModel(SelectedPost);
} }
/// <summary> /// <summary>

View file

@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.Identity.Security;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new(); public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new(); public partial ObservableCollection<CircleAuthorization> AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; } public partial CircleDto? SelectedCircleToAdd { get; set; }
@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
var circles = circlesTask.Result ?? new List<CircleDto>(); var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles); MyCircles = new ObservableCollection<CircleDto>(circles);
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
allAcl.Where(a => a.BlogPostId == Post.Id));
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
} }
@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto var created = await _aclClient.GrantAsync(new CircleAuthorization
{ {
CircleId = SelectedCircleToAdd.Id, CircleId = SelectedCircleToAdd.Id
BlogPostId = Post.Id,
Comment = false,
}); });
if (created is not null) if (created is not null)
{ {
@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(CircleAuthorizationDto? acl) public async Task RevokeAsync(CircleAuthorization? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;

View file

@ -14,56 +14,10 @@ public partial class MainPage : ContentPage
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
} }
MainPageViewModel? _vm; MainPageViewModel? _vm;
void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking handlers
// when DataContext is reassigned (e.g. by the navigation
// host or a binding reset).
if (_vm is not null)
{
_vm.ManageAclRequested -= OnManageAclRequested;
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
}
_vm = DataContext as MainPageViewModel;
if (_vm is not null)
{
_vm.ManageAclRequested += OnManageAclRequested;
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
}
}
void OnManageAclRequested(object? sender, BlogPostDto post)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || post is null) return;
var dialog = new PostAclDialog(
post,
services.GetRequiredService<BlogAclApiClient>(),
services.GetRequiredService<CircleApiClient>());
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
void OnOpenCirclesRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<CirclesPage>();
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(page);
}
/// <summary> /// <summary>
/// DEV ONLY: temporary shortcut to open the signature capture /// DEV ONLY: temporary shortcut to open the signature capture

View file

@ -4,6 +4,7 @@
x:Class="PostIt.Views.PostAclDialog" x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos" xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel" x:DataType="vm:PostAclDialogViewModel"
> >
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -31,13 +32,11 @@
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}"> ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleAuthorizationDto"> <DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}" <TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/> FontWeight="Bold"/>
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Révoquer" <Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}" Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"

View file

@ -1,4 +1,3 @@
using System;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
@ -30,18 +29,17 @@ public class BlogPostDto : IBlogPost
/// </summary> /// </summary>
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public bool AuthorizeCircle(long circleId) public virtual bool AuthorizeCircle(long circleId)
{ {
throw new NotImplementedException(); ACL.Add(new CircleAuthorization { CircleId = circleId });
return true;
} }
public ICircleAuthorization[] GetACL() private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
{
throw new NotImplementedException();
}
public string[] GetTags() public string[] Tags { get; set; }
{
throw new NotImplementedException(); public string[] GetTags() => Tags;
}
public ICircleAuthorization[] GetACL() => ACL.ToArray();
} }

View file

@ -1,4 +1,4 @@
namespace Yavsc.Api.Client.Dtos; namespace Yavsc.Abstract.Identity.Security;
/// <summary> /// <summary>
/// Wire format for <c>GET /api/blogacl</c> and friends. /// Wire format for <c>GET /api/blogacl</c> and friends.
@ -11,9 +11,7 @@ namespace Yavsc.Api.Client.Dtos;
/// UI already has the post, and the circles are looked up by id /// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para> /// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary> /// </summary>
public sealed class CircleAuthorizationDto public sealed class CircleAuthorization : ICircleAuthorization
{ {
public long CircleId { get; set; } public long CircleId { get; set; }
public long BlogPostId { get; set; }
public bool Comment { get; set; }
} }

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client; namespace Yavsc.Api.Client;
@ -10,7 +11,7 @@ namespace Yavsc.Api.Client;
/// <summary> /// <summary>
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server. /// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
/// ///
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single /// <para>Each <see cref="CircleAuthorization"/> grants a single
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server /// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
/// scopes every endpoint to the caller's uid: only the author of /// scopes every endpoint to the caller's uid: only the author of
/// the underlying blog post can list, create, modify, or delete /// the underlying blog post can list, create, modify, or delete
@ -32,16 +33,16 @@ public sealed class BlogAclApiClient
api.Http.BaseAddress = new Uri(blogsBaseAddress); api.Http.BaseAddress = new Uri(blogsBaseAddress);
} }
public Task<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default) public Task<List<CircleAuthorization>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct); => _api.CallAsync<List<CircleAuthorization>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default) public Task<CircleAuthorization?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct); => _api.CallAsync<CircleAuthorization?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default) public Task<CircleAuthorization?> GrantAsync(CircleAuthorization acl, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Post, Path, body: acl, ct: ct); => _api.CallAsync<CircleAuthorization?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default) public Task UpdateAclAsync(long circleId, CircleAuthorization acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct); => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default) public Task RevokeAsync(long circleId, CancellationToken ct = default)

View file

@ -54,7 +54,7 @@ namespace Yavsc.Blogs.Controllers
// PUT: api/BlogApi/5 // PUT: api/BlogApi/5
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] BlogPost blog) public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {

View file

@ -58,7 +58,7 @@ namespace Yavsc.Org.Controllers
return View("Title", blogSpotService.GetTitle(id)); return View("Title", blogSpotService.GetTitle(id));
} }
private async Task<IEnumerable<BlogPost>> UserPosts(string userName, int pageLen = 10, int pageNum = 0) private async Task<IEnumerable<Models.Blog.BlogPost>> UserPosts(string userName, int pageLen = 10, int pageNum = 0)
{ {
return await blogSpotService.UserPosts(userName, User.GetUserId(), pageLen, pageNum); return await blogSpotService.UserPosts(userName, User.GetUserId(), pageLen, pageNum);
@ -95,7 +95,7 @@ namespace Yavsc.Org.Controllers
public IActionResult Create(string title) public IActionResult Create(string title)
{ {
var result = new BlogPostEditViewModel var result = new BlogPostEditViewModel
(new BlogPost (new Models.Blog.BlogPost
{ {
Title = title Title = title
}, true); }, true);
@ -105,11 +105,11 @@ namespace Yavsc.Org.Controllers
// POST: Blog/Create // POST: Blog/Create
[HttpPost, Authorize, ValidateAntiForgeryToken] [HttpPost, Authorize, ValidateAntiForgeryToken]
public IActionResult Create(BlogPost blogInput) public IActionResult Create(Models.Blog.BlogPost blogInput)
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
BlogPost post = blogSpotService.Create(User.GetUserId(), Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(),
blogInput, Request.Form.Files); blogInput, Request.Form.Files);
return RedirectToAction("Index"); return RedirectToAction("Index");
} }

View file

@ -28,7 +28,7 @@ public class OldBlogSpotService
this.fileSystemAuthManager = fileSystemAuthManager; this.fileSystemAuthManager = fileSystemAuthManager;
} }
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
{ {
// Sauvegarder le post d'abord pour obtenir son ID // Sauvegarder le post d'abord pour obtenir son ID
_context.BlogSpot.Add(post); _context.BlogSpot.Add(post);
@ -102,14 +102,14 @@ public class OldBlogSpotService
return new BlogPostEditViewModel(blog, pub); return new BlogPostEditViewModel(blog, pub);
} }
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId) public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
{ {
BlogPost blog = await _context.BlogSpot Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
.Include(p => p.Author) .Include(p => p.Author)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Comments) .Include(p => p.Comments)
.Include(p => p.ACL) .Include(p => p.ACL)
.SingleAsync(m => m.Id == blogPostId); .SingleAsync((object m) => m.Id == blogPostId);
if (blog == null) if (blog == null)
{ {
return null; return null;
@ -165,7 +165,7 @@ public class OldBlogSpotService
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task Modify(ClaimsPrincipal user, BlogPost blog) public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
{ {
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id); var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
if (existing == null) if (existing == null)
@ -233,20 +233,20 @@ public class OldBlogSpotService
public async Task Delete(ClaimsPrincipal user, long id) public async Task Delete(ClaimsPrincipal user, long id)
{ {
var uid = user.GetUserId(); var uid = user.GetUserId();
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
_context.BlogSpot.Remove(blog); _context.BlogSpot.Remove(blog);
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task<IEnumerable<BlogPost>> UserPosts( public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
string posterName, string posterName,
string? readerId, string? readerId,
int pageLen = 10, int pageLen = 10,
int pageNum = 0) int pageNum = 0)
{ {
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
if (posterId == null) return Array.Empty<BlogPost>(); if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
return _context.UserPosts(posterId, readerId); return _context.UserPosts(posterId, readerId);
} }
@ -259,7 +259,7 @@ public class OldBlogSpotService
).ToList(); ).ToList();
} }
public async Task<BlogPost?> GetBlogPostAsync(long value) public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
{ {
return await _context.BlogSpot return await _context.BlogSpot
.Include(b => b.Author) .Include(b => b.Author)

View file

@ -1,7 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json; using Newtonsoft.Json;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
@ -69,7 +68,7 @@ namespace Yavsc.Models.Blog
public ICircleAuthorization[] GetACL() public ICircleAuthorization[] GetACL()
{ {
return ACL.ToArray(); return ACL?.ToArray() ?? Array.Empty<ICircleAuthorization>();
} }
public void Tag(Tag tag) public void Tag(Tag tag)

View file

@ -27,7 +27,7 @@ public class BlogSpotService
this.fileSystemAuthManager = fileSystemAuthManager; this.fileSystemAuthManager = fileSystemAuthManager;
} }
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
{ {
// Sauvegarder le post d'abord pour obtenir son ID // Sauvegarder le post d'abord pour obtenir son ID
// Le créateur vient de l'authentification, donc on ne le prend pas du post // Le créateur vient de l'authentification, donc on ne le prend pas du post
@ -103,9 +103,9 @@ public class BlogSpotService
return new BlogPostEditViewModel(blog, pub); return new BlogPostEditViewModel(blog, pub);
} }
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId) public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
{ {
BlogPost blog = await _context.BlogSpot Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
.Include(p => p.Author) .Include(p => p.Author)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Comments) .Include(p => p.Comments)
@ -170,7 +170,7 @@ public class BlogSpotService
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task Modify(ClaimsPrincipal user, BlogPost blog) public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
{ {
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id); var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
if (existing == null) if (existing == null)
@ -238,7 +238,7 @@ public class BlogSpotService
// the N+1 of one AnyAsync per post. The published ids // the N+1 of one AnyAsync per post. The published ids
// are loaded once and matched against the post list // are loaded once and matched against the post list
// in memory. // in memory.
var postIds = materialised.OfType<BlogPost>().Select(p => p.Id).ToList(); var postIds = materialised.Select(p => p.Id).ToList();
if (postIds.Count > 0) if (postIds.Count > 0)
{ {
var publishedIds = await _context.blogSpotPublications var publishedIds = await _context.blogSpotPublications
@ -246,7 +246,7 @@ public class BlogSpotService
.Select(pub => pub.BlogpostId) .Select(pub => pub.BlogpostId)
.ToListAsync(); .ToListAsync();
var publishedSet = publishedIds.ToHashSet(); var publishedSet = publishedIds.ToHashSet();
foreach (var post in materialised.OfType<BlogPost>()) foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
post.IsPublished = publishedSet.Contains(post.Id); post.IsPublished = publishedSet.Contains(post.Id);
} }
@ -259,20 +259,20 @@ public class BlogSpotService
public async Task Delete(ClaimsPrincipal user, long id) public async Task Delete(ClaimsPrincipal user, long id)
{ {
var uid = user.GetUserId(); var uid = user.GetUserId();
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
_context.BlogSpot.Remove(blog); _context.BlogSpot.Remove(blog);
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task<IEnumerable<BlogPost>> UserPosts( public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
string posterName, string posterName,
string? readerId, string? readerId,
int pageLen = 10, int pageLen = 10,
int pageNum = 0) int pageNum = 0)
{ {
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
if (posterId == null) return Array.Empty<BlogPost>(); if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
return _context.UserPosts(posterId, readerId); return _context.UserPosts(posterId, readerId);
} }
@ -285,7 +285,7 @@ public class BlogSpotService
).ToList(); ).ToList();
} }
public async Task<BlogPost?> GetBlogPostAsync(long value) public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
{ {
return await _context.BlogSpot return await _context.BlogSpot
.Include(b => b.Author) .Include(b => b.Author)