Created
July 22, 2025 12:06
-
-
Save spinningcat/674b5031132785aeca1cd1b3b1e6091b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using DynamicData; | |
using DynamicData.Binding; | |
using Microsoft.EntityFrameworkCore; | |
using Microsoft.Extensions.DependencyInjection; | |
using Microsoft.Extensions.Logging; | |
using Programming.Team.AI.Core; | |
using Programming.Team.Business.Core; | |
using Programming.Team.Core; | |
using ReactiveUI; | |
using System; | |
using System.Collections.Generic; | |
using System.Collections.ObjectModel; | |
using System.ComponentModel.DataAnnotations; | |
using System.Linq; | |
using System.Linq.Expressions; | |
using System.Reactive; | |
using System.Reactive.Disposables; | |
using System.Reactive.Linq; | |
using System.Runtime.InteropServices.Marshalling; | |
using System.Text; | |
using System.Threading.Tasks; | |
using System.Windows.Input; | |
namespace Programming.Team.ViewModels.Resume | |
{ | |
public class AddProjectViewModel : AddUserPartionedEntity<Guid, Project>, IProject | |
{ | |
public SearchSelectPositionViewModel PositionSelector { get; } | |
protected readonly CompositeDisposable disposable = new CompositeDisposable(); | |
public AddProjectViewModel(SearchSelectPositionViewModel positionSelector, IBusinessRepositoryFacade<Project, Guid> facade, ILogger<AddEntityViewModel<Guid, Project, IBusinessRepositoryFacade<Project, Guid>>> logger) : base(facade, logger) | |
{ | |
PositionSelector = positionSelector; | |
PositionSelector.WhenPropertyChanged(p => p.Selected).Subscribe(p => | |
{ | |
if (p.Sender?.Selected != null) | |
PositionId = p.Sender.Selected.Id; | |
else | |
PositionId = Guid.Empty; | |
}).DisposeWith(disposable); | |
} | |
~AddProjectViewModel() | |
{ | |
disposable.Dispose(); | |
} | |
private Guid positionId; | |
public Guid PositionId | |
{ | |
get => positionId; | |
set => this.RaiseAndSetIfChanged(ref positionId, value); | |
} | |
private string? description; | |
public string? Description | |
{ | |
get => description; | |
set => this.RaiseAndSetIfChanged(ref description, value); | |
} | |
private string? projectUrl; | |
[Url(ErrorMessage = "Project URL must be a valid URL")] | |
public string? ProjectUrl | |
{ | |
get => projectUrl; | |
set => this.RaiseAndSetIfChanged(ref projectUrl, value); | |
} | |
private string? sourceUrl; | |
[Url(ErrorMessage = "Source URL must be a valid URL")] | |
public string? SourceUrl | |
{ | |
get => sourceUrl; | |
set => this.RaiseAndSetIfChanged(ref sourceUrl, value); | |
} | |
private string? license; | |
public string? License | |
{ | |
get => license; | |
set => this.RaiseAndSetIfChanged(ref license, value); | |
} | |
private string? sortOrder; | |
public string? SortOrder | |
{ | |
get => sortOrder; | |
set => this.RaiseAndSetIfChanged(ref sortOrder, value); | |
} | |
private string name = string.Empty; | |
[Required(ErrorMessage = "Name is required")] | |
public string Name | |
{ | |
get => name; | |
set => this.RaiseAndSetIfChanged(ref name, value); | |
} | |
private string? liscence; | |
public string? Liscence | |
{ | |
get => liscence; | |
set => this.RaiseAndSetIfChanged(ref liscence, value); | |
} | |
protected override Task Clear() | |
{ | |
Name = string.Empty; | |
SortOrder = null; | |
License = null; | |
SourceUrl = null; | |
ProjectUrl = null; | |
PositionSelector.Selected = null; | |
Description = null; | |
return Task.CompletedTask; | |
} | |
protected override Task<Project> ConstructEntity() | |
{ | |
return Task.FromResult(new Project() | |
{ | |
Name = Name, | |
SortOrder = SortOrder, | |
License = License, | |
SourceUrl = SourceUrl, | |
ProjectUrl = ProjectUrl, | |
PositionId = PositionId, | |
Description = Description, | |
UserId = UserId, | |
}); | |
} | |
} | |
public class ProjectsViewModel : EntitiesDefaultViewModel<Guid, Project, ProjectViewModel, AddProjectViewModel> | |
{ | |
protected IServiceProvider ServiceProvider { get; } | |
public ProjectsViewModel(AddProjectViewModel addViewModel, | |
IBusinessRepositoryFacade<Project, Guid> facade, | |
ILogger<EntitiesViewModel<Guid, Project, ProjectViewModel, IBusinessRepositoryFacade<Project, Guid>>> logger, | |
IServiceProvider serviceProvider) : base(addViewModel, facade, logger) | |
{ | |
ServiceProvider = serviceProvider; | |
} | |
protected override Func<IQueryable<Project>, IQueryable<Project>>? PropertiesToLoad() | |
{ | |
return e => e.Include(x => x.Position).Include(x => x.ProjectSkills).ThenInclude(x => x.Skill); | |
} | |
protected override Func<IQueryable<Project>, IOrderedQueryable<Project>>? OrderBy() | |
{ | |
// return e => e.OrderByDescending(c => c.SortOrder); | |
return e => e.OrderBy(c => c.SortOrder); | |
} | |
protected override async Task<Expression<Func<Project, bool>>?> FilterCondition() | |
{ | |
var userId = await Facade.GetCurrentUserId(); | |
return e => e.UserId == userId; | |
} | |
protected override Task<ProjectViewModel> Construct(Project entity, CancellationToken token) | |
{ | |
var vm = new ProjectViewModel(ServiceProvider.GetRequiredService<ProjectSkillsViewModel>(), Logger, Facade, | |
entity); | |
return Task.FromResult(vm); | |
} | |
} | |
public class ProjectViewModel : EntityViewModel<Guid, Project>, IProject | |
{ | |
private Boolean canExtractSkills; | |
public Boolean CanExtractSkills | |
{ | |
get => canExtractSkills; | |
set => this.RaiseAndSetIfChanged(ref canExtractSkills, value); | |
} | |
public ProjectSkillsViewModel SkillsViewModel { get; } | |
public ProjectViewModel(ProjectSkillsViewModel skillsViewModel, ILogger logger, IBusinessRepositoryFacade<Project, Guid> facade, Guid id) : base(logger, facade, id) | |
{ | |
SkillsViewModel = skillsViewModel; | |
WireUpSkillsVM(); | |
} | |
public ProjectViewModel(ProjectSkillsViewModel skillsViewModel, ILogger logger, IBusinessRepositoryFacade<Project, Guid> facade, Project entity) : base(logger, facade, entity) | |
{ | |
SkillsViewModel = skillsViewModel; | |
WireUpSkillsVM(); | |
} | |
protected void WireUpSkillsVM() | |
{ | |
SkillsViewModel.WhenPropertyChanged(p => p.Description).Subscribe(p => | |
{ | |
if (p.Sender != null) | |
SkillsViewModel.Description = p.Sender.Description ?? ""; | |
}).DisposeWith(disposable); | |
this.WhenPropertyChanged(p => p.Description).Subscribe(p => { | |
CanExtractSkills = | |
!string.IsNullOrWhiteSpace(p.Sender.Description); | |
}).DisposeWith(disposable); | |
this.WhenPropertyChanged(p => p.CanExtractSkills).Subscribe(p => { | |
SkillsViewModel.CanExtractSkills = | |
p.Sender.CanExtractSkills).DisposeWith(disposable); | |
} | |
private Guid positionId; | |
public Guid PositionId | |
{ | |
get => positionId; | |
set => this.RaiseAndSetIfChanged(ref positionId, value); | |
} | |
public Position? position; | |
public Position? Position | |
{ | |
get => position; | |
set => this.RaiseAndSetIfChanged(ref position, value); | |
} | |
private string? description; | |
public string? Description | |
{ | |
get => description; | |
set => this.RaiseAndSetIfChanged(ref description, value); | |
} | |
private string? projectUrl; | |
public string? ProjectUrl | |
{ | |
get => projectUrl; | |
set => this.RaiseAndSetIfChanged(ref projectUrl, value); | |
} | |
private string? sourceUrl; | |
public string? SourceUrl | |
{ | |
get => sourceUrl; | |
set => this.RaiseAndSetIfChanged(ref sourceUrl, value); | |
} | |
private string? license; | |
public string? License | |
{ | |
get => license; | |
set => this.RaiseAndSetIfChanged(ref license, value); | |
} | |
private string? sortOrder; | |
public string? SortOrder | |
{ | |
get => sortOrder; | |
set => this.RaiseAndSetIfChanged(ref sortOrder, value); | |
} | |
private string name = string.Empty; | |
public string Name | |
{ | |
get => name; | |
set => this.RaiseAndSetIfChanged(ref name, value); | |
} | |
private Guid userId; | |
public Guid UserId | |
{ | |
get => userId; | |
set => this.RaiseAndSetIfChanged(ref userId, value); | |
} | |
protected readonly CompositeDisposable disposable = new CompositeDisposable(); | |
~ProjectViewModel() | |
{ | |
disposable.Dispose(); | |
} | |
protected override Func<IQueryable<Project>, IQueryable<Project>>? PropertiesToLoad() | |
{ | |
return e => e.Include(x => x.Position).Include(x => x.ProjectSkills).ThenInclude(x => x.Skill); | |
} | |
internal override Task<Project> Populate() | |
{ | |
return Task.FromResult(new Project() | |
{ | |
Id = Id, | |
Name = Name, | |
UserId = UserId, | |
Description = Description, | |
SortOrder = SortOrder, | |
License = License, | |
SourceUrl = SourceUrl, | |
ProjectUrl = ProjectUrl, | |
PositionId = PositionId, | |
}); | |
} | |
internal override async Task Read(Project entity) | |
{ | |
Id = entity.Id; | |
Name = entity.Name; | |
UserId = entity.UserId; | |
Description = entity.Description; | |
SortOrder = entity.SortOrder; | |
License = entity.License; | |
SourceUrl = entity.SourceUrl; | |
ProjectUrl = entity.ProjectUrl; | |
PositionId = entity.PositionId; | |
SkillsViewModel.ProjectId = entity.Id; | |
SkillsViewModel.PositionId = entity.PositionId; | |
SkillsViewModel.InitialEntities = entity.ProjectSkills; | |
SkillsViewModel.Description = entity.Description ?? ""; | |
Position = entity.Position; | |
await SkillsViewModel.Load.Execute().GetAwaiter(); | |
} | |
} | |
public class ProjectSkillViewModel : EntityViewModel<Guid, ProjectSkill>, IProjectSkill | |
{ | |
private bool isOpen; | |
public bool IsOpen | |
{ | |
get => isOpen; | |
set => this.RaiseAndSetIfChanged(ref isOpen, value); | |
} | |
private Guid projectId; | |
public Guid ProjectId | |
{ | |
get => projectId; | |
set => this.RaiseAndSetIfChanged(ref projectId, value); | |
} | |
public Guid PositionId { get; set; } | |
private Guid skillId; | |
public Guid SkillId | |
{ | |
get => skillId; | |
set => this.RaiseAndSetIfChanged(ref skillId, value); | |
} | |
private string? description; | |
public string? Description | |
{ | |
get => description; | |
set => this.RaiseAndSetIfChanged(ref description, value); | |
} | |
private Boolean canExtractSkills; | |
public Boolean CanExtractSkills | |
{ | |
get => canExtractSkills; | |
set => this.RaiseAndSetIfChanged(ref canExtractSkills, value); | |
} | |
public ReactiveCommand<Unit, Unit> Cancel { get; } | |
public ReactiveCommand<Unit, Unit> Edit { get; } | |
public ProjectSkillViewModel(ILogger logger, IBusinessRepositoryFacade<ProjectSkill, Guid> facade, Guid id) : base(logger, facade, id) | |
{ | |
Cancel = ReactiveCommand.CreateFromTask(DoCancel); | |
Edit = ReactiveCommand.Create(() => { IsOpen = true; }); | |
} | |
public ProjectSkillViewModel(ILogger logger, IBusinessRepositoryFacade<ProjectSkill, Guid> facade, ProjectSkill entity) : base(logger, facade, entity) | |
{ | |
Cancel = ReactiveCommand.CreateFromTask(DoCancel); | |
Edit = ReactiveCommand.Create(() => { IsOpen = true; }); | |
} | |
protected async Task DoCancel(CancellationToken token) | |
{ | |
IsOpen = false; | |
await Load.Execute().GetAwaiter(); | |
} | |
protected override Func<IQueryable<ProjectSkill>, IQueryable<ProjectSkill>>? PropertiesToLoad() | |
{ | |
return q => q.Include(e => e.Skill).Include(e => e.Project); | |
} | |
private Skill skill = null!; | |
public Skill Skill | |
{ | |
get => skill; | |
set => this.RaiseAndSetIfChanged(ref skill, value); | |
} | |
internal override Task<ProjectSkill> Populate() | |
{ | |
return Task.FromResult(new ProjectSkill() | |
{ | |
Id = Id, | |
ProjectId = ProjectId, | |
SkillId = SkillId, | |
Description = Description | |
}); | |
} | |
internal override Task Read(ProjectSkill entity) | |
{ | |
Id = entity.Id; | |
ProjectId = entity.ProjectId; | |
SkillId = entity.SkillId; | |
Description = entity.Description; | |
Skill = entity.Skill; | |
PositionId = entity.Project.PositionId; | |
return Task.CompletedTask; | |
} | |
} | |
public class AddProjectSkillViewModel : AddEntityViewModel<Guid, ProjectSkill>, IProjectSkill | |
{ | |
public SearchSelectSkillViewModel SkillSelectorViewModel { get; } | |
protected IBusinessRepositoryFacade<PositionSkill, Guid> PositionSkillFacade { get; } | |
private readonly CompositeDisposable disposables = new CompositeDisposable(); | |
public AddProjectSkillViewModel(IBusinessRepositoryFacade<PositionSkill, Guid> positionFacade, SearchSelectSkillViewModel skillViewModel, IBusinessRepositoryFacade<ProjectSkill, Guid> facade, ILogger<AddEntityViewModel<Guid, ProjectSkill, IBusinessRepositoryFacade<ProjectSkill, Guid>>> logger) : base(facade, logger) | |
{ | |
SkillSelectorViewModel = skillViewModel; | |
PositionSkillFacade = positionFacade; | |
skillViewModel.WhenPropertyChanged(p => p.Selected).Subscribe(p => | |
{ | |
if (p.Sender.Selected == null) | |
SkillId = Guid.Empty; | |
else | |
SkillId = p.Sender.Selected.Id; | |
}).DisposeWith(disposables); | |
} | |
public override bool CanAdd => SkillSelectorViewModel.Selected != null; | |
private Guid projectId; | |
public Guid ProjectId | |
{ | |
get => projectId; | |
set => this.RaiseAndSetIfChanged(ref projectId, value); | |
} | |
private Guid positionId; | |
public Guid PositionId | |
{ | |
get => positionId; | |
set => this.RaiseAndSetIfChanged(ref positionId, value); | |
} | |
private Guid skillId; | |
public Guid SkillId | |
{ | |
get => skillId; | |
set | |
{ | |
this.RaiseAndSetIfChanged(ref skillId, value); | |
this.RaisePropertyChanged(nameof(CanAdd)); | |
} | |
} | |
private string? description; | |
public string? Description | |
{ | |
get => description; | |
set => this.RaiseAndSetIfChanged(ref description, value); | |
} | |
protected override Task Clear() | |
{ | |
SkillId = Guid.Empty; | |
Description = null; | |
SkillSelectorViewModel.Selected = null; | |
return Task.CompletedTask; | |
} | |
protected override async Task<ProjectSkill?> DoAdd(CancellationToken token) | |
{ | |
try | |
{ | |
var rs = await PositionSkillFacade.Get(page: new Pager() { Page = 1, Size = 1 }, | |
filter: f => f.PositionId == PositionId && f.SkillId == SkillId, token: token); | |
if (rs.Count == 0) | |
await PositionSkillFacade.Add(new PositionSkill() | |
{ | |
SkillId = SkillId, | |
PositionId = PositionId | |
}, token: token); | |
} | |
catch (Exception ex) | |
{ | |
Logger.LogError(ex, ex.Message); | |
await Alert.Handle(ex.Message).GetAwaiter(); | |
return null; | |
} | |
return await base.DoAdd(token); | |
} | |
protected override Task<ProjectSkill> ConstructEntity() | |
{ | |
return Task.FromResult(new ProjectSkill() | |
{ | |
Id = Id, | |
ProjectId = ProjectId, | |
SkillId = SkillId, | |
Description = Description | |
}); | |
} | |
~AddProjectSkillViewModel() | |
{ | |
disposables.Dispose(); | |
} | |
} | |
public class SuggestAddSkillsForProjectViewModel : ReactiveObject | |
{ | |
public Interaction<string, bool> Alert { get; } = new Interaction<string, bool>(); | |
public Guid ProjectId { get; set; } | |
public ReactiveCommand<Unit, Unit> SuggestSkills { get; } | |
public ReactiveCommand<Unit, Unit> AddSelectedSkills { get; } | |
public ObservableCollection<SkillViewModel> Skills { get; } = new ObservableCollection<SkillViewModel>(); | |
protected ISkillsBusinessFacade SkillFacade { get; } | |
protected IBusinessRepositoryFacade<ProjectSkill, Guid> ProjectSkillFacade { get; } | |
protected ILogger Logger { get; } | |
public SuggestAddSkillsForProjectViewModel(ISkillsBusinessFacade skillFacade, IBusinessRepositoryFacade<ProjectSkill, Guid> positionSkillFacade, ILogger<SuggestAddSkillsForPositionViewModel> logger) | |
{ | |
SuggestSkills = ReactiveCommand.CreateFromTask(DoSuggestSkills); | |
AddSelectedSkills = ReactiveCommand.CreateFromTask(DoAddSelectedSkills); | |
SkillFacade = skillFacade; | |
ProjectSkillFacade = positionSkillFacade; | |
Logger = logger; | |
} | |
protected async Task DoSuggestSkills(CancellationToken token) | |
{ | |
try | |
{ | |
Skills.Clear(); | |
foreach (var skill in await SkillFacade.GetSkillsExcludingProject(ProjectId, token: token)) | |
{ | |
var vm = new SkillViewModel(Logger, SkillFacade, skill); | |
await vm.Load.Execute().GetAwaiter(); | |
Skills.Add(vm); | |
} | |
} | |
catch (Exception ex) | |
{ | |
Logger.LogError(ex, ex.Message); | |
await Alert.Handle(ex.Message).GetAwaiter(); | |
} | |
} | |
protected async Task DoAddSelectedSkills(CancellationToken token) | |
{ | |
try | |
{ | |
foreach (var skill in Skills.Where(s => s.IsSelected).ToArray()) | |
{ | |
var ps = new ProjectSkill() | |
{ | |
ProjectId = ProjectId, | |
SkillId = skill.Id | |
}; | |
await ProjectSkillFacade.Add(ps, token: token); | |
Skills.Remove(skill); | |
} | |
} | |
catch (Exception ex) | |
{ | |
Logger.LogError(ex, ex.Message); | |
await Alert.Handle(ex.Message).GetAwaiter(); | |
} | |
} | |
} | |
public class ProjectSkillsViewModel : EntitiesDefaultViewModel<Guid, ProjectSkill, ProjectSkillViewModel, AddProjectSkillViewModel> | |
{ | |
public ReactiveCommand<Unit, Unit> ExtractSkills { get; } | |
public ReactiveCommand<RawSkillViewModel, Unit> AddRawSkill { get; } | |
public ICommand ToggleOpen => ReactiveCommand.Create(() => IsOpen = !IsOpen); | |
protected IResumeEnricher Enricher { get; } | |
protected IBusinessRepositoryFacade<Skill, Guid> SkillFacade { get; } | |
public Guid ProjectId | |
{ | |
get => AddViewModel.ProjectId; | |
set | |
{ | |
AddViewModel.ProjectId = value; | |
SuggestAddSkillsVM.ProjectId = value; | |
} | |
} | |
public Guid PositionId | |
{ | |
get => AddViewModel.PositionId; | |
set | |
{ | |
AddViewModel.PositionId = value; | |
} | |
} | |
private string description = string.Empty; | |
public string Description | |
{ | |
get => description; | |
set => this.RaiseAndSetIfChanged(ref description, value); | |
} | |
public SuggestAddSkillsForProjectViewModel SuggestAddSkillsVM { get; } | |
protected IBusinessRepositoryFacade<PositionSkill, Guid> PositionSkillFacade { get; } | |
public ProjectSkillsViewModel(AddProjectSkillViewModel addViewModel, SuggestAddSkillsForProjectViewModel suggestAddSkillsVM, | |
IBusinessRepositoryFacade<ProjectSkill, Guid> facade, IBusinessRepositoryFacade<Skill, Guid> skillFacade, | |
ILogger<EntitiesViewModel<Guid, ProjectSkill, ProjectSkillViewModel, IBusinessRepositoryFacade<ProjectSkill, Guid>>> logger, | |
IResumeEnricher enricher, IBusinessRepositoryFacade<PositionSkill, Guid> positionSkillFacade) : base(addViewModel, facade, logger) | |
{ | |
ExtractSkills = ReactiveCommand.CreateFromTask(DoExtractSkills); | |
Enricher = enricher; | |
SkillFacade = skillFacade; | |
SuggestAddSkillsVM = suggestAddSkillsVM; | |
PositionSkillFacade = positionSkillFacade; | |
AddRawSkill = ReactiveCommand.CreateFromTask<RawSkillViewModel>(DoAddRawSkill); | |
} | |
protected async Task DoAddRawSkill(RawSkillViewModel raw, CancellationToken token) | |
{ | |
try | |
{ | |
var skillRes = await SkillFacade.Get(page: new Pager() { Page = 1, Size = 1 }, filter: q => q.Name == raw.Name, token: token); | |
var skill = skillRes.Entities.FirstOrDefault(); | |
if (skill == null) | |
{ | |
skill = new Skill() | |
{ | |
Name = raw.Name | |
}; | |
await SkillFacade.Add(skill, token: token); | |
await PositionSkillFacade.Add(new PositionSkill() | |
{ | |
PositionId = PositionId, | |
SkillId = skill.Id, | |
}, token: token); | |
} | |
var ps = new ProjectSkill() | |
{ | |
ProjectId = ProjectId, | |
SkillId = skill.Id | |
}; | |
await Facade.Add(ps, token: token); | |
RawSkills.Remove(raw); | |
await Load.Execute().GetAwaiter(); | |
} | |
catch(Exception ex) | |
{ | |
Logger.LogError(ex, ex.Message); | |
await Alert.Handle(ex.Message).GetAwaiter(); | |
} | |
} | |
private bool isOpen; | |
public bool IsOpen | |
{ | |
get => isOpen; | |
set => this.RaiseAndSetIfChanged(ref isOpen, value); | |
} | |
public ObservableCollection<RawSkillViewModel> RawSkills { get; } = new ObservableCollection<RawSkillViewModel>(); | |
protected async Task DoExtractSkills(CancellationToken token) | |
{ | |
RawSkills.Clear(); | |
try | |
{ | |
var skills = await Enricher.ExtractSkills(Description, token); | |
if (skills?.Length > 0) | |
{ | |
var sks = skills.ToList(); | |
sks.RemoveAll(s => Entities.Any(e => string.Compare(e.Skill.Name, s, StringComparison.OrdinalIgnoreCase) == 0)); | |
RawSkills.AddRange(sks.Select(s => new RawSkillViewModel(Logger, ProjectId, s, AddRawSkill))); | |
} | |
} | |
catch (Exception ex) | |
{ | |
Logger.LogError(ex, ex.Message); | |
await Alert.Handle(ex.Message); | |
} | |
} | |
protected override async Task<ProjectSkillViewModel> Construct(ProjectSkill entity, CancellationToken token) | |
{ | |
var vm = new ProjectSkillViewModel(Logger, Facade, entity); | |
PositionId = entity.Project.PositionId; | |
return vm; | |
} | |
protected override Func<IQueryable<ProjectSkill>, IOrderedQueryable<ProjectSkill>>? OrderBy() | |
{ | |
return e => e.OrderBy(c => c.Skill.Name); | |
} | |
protected override Func<IQueryable<ProjectSkill>, IQueryable<ProjectSkill>>? PropertiesToLoad() | |
{ | |
return e => e.Include(x => x.Skill).Include(x => x.Project); | |
} | |
protected override async Task<Expression<Func<ProjectSkill, bool>>?> FilterCondition() | |
{ | |
return e => e.ProjectId == ProjectId; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment