initial commit

This commit is contained in:
Fox 2026-06-23 17:47:07 +01:00
commit 689276ea7d
102 changed files with 8740 additions and 0 deletions

482
.gitignore vendored Normal file
View File

@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# README

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /App
# Copy everything
COPY . ./
# Restore as distinct layers
RUN dotnet restore
# Build and publish a release
RUN dotnet publish -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0
VOLUME /data
WORKDIR /App
COPY --from=build /App/out .
ENTRYPOINT ["dotnet", "SolicitorsApi.dll"]

View File

@ -0,0 +1,13 @@
<Solution>
<Folder Name="/libs/">
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
<Project Path="libs/Solicitors.Data/Solicitors.Data.csproj" />
<Project Path="libs/Solicitors.HtmlParsing/Solicitors.HtmlParsing.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Solicitors.HtmlParsing.Tests/Solicitors.HtmlParsing.Tests.csproj" />
</Folder>
<Project Path="SolicitorsApi/SolicitorsApi.csproj" />
<Project Path="Tester/Tester.csproj" />
</Solution>

View File

@ -0,0 +1,176 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Mvc;
using Solicitors.CacheBuild;
using Solicitors.Core;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Data;
using Solicitors.Data.RepositorySetup.Sqlite;
using Solicitors.HtmlParsing;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddCore();
builder.Services.AddData(new SqliteDbSetupOptions("demo"));
builder.Services.AddCacheBuild();
builder.Services.AddHtmlParsing();
builder.Services.AddHttpClient();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors();
app.MapGet(
"/conveyancors",
(
[FromServices] IReadOnlySolicitorService solicitorService,
CancellationToken cancellationToken,
[FromQuery] uint pageNumber = 1,
[FromQuery] uint pageSize = 20,
[FromQuery] string? nameFilter = null,
[FromQuery] decimal minRating = 0,
[FromQuery] string[]? cities = null,
[FromQuery] string ratingsProvider = "Solicitors.com",
[FromQuery] string? orderingType = null) =>
{
IFilter<Solicitor>? filter = null;
if (!string.IsNullOrEmpty(nameFilter))
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter));
if (cities is not null && cities.Length > 0)
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
if (minRating > 0)
filter = new WrapperFilter<Solicitor>(
filter,
solicitor => solicitor.Ratings.Any(rating => (rating.Provider == ratingsProvider) && (rating.Value / rating.Maximum >= minRating / 5.0m)));
IComparer<Solicitor>? ordering = orderingType switch
{
"rating-asc" => new RatingComparer(ratingsProvider, true),
"rating-desc" => new RatingComparer(ratingsProvider, false),
"alphabet-asc" => new NameComparer(true),
"alphabet-desc" => new NameComparer(false),
_ => null
};
return solicitorService.GetSolicitorSummariesAsync(
new Pagination(pageNumber, pageSize),
ratingsProvider,
filter,
ordering,
cancellationToken: cancellationToken);
}
);
app.MapGet(
"/cities",
(
[FromServices] IReadOnlyCitiesService citiesService,
CancellationToken cancellationToken) =>
{
return citiesService.GetAllCitiesAsync(cancellationToken);
}
);
app.MapGet(
"/conveyancors/{id}",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromRoute] Guid id,
CancellationToken cancellationToken
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
);
app.MapGet(
"/ratingsProviders",
(
[FromServices] IRatingsProviderService service,
CancellationToken cancellationToken
) =>
{
return service.GetRatingsProvidersAsync(cancellationToken);
}
);
using (var scope = app.Services.CreateScope())
{
var solicitors = scope.ServiceProvider.GetRequiredService<ISolicitorService>();
var first = await solicitors
.GetSolicitorSummariesAsync(
new Pagination(1, 1),
"Solicitors.com",
cancellationToken: CancellationToken.None);
if (first.Data.Length == 0)
await scope.ServiceProvider
.GetRequiredService<ISolicitorImporter>()
.RunFullImport(CancellationToken.None);
}
app.Run();
public class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
var xRating = x?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
var yRating = y?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
decimal compare;
if (xRating is null)
compare = yRating is null ? 0 : 1;
else if (yRating is null)
compare = -1;
else
{
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
if (!ascending)
compare *= -1;
}
if (compare >= 0)
return (int)Math.Ceiling(compare);
else
return (int)Math.Floor(compare);
}
}
public class NameComparer(bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
int compare;
if (ReferenceEquals(x, y))
compare = 0;
else if (y is null)
compare = 1;
else if (x is null)
compare = -1;
else
{
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
if (!ascending)
compare *= -1;
}
return compare;
}
}

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5084",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7128;http://localhost:5084",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
<ProjectReference Include="..\libs\Solicitors.Core\Solicitors.Core.csproj" />
<ProjectReference Include="..\libs\Solicitors.Data\Solicitors.Data.csproj" />
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@SolicitorsApi_HostAddress = http://localhost:5084
GET {{SolicitorsApi_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

10
backend/Tester/Program.cs Normal file
View File

@ -0,0 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Solicitors.HtmlParsing;
var _html = File.ReadAllText("/var/home/fox/main.html");
var services = new ServiceCollection();
services.AddHtmlParsing();
var parser = services.BuildServiceProvider().GetRequiredService<IHtmlParser>();
var htmlNodes = parser.ParseHtml(_html).ToArray();
Console.ReadKey();

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
<HintPath>..\..\..\..\..\..\..\..\var\home\fox\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.CacheBuild;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddCacheBuild(this IServiceCollection services)
{
return services
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
.AddScoped<ISolicitorImporter, SolicitorImporter>();
}
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.CacheBuild;
public interface ISolicitorImporter
{
Task RunFullImport(CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Solicitors.Core.Models.Imports;
namespace Solicitors.CacheBuild;
internal interface ISolicitorParser
{
Task<SolicitorData[]> GetSolicitorsAsync(
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,29 @@
using Solicitors.Core.Data;
using Solicitors.Core.Models.Imports;
namespace Solicitors.CacheBuild;
internal class SolicitorImporter : ISolicitorImporter
{
private readonly ISolicitorParser _parser;
private readonly ISolicitorRepository _repository;
public SolicitorImporter(
ISolicitorParser parser,
ISolicitorRepository repository)
{
_parser = parser;
_repository = repository;
}
public async Task RunFullImport(CancellationToken cancellationToken)
{
foreach (var solicitor in await _parser.GetSolicitorsAsync(cancellationToken))
{
await RunImport(solicitor, cancellationToken);
}
}
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
=> _repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
<ProjectReference Include="..\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,14 @@
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal static class Consts
{
public const string BaseUrl = "https://www.solicitors.com";
public static readonly string[] Locations =
[
"london", "birmingham", "leeds", "manchester", "sheffield", "bradford", "liverpool", "bristol"
];
public const decimal MaxRating = 5.0m;
public const string Name = "Solicitors.com";
public const string IconPath = "/images/logo.svg";
}

View File

@ -0,0 +1,31 @@
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal class SolicitorBuilder
{
private readonly HashSet<string> _baseLocations = [];
public SolicitorBuilder(
string name,
string path,
string? shortDesc,
string baseLocation)
{
Name = name;
ShortDescription = shortDesc;
_baseLocations.Add(baseLocation);
Path = path;
}
public void AddBaseLocation(string baseLocation)
{
_baseLocations.Add(baseLocation);
}
public string Path { get; }
public string Name { get; }
public string? ShortDescription { get; }
public string[] BaseLocations => _baseLocations.ToArray();
}

View File

@ -0,0 +1,316 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Solicitors.Core.Misc;
using Solicitors.Core.Models.Imports;
using Solicitors.HtmlParsing;
using Solicitors.HtmlParsing.Models;
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal class SolicitorParser : ISolicitorParser
{
private readonly HttpClient _client;
private readonly IHtmlParser _htmlParser;
public SolicitorParser(
HttpClient client,
IHtmlParser htmlParser)
{
_client = client;
_client.BaseAddress = new Uri(Consts.BaseUrl);
_htmlParser = htmlParser;
}
public async Task<SolicitorData[]> GetSolicitorsAsync(
CancellationToken cancellationToken = default)
{
var builders = new Dictionary<string, SolicitorBuilder>();
foreach (var location in Consts.Locations)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/conveyancing+{location}.html");
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
var nodes = _htmlParser.ParseHtml(html);
IHtmlNode? mainNode = null;
foreach (var node in nodes)
if (node.TryGetByTagName("main", out mainNode))
break;
if (TryGetResultSection(mainNode, out var results))
ParseResultsSection(results, location, builders);
}
var batches = builders.Values
.Select(builder => GetFullSolicitorDataAsync(builder, cancellationToken))
.Batch(5);
var solicitors = new List<SolicitorData?>();
foreach (var batch in batches)
solicitors.AddRange(await Task.WhenAll(batch));
return solicitors
.Where(x => x is not null)
.Cast<SolicitorData>()
.ToArray();
}
private bool TryGetResultSection(
IHtmlNode? mainNode,
[NotNullWhen(true)] out IEnumerable<IHtmlNode>? results)
{
results = null;
// going one level at a time helps a little with performance here, at the cost of messier code
return mainNode is not null
&& mainNode.TryGetByTagNameAndClass("div", "content-holder", out var contentHolderNode)
&& contentHolderNode.TryGetByTagNameAndClass("div", "container", out var containerNode)
&& containerNode.TryGetByTagNameAndClass("div", "content", out var contentNode)
&& contentNode.TryGetByTagNameAndClass("div", "result-section", out var resultSectionNode)
&& resultSectionNode.TryGetChildren(out results);
}
private void ParseResultsSection(
IEnumerable<IHtmlNode> results,
string location,
Dictionary<string, SolicitorBuilder> builders)
{
foreach (var result in results)
{
if (result.TryGetByClass("result-item", out var resultItem, true)
&& TryParseToNewBuilder(resultItem, location, out var builder))
{
if (!builders.TryAdd(builder.Path, builder))
builders[builder.Path].AddBaseLocation(location);
}
}
}
private bool TryParseToNewBuilder(
IHtmlNode resultItemNode,
string baseLocation,
[NotNullWhen(true)] out SolicitorBuilder? builder)
{
builder = null;
if (!resultItemNode.TryGetByTagNameAndClass("a", "link-map", out var linkMapNode)
|| !linkMapNode.TryGetAttributeValue("href", out var uniqueUrl)
|| !resultItemNode.TryGetByTagNameAndClass("span", "h2", out var titleNode)
|| !titleNode.TryGetChildren(out var titleChildren))
return false;
string? name = null;
foreach (var child in titleChildren)
if (child.TryGetText(out name))
break;
if (name is null)
return false;
string? shortDesc = null;
if (resultItemNode.TryGetByTagName("p", out var pNode)
&& pNode.TryGetChildren(out var pChildren))
foreach (var child in pChildren)
if (child.TryGetText(out shortDesc))
break;
builder = new SolicitorBuilder(name, uniqueUrl, shortDesc, baseLocation);
return true;
}
private async Task<SolicitorData?> GetFullSolicitorDataAsync(
SolicitorBuilder builder,
CancellationToken cancellationToken)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, builder.Path);
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
var nodes = _htmlParser.ParseHtml(html);
IHtmlNode? mainNode = null;
foreach (var node in nodes)
if (node.TryGetByTagName("main", out mainNode))
break;
if (mainNode is null || !mainNode.TryGetByTagNameAndClass("div", "content-block", out var contentBlockNode))
return null;
Location[] offices = [];
if (contentBlockNode.TryGetByTagNameAndClass("div", "office-item", out var officeItemNode)
&& officeItemNode.TryGetChildren(out var officeItems))
{
offices = ParseOffices(officeItems);
}
Rating[] ratings = [];
string? phone = null;
string? email = null;
string? website = null;
if (mainNode.TryGetByTagNameAndClass("div", "side-holder", out var sidebarNode)
&& sidebarNode.TryGetChildren(out var sidebarChildren))
{
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
}
return new SolicitorData()
{
Name = builder.Name,
UrlPath = builder.Path,
ShortDescription = builder.ShortDescription,
Phone = phone,
Email = email,
Website = website,
Ratings = ratings,
Offices = offices,
Cities = builder.BaseLocations
};
}
Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
=> officeNodes
.Select(ParseOffice)
.Where(x => x is not null)
.Cast<Location>()
.ToArray();
Location? ParseOffice(IHtmlNode officeNode)
{
if (!officeNode.TryGetChildren(out var officeChildren)
|| !officeNode.TryGetByTagName("address", out var addressNode)
|| !addressNode.TryGetChildren(out var addressLines))
return null;
Location? location = null;
string? address = "";
foreach (var line in addressLines)
{
if (line.TryGetText(out var lineText))
address += lineText;
else if (line.TryGetByTagName("br", out _, true))
address += "\n";
}
if (string.IsNullOrWhiteSpace(address))
address = null;
string? phone = null;
foreach (var child in officeChildren)
{
if (child.TryGetByTagName("a", out var anchor)
&& anchor.TryGetAttributeValue("href", out var telString)
&& telString.StartsWith("\"tel:")
&& telString.Length > 5)
{
phone = telString.Substring(5, telString.Length - 6);
break;
}
}
if (address is not null && phone is not null)
location = new Location(address, phone, []);
return location;
}
void ParseSidebar(
IEnumerable<IHtmlNode> sidebarChildren,
out string? phone,
out string? email,
out string? website,
out Rating[] ratings)
{
List<Rating> ratingsList = [];
phone = null;
website = null;
email = null;
foreach (var node in sidebarChildren)
{
var isRevCount = node.TryGetByTagNameAndClass("div", "rev-count", out _, true);
var isLinksHolder = node.TryGetByTagNameAndClass("div", "links-holder", out _, true);
if ((!isRevCount && !isLinksHolder)
|| !node.TryGetChildren(out var children))
continue;
if (isLinksHolder)
{
if (node.TryGetByTagNameAndClass("a", "website", out var websiteNode)
&& websiteNode.TryGetAttributeValue("href", out website))
website = website.Trim('"');
if (node.TryGetByTagNameAndClass("a", "phone", out var phoneNode)
&& phoneNode.TryGetAttributeValue("href", out var phoneStr)
&& phoneStr.StartsWith("\"tel:")
&& phoneStr.Length > 5)
{
phone = phoneStr.Substring(5, phoneStr.Length - 6);
}
}
var childrenArray = children as IHtmlNode[] ?? children.ToArray();
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
{
if (child.TryGetAttributeValue("title", out var ratingString)
&& TryParseRatingString(ratingString, out decimal rating, out decimal maxRating)
&& child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode)
&& imgNode.TryGetAttributeValue("src", out var imgSrc)
&& TryParseRatingSrc(imgSrc, out var ratingProvider))
{
ratingsList.Add(new Rating(rating, maxRating, ratingProvider, imgSrc));
}
}
if (ratingsList.Count == 0)
{
foreach (var child in childrenArray.Where(x => !x.HasAttribute("class")))
{
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
if (grandChild.TryGetText(out var textContent)
&& TryParseOwnRatingString(textContent, out var rating))
ratingsList.Add(new Rating(rating, Consts.MaxRating, Consts.Name, Consts.IconPath));
}
}
}
ratings = ratingsList.ToArray();
}
bool TryParseRatingString(string ratingString, out decimal rating, out decimal maxRating)
{
rating = 0;
maxRating = 0;
var parts = ratingString.Trim('"').Split(" / ");
if (parts.Length != 2)
return false;
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
}
bool TryParseRatingSrc(string imgSrc, out string provider)
{
provider = "";
imgSrc = imgSrc.Trim('"');
var prefix = "/images/logo-";
if (!imgSrc.StartsWith(prefix))
return false;
var providerAndExtension = imgSrc.Substring(prefix.Length);
provider = providerAndExtension.Split('.').First();
return true;
}
bool TryParseOwnRatingString(string ratingString, out decimal rating)
{
rating = 0;
var prefix = "Average review score : ";
if (!ratingString.StartsWith(prefix))
return false;
var ratingPart = ratingString.Substring(prefix.Length);
return decimal.TryParse(ratingPart, out rating);
}
}

View File

@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.Core;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddCore(this IServiceCollection services)
{
return services
.AddScoped<ISolicitorService, SolicitorService>()
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
.AddScoped<IRatingsProviderService, RatingsProviderService>();
}
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Core.Data;
public interface IRatingsProviderRepository
{
IAsyncEnumerable<string> GetAllRatingsProvidersAsync();
}

View File

@ -0,0 +1,14 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Data;
public interface IReadOnlySolicitorRepository
{
IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync();
Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default);
}
public interface IReadOnlyCitiesRepository
{
IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken);
}

View File

@ -0,0 +1,8 @@
using Solicitors.Core.Models.Imports;
namespace Solicitors.Core.Data;
public interface ISolicitorRepository : IReadOnlySolicitorRepository
{
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Core;
public interface IRatingsProviderService
{
Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken);
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Core;
public interface IReadOnlyCitiesService
{
Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken);
}

View File

@ -0,0 +1,16 @@
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
namespace Solicitors.Core;
public interface IReadOnlySolicitorService
{
Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
Pagination pagination,
string ratingsProvider,
IFilter<Solicitor>? filter = null,
IComparer<Solicitor>? ordering = null,
CancellationToken cancellationToken = default);
Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Core;
public interface ISolicitorService : IReadOnlySolicitorService
{
}

View File

@ -0,0 +1,23 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Misc;
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
{
public static IComparer<Solicitor> Instance { get; } = new DefaultSolicitorDataOrdering();
private DefaultSolicitorDataOrdering()
{
}
public int Compare(Solicitor? x, Solicitor? y)
{
if (x is null)
return y is null ? 0 : int.MaxValue;
else if (y is null)
return int.MinValue;
return Comparer<Guid>.Default.Compare(x.SolicitorId, y.SolicitorId);
}
}

View File

@ -0,0 +1,21 @@
namespace Solicitors.Core.Misc;
public static class EnumerableExtensions
{
public static IEnumerable<T[]> Batch<T>(this IEnumerable<T> source, int batchSize)
{
var batch = new List<T>(batchSize);
foreach (var item in source)
{
batch.Add(item);
if (batch.Count == batch.Capacity)
{
yield return batch.ToArray();
batch.Clear();
}
}
if (batch.Count > 0)
yield return batch.ToArray();
}
}

View File

@ -0,0 +1,29 @@
namespace Solicitors.Core.Misc;
public interface IFilter<T>
{
bool Filter(T item);
}
public class WrapperFilter<T> : IFilter<T>
{
private readonly Func<T, bool> _filter;
private readonly IFilter<T>? _inner;
public WrapperFilter(
IFilter<T>? inner,
Func<T, bool> filter)
{
_inner = inner;
_filter = filter;
}
public bool Filter(T item)
{
if (_inner is not null)
return _inner.Filter(item) && _filter(item);
return _filter(item);
}
}

View File

@ -0,0 +1,7 @@
namespace Solicitors.Core.Misc;
public readonly struct Pagination(uint currentPage, uint pageSize)
{
public uint CurrentPage { get; } = currentPage;
public uint PageSize { get; } = pageSize;
}

View File

@ -0,0 +1,9 @@
namespace Solicitors.Core.Models;
public record City
{
public Guid CityId { get; set; }
public required string Name { get; set; }
public List<Solicitor> Solicitors { get; } = new();
}

View File

@ -0,0 +1,8 @@
namespace Solicitors.Core.Models.Imports;
public record Location(string address, string phone, Rating[] locationRatings)
{
public string Address { get; } = address;
public string Phone { get; } = phone;
public Rating[] LocationRatings { get; } = locationRatings;
}

View File

@ -0,0 +1,9 @@
namespace Solicitors.Core.Models.Imports;
public record Rating(decimal value, decimal maxValue, string provider, string imgSrc)
{
public decimal Value { get; } = value;
public decimal MaxValue { get; } = maxValue;
public string RatingProvider { get; } = provider;
public string RatingProviderImgSrc { get; } = imgSrc;
}

View File

@ -0,0 +1,16 @@
namespace Solicitors.Core.Models.Imports;
public class SolicitorData
{
public required string Name { get; init; }
public required string UrlPath { get; init; }
public string? ShortDescription { get; init; }
public string? Phone { get; init; }
public string? Email { get; init; }
public string? Website { get; init; }
public string[] Cities { get; init; } = [];
public Rating[] Ratings { get; init; } = [];
public Location[] Offices { get; init; } = [];
}

View File

@ -0,0 +1,18 @@
namespace Solicitors.Core.Models;
public record LocationInfo
{
public LocationInfo(SolicitorLocation location)
{
Address = location.Address;
Phone = location.Phone;
Ratings = location.LocationRatings
.Select(rating => new RatingInfo(rating))
.ToArray();
}
public string Address { get; }
public string Phone { get; }
public RatingInfo[] Ratings { get; }
}

View File

@ -0,0 +1,15 @@
namespace Solicitors.Core.Models;
public record RatingInfo
{
public RatingInfo(IRating rating)
{
Value = rating.Value;
Maximum = rating.Maximum;
Provider = rating.Provider;
}
public decimal Value { get; }
public decimal Maximum { get; }
public string Provider { get; }
}

View File

@ -0,0 +1,18 @@
namespace Solicitors.Core.Models;
public record Solicitor
{
public Guid SolicitorId { get; set; }
public required string Name { get; set; }
public required string RelativeUrl { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? ShortDescription { get; set; }
public List<City> Cities { get; } = new();
public List<SolicitorRating> Ratings { get; } = new();
public List<SolicitorLocation> Locations { get; } = new();
}

View File

@ -0,0 +1,38 @@
namespace Solicitors.Core.Models;
public record SolicitorInfo
{
internal SolicitorInfo(Solicitor solicitor)
{
Id = solicitor.SolicitorId;
Name = solicitor.Name;
ShortDescription = solicitor.ShortDescription;
Phone = solicitor.Phone;
Email = solicitor.Email;
Website = solicitor.Website;
Cities = solicitor.Cities
.Select(city => city.Name)
.ToArray();
Ratings = solicitor.Ratings
.Select(rating => new RatingInfo(rating))
.ToArray();
Locations = solicitor.Locations
.Select(location => new LocationInfo(location))
.ToArray();
}
public string Name { get; set; }
public string ShortDescription { get; set; }
public Guid Id { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string[] Cities { get; set; }
public RatingInfo[] Ratings { get; set; }
public LocationInfo[] Locations { get; set; }
}

View File

@ -0,0 +1,21 @@
namespace Solicitors.Core.Models;
public record SolicitorLocation
{
public Guid SolicitorLocationId { get; set; }
public required string Address { get; set; }
public required string Phone { get; set; }
public List<SolicitorLocationRating> LocationRatings { get; } = new();
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }
}
public interface IRating
{
decimal Value { get; }
decimal Maximum { get; }
string Provider { get; }
}

View File

@ -0,0 +1,13 @@
namespace Solicitors.Core.Models;
public record SolicitorLocationRating : IRating
{
public Guid SolicitorLocationRatingId { get; set; }
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public Guid SolicitorLocationId { get; set; }
public required SolicitorLocation SolicitorLocation { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace Solicitors.Core.Models;
public record SolicitorRating : IRating
{
public Guid SolicitorRatingId { get; set; }
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace Solicitors.Core.Models;
public record SolicitorSummary
{
internal SolicitorSummary(Solicitor solicitor, string ratingsProvider)
{
Id = solicitor.SolicitorId;
Name = solicitor.Name;
ShortDescription = solicitor.ShortDescription;
var rating = solicitor.Ratings.FirstOrDefault(x => x.Provider == ratingsProvider);
if (rating is not null)
Rating = new RatingInfo(rating);
}
public Guid Id { get; }
public string Name { get; }
public string? ShortDescription { get; }
public RatingInfo? Rating { get; }
}

View File

@ -0,0 +1,7 @@
namespace Solicitors.Core;
public record PaginationResponse<T>(int Total, T[] Data)
{
public T[] Data { get; set; } = Data;
public int Total { get; set; } = Total;
}

View File

@ -0,0 +1,18 @@
using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class RatingsProviderService : IRatingsProviderService
{
private readonly IRatingsProviderRepository _repo;
public RatingsProviderService(IRatingsProviderRepository repo)
{
_repo = repo;
}
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
{
return await _repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
}
}

View File

@ -0,0 +1,19 @@
using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class ReadOnlyCitiesService : IReadOnlyCitiesService
{
private readonly IReadOnlyCitiesRepository _repo;
public ReadOnlyCitiesService(IReadOnlyCitiesRepository repo)
{
_repo = repo;
}
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
{
var cities = _repo.GetCitiesAsync(cancellationToken);
return await cities.ToArrayAsync(cancellationToken);
}
}

View File

@ -0,0 +1,50 @@
using Solicitors.Core.Data;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
namespace Solicitors.Core;
internal class SolicitorService : ISolicitorService
{
private readonly IReadOnlySolicitorRepository _repo;
public SolicitorService(IReadOnlySolicitorRepository repo)
{
_repo = repo;
}
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
Pagination pagination,
string ratingsProvider,
IFilter<Solicitor>? filter = null,
IComparer<Solicitor>? ordering = null,
CancellationToken cancellationToken = default)
{
var allSolicitors = _repo.GetAllSolicitorsAsync();
if (filter is not null)
allSolicitors = allSolicitors.Where(filter.Filter);
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
if (ordering is null)
ordering = DefaultSolicitorDataOrdering.Instance;
var results = matchingSolicitors
.OrderBy(x => x, ordering)
.Skip((int)((pagination.CurrentPage - 1) * pagination.PageSize))
.Take((int)pagination.PageSize)
.Select(x => new SolicitorSummary(x, ratingsProvider))
.ToArray();
return new PaginationResponse<SolicitorSummary>(matchingSolicitors.Length, results);
}
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var data = await _repo.GetSolicitorByIdAsync(id, cancellationToken);
if (data is null)
return null;
return new SolicitorInfo(data);
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,36 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Solicitors.Core;
using Solicitors.Core.Data;
using Solicitors.Data.RepositorySetup;
using Solicitors.Data.RepositorySetup.InMemory;
using Solicitors.Data.RepositorySetup.Sqlite;
namespace Solicitors.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddData(this IServiceCollection services, IRepoSetupOptions options)
{
return services
.AddScoped<SolicitorsRepository>()
.AddScoped<ISolicitorRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddSingleton(GetSetupService(options));
}
private static IRepoSetupService GetSetupService(IRepoSetupOptions options)
{
if (options is InMemoryDbSetupOptions inMemOptions)
return new InMemorySetupService(inMemOptions);
if (options is SqliteDbSetupOptions sqliteOptions)
return new SqliteSetupService(sqliteOptions);
throw new ArgumentException("Options type not supported", nameof(options));
}
}

View File

@ -0,0 +1,222 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Solicitors.Data;
#nullable disable
namespace Solicitors.Data.Migrations
{
[DbContext(typeof(SolicitorsRepository))]
[Migration("20260622195820_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("CitySolicitor", b =>
{
b.Property<Guid>("CitiesCityId")
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorsSolicitorId")
.HasColumnType("TEXT");
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
b.HasIndex("SolicitorsSolicitorId");
b.ToTable("CitySolicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
{
b.Property<Guid>("CityId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CityId");
b.ToTable("Cities");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Property<Guid>("SolicitorId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasColumnType("TEXT");
b.Property<string>("RelativeUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ShortDescription")
.HasColumnType("TEXT");
b.Property<string>("Website")
.HasColumnType("TEXT");
b.HasKey("SolicitorId");
b.ToTable("Solicitors");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Property<Guid>("SolicitorLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorLocations");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.Property<Guid>("SolicitorLocationRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorLocationId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationRatingId");
b.HasIndex("SolicitorLocationId");
b.ToTable("SolicitorLocationRatings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.Property<Guid>("SolicitorRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorRatingId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorRatings");
});
modelBuilder.Entity("CitySolicitor", b =>
{
b.HasOne("Solicitors.Core.Models.City", null)
.WithMany()
.HasForeignKey("CitiesCityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Solicitors.Core.Models.Solicitor", null)
.WithMany()
.HasForeignKey("SolicitorsSolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Locations")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
.WithMany("LocationRatings")
.HasForeignKey("SolicitorLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SolicitorLocation");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Ratings")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Navigation("Locations");
b.Navigation("Ratings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Navigation("LocationRatings");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,172 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Solicitors.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Cities",
columns: table => new
{
CityId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cities", x => x.CityId);
});
migrationBuilder.CreateTable(
name: "Solicitors",
columns: table => new
{
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
RelativeUrl = table.Column<string>(type: "TEXT", nullable: false),
Phone = table.Column<string>(type: "TEXT", nullable: true),
Email = table.Column<string>(type: "TEXT", nullable: true),
Website = table.Column<string>(type: "TEXT", nullable: true),
ShortDescription = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Solicitors", x => x.SolicitorId);
});
migrationBuilder.CreateTable(
name: "CitySolicitor",
columns: table => new
{
CitiesCityId = table.Column<Guid>(type: "TEXT", nullable: false),
SolicitorsSolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CitySolicitor", x => new { x.CitiesCityId, x.SolicitorsSolicitorId });
table.ForeignKey(
name: "FK_CitySolicitor_Cities_CitiesCityId",
column: x => x.CitiesCityId,
principalTable: "Cities",
principalColumn: "CityId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CitySolicitor_Solicitors_SolicitorsSolicitorId",
column: x => x.SolicitorsSolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorLocations",
columns: table => new
{
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false),
Address = table.Column<string>(type: "TEXT", nullable: false),
Phone = table.Column<string>(type: "TEXT", nullable: false),
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorLocations", x => x.SolicitorLocationId);
table.ForeignKey(
name: "FK_SolicitorLocations_Solicitors_SolicitorId",
column: x => x.SolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorRatings",
columns: table => new
{
SolicitorRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: false),
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
Provider = table.Column<string>(type: "TEXT", nullable: false),
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorRatings", x => x.SolicitorRatingId);
table.ForeignKey(
name: "FK_SolicitorRatings_Solicitors_SolicitorId",
column: x => x.SolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorLocationRatings",
columns: table => new
{
SolicitorLocationRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: false),
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
Provider = table.Column<string>(type: "TEXT", nullable: false),
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorLocationRatings", x => x.SolicitorLocationRatingId);
table.ForeignKey(
name: "FK_SolicitorLocationRatings_SolicitorLocations_SolicitorLocationId",
column: x => x.SolicitorLocationId,
principalTable: "SolicitorLocations",
principalColumn: "SolicitorLocationId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CitySolicitor_SolicitorsSolicitorId",
table: "CitySolicitor",
column: "SolicitorsSolicitorId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorLocationRatings_SolicitorLocationId",
table: "SolicitorLocationRatings",
column: "SolicitorLocationId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorLocations_SolicitorId",
table: "SolicitorLocations",
column: "SolicitorId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorRatings_SolicitorId",
table: "SolicitorRatings",
column: "SolicitorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CitySolicitor");
migrationBuilder.DropTable(
name: "SolicitorLocationRatings");
migrationBuilder.DropTable(
name: "SolicitorRatings");
migrationBuilder.DropTable(
name: "Cities");
migrationBuilder.DropTable(
name: "SolicitorLocations");
migrationBuilder.DropTable(
name: "Solicitors");
}
}
}

View File

@ -0,0 +1,219 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Solicitors.Data;
#nullable disable
namespace Solicitors.Data.Migrations
{
[DbContext(typeof(SolicitorsRepository))]
partial class SolicitorsRepositoryModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("CitySolicitor", b =>
{
b.Property<Guid>("CitiesCityId")
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorsSolicitorId")
.HasColumnType("TEXT");
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
b.HasIndex("SolicitorsSolicitorId");
b.ToTable("CitySolicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
{
b.Property<Guid>("CityId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CityId");
b.ToTable("Cities");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Property<Guid>("SolicitorId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasColumnType("TEXT");
b.Property<string>("RelativeUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ShortDescription")
.HasColumnType("TEXT");
b.Property<string>("Website")
.HasColumnType("TEXT");
b.HasKey("SolicitorId");
b.ToTable("Solicitors");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Property<Guid>("SolicitorLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorLocations");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.Property<Guid>("SolicitorLocationRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorLocationId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationRatingId");
b.HasIndex("SolicitorLocationId");
b.ToTable("SolicitorLocationRatings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.Property<Guid>("SolicitorRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorRatingId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorRatings");
});
modelBuilder.Entity("CitySolicitor", b =>
{
b.HasOne("Solicitors.Core.Models.City", null)
.WithMany()
.HasForeignKey("CitiesCityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Solicitors.Core.Models.Solicitor", null)
.WithMany()
.HasForeignKey("SolicitorsSolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Locations")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
.WithMany("LocationRatings")
.HasForeignKey("SolicitorLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SolicitorLocation");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Ratings")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Navigation("Locations");
b.Navigation("Ratings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Navigation("LocationRatings");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Data.RepositorySetup;
public interface IRepoSetupOptions
{
}

View File

@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup;
internal interface IRepoSetupService
{
void OnConfiguring(DbContextOptionsBuilder optionsBuilder);
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Data.RepositorySetup.InMemory;
public class InMemoryDbSetupOptions(string dbName) : IRepoSetupOptions
{
public string DbName { get; } = dbName;
}

View File

@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup.InMemory;
internal class InMemorySetupService(InMemoryDbSetupOptions config) : IRepoSetupService
{
public void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseInMemoryDatabase(config.DbName);
}
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.Data.RepositorySetup.Sqlite;
public class SqliteDbSetupOptions(string dbName) : IRepoSetupOptions
{
public string DbName { get; } = dbName;
}

View File

@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup.Sqlite;
internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupService
{
public void OnConfiguring(DbContextOptionsBuilder options)
{
var folder = Environment.SpecialFolder.MyDocuments;
var path = Environment.GetFolderPath(folder);
path = Path.Combine(path, "/data");
var dbPath = System.IO.Path.Join(path, $"{config.DbName}.db");
var dbString = $"Data Source={dbPath}";
options.UseSqlite(dbString);
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,157 @@
using Microsoft.EntityFrameworkCore;
using Solicitors.Core;
using Solicitors.Core.Data;
using Solicitors.Core.Models;
using Solicitors.Core.Models.Imports;
using Solicitors.Data.RepositorySetup;
namespace Solicitors.Data;
internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
{
private readonly IRepoSetupService _setup;
public SolicitorsRepository(IRepoSetupService setup)
{
_setup = setup;
}
public DbSet<Solicitor> Solicitors { get; set; }
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
public DbSet<SolicitorLocationRating> SolicitorLocationRatings { get; set; }
public DbSet<City> Cities { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> _setup.OnConfiguring(optionsBuilder);
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
=> Solicitors
.Include(solicitor => solicitor.Cities)
.Include(solicitor => solicitor.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(solicitor => solicitor.Ratings)
.AsAsyncEnumerable();
public Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> Solicitors
.Include(solicitor => solicitor.Cities)
.Include(solicitor => solicitor.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(solicitor => solicitor.Ratings)
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
{
var matchingSolicitor = await Solicitors
.FirstOrDefaultAsync(
item => item.Name == solicitor.Name,
cancellationToken: cancellationToken);
if (matchingSolicitor is null)
await AddNewSolicitor(solicitor, cancellationToken);
else
await UpdateExistingSolicitor(solicitor, matchingSolicitor, cancellationToken);
}
private async Task AddNewSolicitor(
SolicitorData solicitor,
CancellationToken cancellationToken)
{
var newSol = new Solicitor()
{
Name = solicitor.Name,
RelativeUrl = solicitor.UrlPath,
Phone = solicitor.Phone,
Email = solicitor.Email,
Website = solicitor.Website,
ShortDescription = solicitor.ShortDescription
};
Solicitors.Add(newSol);
var cityTasks = solicitor.Cities
.Distinct()
.Select(async (city) =>
{
var matchingCity = await Cities.FirstOrDefaultAsync(
item => item.Name == city,
cancellationToken: cancellationToken);
if (matchingCity is null)
{
matchingCity = new City() { Name = city };
Cities.Add(matchingCity);
newSol.Cities.Add(matchingCity);
}
matchingCity.Solicitors.Add(newSol);
});
await Task.WhenAll(cityTasks);
foreach (var rating in solicitor.Ratings)
{
var newRating = new SolicitorRating()
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
Solicitor = newSol
};
SolicitorRatings.Add(newRating);
newSol.Ratings.Add(newRating);
}
foreach (var location in solicitor.Offices)
{
var newLocation = new SolicitorLocation()
{
Address = location.Address,
Phone = location.Phone,
Solicitor = newSol
};
SolicitorLocations.Add(newLocation);
newSol.Locations.Add(newLocation);
foreach (var rating in location.LocationRatings)
{
var newLocRating = new SolicitorLocationRating()
{
Value = rating.value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
SolicitorLocation = newLocation
};
SolicitorLocationRatings.Add(newLocRating);
newLocation.LocationRatings.Add(newLocRating);
}
}
await SaveChangesAsync(cancellationToken);
}
private Task UpdateExistingSolicitor(
SolicitorData solicitor,
Solicitor matchingSolicitor,
CancellationToken cancellationToken)
{
// TODO
return Task.CompletedTask;
}
public IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken)
{
return Cities
.Select(city => city.Name)
.AsAsyncEnumerable();
}
public IAsyncEnumerable<string> GetAllRatingsProvidersAsync()
{
return SolicitorRatings
.Select(rating => rating.Provider)
.Distinct()
.OrderBy(x => x == "Solicitors.com" ? 0 : 1)
.ThenBy(x => x)
.AsAsyncEnumerable();
}
}

View File

@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.HtmlParsing;
public static class DIExtensions
{
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)
{
return services.AddSingleton<IHtmlParser, HtmlParser>();
}
}

View File

@ -0,0 +1,130 @@
namespace Solicitors.HtmlParsing;
using Models;
internal class HtmlParser : IHtmlParser
{
private readonly string[] _voidElements =
[
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr"
];
public IEnumerable<IHtmlNode> ParseHtml(string html)
{
// wanted to do this in one pass but was quite time-consuming
// so taking a little lesson from compiler development
// and lex the text into tokens before parsing :)
// deferred execution might help it still be "one pass"?
var stringEnumerator = html.GetEnumerator();
var tokens = LexHtml(stringEnumerator);
var tokenEnumerator = tokens.GetEnumerator();
return ParseHtml(tokenEnumerator);
}
private IEnumerable<string> LexHtml(IEnumerator<char> content)
{
var work = "";
while (content.MoveNext())
{
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
work += content.Current;
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
}
}
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
{
while (tokens.MoveNext())
{
var token = tokens.Current;
if (!token.StartsWith('<'))
{
yield return new StringNode(token);
continue;
}
var noBraces = token.Substring(1, token.Length - 2);
var split = noBraces.Split(' ');
IHtmlAttribute[] attributes = [];
if (split.Length > 1)
{
attributes = ParseAttributes(noBraces).ToArray();
}
if (IsVoidOrSelfClosingElement(token))
yield return new HtmlNode(split.First(), attributes);
else if (token.StartsWith("</"))
yield break;
else
{
var children = ParseHtml(tokens).ToArray();
yield return new ParentHtmlNode(split.First(), attributes, children);
}
}
}
bool IsVoidOrSelfClosingElement(string token)
{
if (token.StartsWith("<!") || token.EndsWith("/>"))
return true;
var tagName = token.Split(' ').First().Substring(1);
if (tagName.EndsWith('>'))
tagName = tagName.Substring(0, tagName.Length - 1);
return _voidElements.Contains(tagName);
}
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
{
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
var attString = "";
bool inQuote = false;
foreach (char c in attributesString)
{
if (!inQuote && c == ' ')
{
yield return ParseAttribute(attString);
attString = "";
}
else
{
attString += c;
if (c == '\"')
inQuote = !inQuote;
}
}
yield return ParseAttribute(attString);
}
private IHtmlAttribute ParseAttribute(string attString)
{
var split = attString.Split('=');
if (split.Length > 1)
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
else
return new HtmlAttribute(attString);
}
}

View File

@ -0,0 +1,8 @@
namespace Solicitors.HtmlParsing;
using Models;
public interface IHtmlParser
{
IEnumerable<IHtmlNode> ParseHtml(string html);
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlAttribute(string name) : IHtmlAttribute
{
public string Name { get; } = name;
}

View File

@ -0,0 +1,95 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
: IHtmlNode
{
private readonly IHtmlAttribute[] _attributes = attributes;
private readonly string _tagName = tagName;
public virtual bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == _tagName)
node = this;
return node is not null;
}
private string[] Classes
{
get
{
var classAttribute = _attributes
.Where(a => a is HtmlValueAttribute)
.Select(a => (a as HtmlValueAttribute)!)
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
return classAttribute is not null
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
: [];
}
}
public virtual bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == _tagName && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
{
text = null;
return false;
}
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName)
{
return _attributes
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
}
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
var matchingAttribute = _attributes.FirstOrDefault(
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
);
if (matchingAttribute is HtmlValueAttribute valueAttribute)
attributeValue = valueAttribute.Value;
return attributeValue is not null;
}
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
{
public string Value { get; } = value;
}

View File

@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal interface IHtmlAttribute
{
string Name { get; }
}

View File

@ -0,0 +1,64 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
public interface IHtmlNode
{
/// <summary>
/// Searches this node and its direct children for a matching tag name.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="node">If found, the node that matches the tag name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching class.
/// </summary>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the tag name and class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Checks this node (and *not* its children) to see if it is text content.
/// </summary>
/// <param name="text">If found, the text content.</param>
/// <returns>True if the node is a text node, false otherwise.</returns>
bool TryGetText([NotNullWhen(true)] out string? text);
/// <summary>
/// Checks this node to see if it has child elements.
/// </summary>
/// <param name="children">If found, the child elements.</param>
/// <returns>True if the node has child elements, false otherwise.</returns>
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
bool HasAttribute(string attributeName);
bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue);
}

View File

@ -0,0 +1,94 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
: HtmlNode(tagName, attributes)
{
private readonly IHtmlNode[] _children = children;
public override bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagName(tagName, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByClass(className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = _children;
return _children.Length != 0;
}
}

View File

@ -0,0 +1,59 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class StringNode(string content)
: IHtmlNode
{
private readonly string _content = content;
public bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetText([NotNullWhen(true)] out string? text)
{
text = _content;
return !string.IsNullOrEmpty(text);
}
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName) => false;
public bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
return false;
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
</Project>

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

Binary file not shown.

8
frontend/.yarnrc.yml Normal file
View File

@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0

15
frontend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:26.3.1-alpine3.24 as build
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN mkdir -p /code
WORKDIR /code
ADD . /code
RUN npm install -g -s --no-progress corepack
RUN COREPACK_ENABLE_DOWNLOAD_PROMPT=0 /bin/sh -c "yarn -v"
RUN yarn install
RUN yarn run build
FROM httpd:2.4.68-alpine3.24
COPY --from=build /code/dist /usr/local/apache2/htdocs/

15
frontend/README.md Normal file
View File

@ -0,0 +1,15 @@
# `create-preact`
<h2 align="center">
<img height="256" width="256" src="./src/assets/preact.svg">
</h2>
<h3 align="center">Get started using Preact and Vite!</h3>
## Getting Started
- `yarn dev` - Starts a dev server at http://localhost:5173/
- `yarn build` - Builds for production, emitting to `dist/`
- `yarn preview` - Starts a server at http://localhost:4173/ to test production build locally

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=roboto:300,400,500,700" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Vite + Preact</title>
<style>body { margin: 0; max-height: 100vh; overflow: hidden; }</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

31
frontend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@babel/core": "^8.0.1",
"@base-ui/react": "^1.6.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.10",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"axios": "^1.18.1",
"preact": "^10.26.9"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"eslint": "^10.5.0",
"eslint-config-preact": "^2.0.0",
"typescript": "^6.0.3",
"vite": "^7.0.4"
},
"eslintConfig": {
"extends": "preact"
},
"packageManager": "yarn@4.17.0"
}

15
frontend/public/vite.svg Normal file
View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257">
<defs>
<linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%">
<stop offset="0%" stop-color="#41D1FF"></stop>
<stop offset="100%" stop-color="#BD34FE"></stop>
</linearGradient>
<linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%">
<stop offset="0%" stop-color="#FFEA83"></stop>
<stop offset="8.333%" stop-color="#FFDD35"></stop>
<stop offset="100%" stop-color="#FFA800"></stop>
</linearGradient>
</defs>
<path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path>
<path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296">
<path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path>
<path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path>
<path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path>
<path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,137 @@
import Box from "@mui/material/Box";
import Modal from "@mui/material/Modal";
import { forwardRef, useEffect, useState } from "react";
import { SolicitorInfo } from "../models/solicitorSummary";
import getSolicitor from "../functions/getSolicitor";
import Card from "@mui/material/Card";
import { Grid, Rating, Typography } from "@mui/material";
interface ClosedProps {
open : false;
}
interface OpenProps {
open : true;
id : string;
}
export type DetailInfo = OpenProps | ClosedProps;
type DetailProps = DetailInfo & { onClose : (error? : string) => void };
export default function Detail(props : DetailProps) {
const [data, setData] = useState<SolicitorInfo | 'loading'>('loading');
const handleResult = (result : SolicitorInfo | undefined) => {
console.log(data, props, result);
if (data == 'loading' && props.open && props.id == result?.id) {
setData(result);
} else if (!result) {
handleClose('An error occurred loading the solicitor');
}
}
const handleClose = (error? : string) => {
props.onClose(error);
setData('loading');
}
useEffect(() => {
if (props.open && data == 'loading') {
getSolicitor(props.id)
.then(result => { console.log(result); handleResult(result); });
}
}, [props.open]);
useEffect(() => {}, [data])
return (
<Modal
open={props.open}
onClose={() => handleClose()}
>
<DetailDisplay data={data} />
</Modal>
);
}
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 800,
maxWidth: '60%',
maxHeight: '80%',
bgcolor: 'background.default',
overflow: 'scroll',
p: 4,
};
const DetailDisplay = forwardRef((props : {data : SolicitorInfo | 'loading'}) => {
console.log(props.data);
useEffect(() => {console.log(props.data);}, [props.data]);
if (props.data === 'loading') {
return <Box>Nope</Box>;
} else {
return (
<Box sx={style}>
<Typography variant='h3' component='h2'>{props.data.name}</Typography>
<Typography variant='body1'>{props.data.shortDescription}</Typography>
{props.data.ratings &&
(<>
<br />
<Typography variant='h5' component='h3'>Ratings</Typography>
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
{props.data.ratings.map(rating => (
<Grid size={3}>
<Box sx={{padding: '15px', height: '100%'}}>
<Typography variant='body2'>{rating.provider}</Typography>
<Rating
readOnly
value={rating.value * 5.0 / rating.maximum}
precision={0.1}
size='small'
sx={{top: '2px'}}
/>
</Box>
</Grid>
))}
</Grid>
</>)
}
{props.data.locations &&
(<>
<br />
<Typography variant='h5' component='h3'>Offices</Typography>
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
{props.data.locations.map(office => (
<Grid size={3}>
<Card sx={{padding: '15px', height: '100%'}}>
{office.address.split('\n').map(line => (<Typography variant='body2'>{line}</Typography>))}
{office.phone && <><br/><Typography variant='body2'>tel: {office.phone}</Typography></>}
{office.ratings && office.ratings.map(rating =>
(<>
<Typography variant='body2'>{rating.provider}</Typography>
<Rating
readOnly
value={rating.value * 5.0 / rating.maximum}
precision={0.1}
size='small'
sx={{top: '2px'}}
/>
</>)
)}
</Card>
</Grid>
))}
</Grid>
</>)
}
</Box>
);
}
});

View File

@ -0,0 +1,42 @@
import DarkMode from "@mui/icons-material/DarkMode";
import LightMode from "@mui/icons-material/LightMode";
import { AppBar, IconButton, Toolbar, Tooltip, Typography } from "@mui/material";
import { Theme } from "@mui/material/styles";
export interface ThemeWrapper {
theme : Theme,
value : 'dark' | 'light';
}
interface HeaderProps {
theme : ThemeWrapper;
toggleTheme : () => void;
}
export default function Header(props : HeaderProps) {
return (
<AppBar position='static' sx={{}}>
<Toolbar sx={{bgcolor:'primary.main'}}>
<Typography variant="h4" component="h1" sx={{ flexGrow: 2, marginLeft: '16px' }}>
Conveyancing Search
</Typography>
<Tooltip title="Toggle theme">
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="theme toggle"
sx={{ mr: 2 }}
onClick={props.toggleTheme}
>
{
props.theme.value === 'dark'
? <DarkMode />
: <LightMode />
}
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
);
}

View File

@ -0,0 +1,114 @@
import * as React from 'react';
import { NumberField as BaseNumberField } from '@base-ui/react/number-field';
import IconButton from '@mui/material/IconButton';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
/**
* This component is a placeholder for FormControl to correctly set the shrink label state on SSR.
*/
function SSRInitialFilled(_: BaseNumberField.Root.Props) {
return null;
}
SSRInitialFilled.muiName = 'Input';
export default function NumberField({
id: idProp,
label,
error,
size = 'medium',
...other
}: BaseNumberField.Root.Props & {
label?: React.ReactNode;
size?: 'small' | 'medium';
error?: boolean;
}) {
let id = React.useId();
if (idProp) {
id = idProp;
}
return (
<BaseNumberField.Root
{...other}
render={(props, state) => (
<FormControl
size={size}
ref={props.ref}
disabled={state.disabled}
required={state.required}
error={error}
variant="outlined"
>
{props.children}
</FormControl>
)}
>
<SSRInitialFilled {...other} />
<InputLabel htmlFor={id}>{label}</InputLabel>
<BaseNumberField.Input
id={id}
render={(props, state) => (
<OutlinedInput
aria-describedby={`${id}-helper-text`}
label={label}
inputRef={props.ref}
value={state.inputValue}
onBlur={props.onBlur}
onChange={props.onChange}
onKeyUp={props.onKeyUp}
onKeyDown={props.onKeyDown}
onFocus={props.onFocus}
slotProps={{
input: props,
}}
endAdornment={
<InputAdornment
position="end"
sx={{
flexDirection: 'column',
maxHeight: 'unset',
alignSelf: 'stretch',
borderLeft: '1px solid',
borderColor: 'divider',
ml: 0,
'& button': {
py: 0,
flex: 1,
borderRadius: 0.5,
},
}}
>
<BaseNumberField.Increment
render={<IconButton size={size} aria-label="Increase" />}
>
<KeyboardArrowUpIcon
fontSize={size}
sx={{ transform: 'translateY(2px)' }}
/>
</BaseNumberField.Increment>
<BaseNumberField.Decrement
render={<IconButton size={size} aria-label="Decrease" />}
>
<KeyboardArrowDownIcon
fontSize={size}
sx={{ transform: 'translateY(-2px)' }}
/>
</BaseNumberField.Decrement>
</InputAdornment>
}
sx={{ pr: 0 }}
/>
)}
/>
<FormHelperText id={`${id}-helper-text`} sx={{ ml: 0, '&:empty': { mt: 0 } }}>
Enter value between 10 and 40
</FormHelperText>
</BaseNumberField.Root>
);
}

View File

@ -0,0 +1,42 @@
import ListItemText from "@mui/material/ListItemText";
import SolicitorSummary from "../models/solicitorSummary";
import ListItemButton from "@mui/material/ListItemButton";
import Typography from "@mui/material/Typography";
import Rating from "@mui/material/Rating";
import Divider from "@mui/material/Divider";
type ResultProps = SolicitorSummary & { last : boolean, onClick: () => void };
export default function Result(props : ResultProps) {
return (
<>
<ListItemButton
alignItems='flex-start'
onClick={props.onClick}
>
<ListItemText
primary={
<>
<Typography
component='span'
variant='h6'
sx={{marginRight:'10px'}}
>
{props.name}
</Typography>
<Rating
readOnly
value={props.rating.value * 5.0 / props.rating.maximum}
precision={0.1}
size='small'
sx={{top: '2px'}}
/>
</>
}
secondary={props.shortDescription}
/>
</ListItemButton>
{props.last || <Divider variant='middle' component="li" />}
</>
);
}

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from "preact/hooks";
import GetSolicitors from "../functions/getSolicitors";
import Grid from "@mui/material/Grid";
import Sidebar from "./sidebar";
import FilterState from "../models/filterState";
import { default as ResultsType, waitingResults } from "../models/results";
import ResultsList from "./resultsList";
export default function Results() {
const [results, setResults] = useState<ResultsType>(waitingResults);
const [filters, setFilters] = useState<FilterState>({cities: [], minRating: 3, resultsPerPage: 20, currentPage: 1, ratingsProvider: 'Solicitors.com', sortBy: 'rating-desc'});
useEffect(() => {
if (results.type == 'Waiting') {
GetSolicitors(filters)
.then(result => setResults({type: 'Success', data: result}));
}
}, [results]);
useEffect(() => {
setResults({type: 'Waiting'});
}, [filters]);
return (
<Grid container spacing={0} sx={{minHeight: '100vh'}}>
<Grid size={4}>
<Sidebar
filters={filters}
onChange={setFilters}
/>
</Grid>
<Grid size={8}>
<ResultsList results={results} filters={filters} setFilters={setFilters} />
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,42 @@
import List from "@mui/material/List";
import Pagination from "@mui/material/Pagination";
import FilterState from "../models/filterState";
import Results from "../models/results";
import Result from "./result";
import Detail, { DetailInfo } from "./detail";
import { useState } from "react";
interface ResultsListProps {
results : Results;
filters : FilterState;
setFilters : (state : FilterState) => void;
}
export default function ResultsList(props : ResultsListProps) {
const [detail, setDetail] = useState<DetailInfo>({open: false});
switch (props.results.type) {
case 'Success':
let data = props.results.data.data;
return (
<>
<List sx={{ width: '100%', maxHeight: '85vh', overflow: 'scroll' }}>
{data.map((r, i) => (<Result {...r} onClick={() => setDetail({open : true, id : r.id})} last={i == data.length - 1} />))}
</List>
<Detail {...detail} onClose={() => setDetail({open: false})} />
<Pagination
page={props.filters.currentPage}
count={Math.ceil(props.results.data.total / props.filters.resultsPerPage)}
shape='rounded'
onChange={(_, page) => props.setFilters(Object.assign({}, {...props.filters}, {currentPage: page}))}
/>
</>
);
case 'Waiting':
return <div>Waiting...</div>;
case 'Error':
return <div>Error...</div>;
case 'Warning':
return <div>Warning...</div>;
}
}

View File

@ -0,0 +1,140 @@
import { Autocomplete, Box, Divider, Grid, InputLabel, MenuItem, Paper, Rating, Select, SelectChangeEvent, TextField, Typography } from "@mui/material";
import getCities from "../functions/getCities";
import FilterState from "../models/filterState";
import { useEffect, useState } from "react";
import NumberField from "./numberField";
import getRatingsProviders from "../functions/getRatingsProviders";
interface SidebarProps {
filters : FilterState;
onChange : (filters : FilterState) => void;
}
function capitalizeFirstLetter(val : string) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
export default function Sidebar(props : SidebarProps) {
const [cities, setCities] = useState<string[]>([]);
const [ratingsProviders, setRatingsProviders] = useState<string[]>(['Solicitors.com']);
useEffect(() => {
if (cities.length === 0) {
getCities().then((results) => setCities(results));
}
}, [cities.length]);
useEffect(() => {
if (cities.length === 0) {
getRatingsProviders().then((results) => setRatingsProviders(results));
}
}, [cities.length]);
return (
<Paper sx={{margin: '8px', padding: '8px'}}>
<Box sx={{p: 2}}>
<Typography variant='h5' component='h2'>Filter Conveyancers</Typography>
</Box>
<Box sx={{ p: 2}}>
<Typography component='legend'>Cities</Typography>
<Autocomplete
multiple
options={cities}
value={props.filters.cities}
getOptionLabel={capitalizeFirstLetter}
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {cities: value}))}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
label="Cities"
placeholder=""
/>
)}
/>
</Box>
<Divider />
<Box sx={{p: 2}}>
<Grid container spacing={1} sx={{width: '100%'}}>
<Grid size={6}>
<Typography component='legend'>Min. Rating</Typography>
<Rating
name='Min. Rating'
precision={0.5}
value={props.filters.minRating}
size='medium'
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {minRating: value}))}
/>
</Grid>
<Grid size={6}>
<InputLabel id='ratings-label'>Rating Provider</InputLabel>
<Select
labelId='ratings-label'
value={props.filters.ratingsProvider}
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {ratingsProvider: (e.target as any).value}))}
>
{ratingsProviders.map(provider => {
return (
<MenuItem
value={provider}
selected={provider === props.filters.ratingsProvider}>{capitalizeFirstLetter(provider)}
</MenuItem>
)
})}
</Select>
</Grid>
</Grid>
</Box>
<Divider />
<Box sx={{p: 2}}>
<Typography component='legend'>Results per Page</Typography>
<NumberField
label=""
value={props.filters.resultsPerPage}
onValueChange={value => props.onChange(Object.assign({}, {...props.filters}, {resultsPerPage: value, currentPage: 1}))}
min={10}
max={40}
/>
</Box>
<Divider />
<Box sx={{p: 2}}>
<InputLabel id='sort-label'>Sort By</InputLabel>
<Select
labelId='sort-label'
value={props.filters.sortBy}
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {sortBy: (e.target as any).value}))}
>
{sortingOptions.map(option => {
return (
<MenuItem
value={option.value}
selected={option.value === props.filters.sortBy}
>
{option.name}
</MenuItem>
)
})}
</Select>
</Box>
</Paper>
)
}
const sortingOptions = [
{
value: 'rating-desc',
name: 'Rating (descending)'
},
{
value: 'rating-asc',
name: 'Rating (ascending)'
},
{
value: 'alphabet-asc',
name: 'Alphabetical (ascending)'
},
{
value: 'alphabet-desc',
name: 'Alphabetical (descending)'
}
]

View File

@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL
});
export default api;

View File

@ -0,0 +1,6 @@
import api from "./axios";
export default async function GetCiries() {
const response = await api.get('/cities');
return response.data as string[];
}

View File

@ -0,0 +1,6 @@
import api from "./axios";
export default async function GetCiries() {
const response = await api.get('/ratingsProviders');
return response.data as string[];
}

View File

@ -0,0 +1,7 @@
import { SolicitorInfo } from "../models/solicitorSummary";
import api from "./axios";
export default async function GetSolicitor(id : string) : Promise<SolicitorInfo | undefined> {
const response = await api.get(`/conveyancors/${id}`);
return response.data as SolicitorInfo;
}

View File

@ -0,0 +1,22 @@
import SolicitorSummary from "../models/solicitorSummary";
import PaginationResponse from "../models/paginationResponse";
import FilterState from "../models/filterState";
import api from "./axios";
export default async function GetSolicitors(filters : FilterState) {
console.log(api.defaults.baseURL);
var minRating = filters.minRating;
if (minRating == null) {
minRating = 0;
}
let queryString = `pageSize=${filters.resultsPerPage}&pageNumber=${filters.currentPage}&ratingsProvider=${filters.ratingsProvider}&minRating=${minRating}`;
if (filters.cities.length > 0) {
queryString += '&cities=' + filters.cities.join('&cities=');
}
if (filters.sortBy != null) {
queryString += `&orderingType=${filters.sortBy}`;
}
const response = await api.get(`/conveyancors?${queryString}`);
return response.data as PaginationResponse<SolicitorSummary>;
}

129
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,129 @@
import { render } from 'preact';
import Button from '@mui/material/Button';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import { useState } from 'preact/compat';
import { createTheme, Theme, ThemeProvider } from '@mui/material/styles';
import DarkMode from '@mui/icons-material/DarkMode';
import LightMode from '@mui/icons-material/LightMode';
import { CssBaseline, Tooltip } from '@mui/material';
import Header, { ThemeWrapper } from './components/header';
import Results from './components/results';
function prefersDarkMode() {
const darkModeMql = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
return darkModeMql && darkModeMql.matches;
}
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
light: '#B79AD5',
main: '#A27EC9',
dark: '#8D61BD'
},
secondary: {
light: '#70DBFF',
main: '#47D1FF',
dark: '#1FC7FF'
},
error: {
light: '#E73936',
main: '#DD1C1A',
dark: '#B71815'
},
warning: {
light: '#FA804C',
main: '#F9611F',
dark: '#EF4C06'
},
info : {
light: '#FFEB99',
main: '#FFE066',
dark: '#FFDA47'
},
success: {
light: '#6ADC98',
main: '#48D480',
dark: '#2FC66B'
},
background: {
default: '#1D2535',
paper: '#242E42'
}
}
});
const lightTheme = createTheme({
palette: {
mode: 'light',
primary: {
light: '#B79AD5',
main: '#A27EC9',
dark: '#8D61BD'
},
secondary: {
light: '#70DBFF',
main: '#47D1FF',
dark: '#1FC7FF'
},
error: {
light: '#E73936',
main: '#DD1C1A',
dark: '#B71815'
},
warning: {
light: '#FA804C',
main: '#F9611F',
dark: '#EF4C06'
},
info : {
light: '#FFEB99',
main: '#FFE066',
dark: '#FFDA47'
},
success: {
light: '#6ADC98',
main: '#48D480',
dark: '#2FC66B'
},
background: {
default: '#EFFFFF',
paper: '#e8f1f1'
}
}
});
export function App() {
const [value, setValue] = useState(0);
const [theme, setTheme] = useState<ThemeWrapper>({
theme : prefersDarkMode() ? darkTheme : lightTheme,
value : prefersDarkMode() ? 'dark' : 'light'
});
const handleChange = (event: any, newValue: number) => {
setValue(newValue);
};
const toggleTheme = () => {
setTheme(theme.value == 'dark'
? { theme : lightTheme, value : 'light' }
: { theme : darkTheme, value : 'dark' });
}
return (
<ThemeProvider theme={theme.theme}>
<CssBaseline />
<Header theme={theme} toggleTheme={toggleTheme} />
<Container maxWidth="lg" sx={{marginTop: '10px'}}>
<Results />
</Container>
</ThemeProvider>
);
}
render(<App />, document.getElementById('app'));

View File

@ -0,0 +1,8 @@
export default interface FilterState {
cities : string[];
minRating : number;
resultsPerPage : number;
currentPage : number;
ratingsProvider : string;
sortBy : string;
};

View File

@ -0,0 +1,4 @@
export default interface PaginationResponse<T> {
data : T[];
total : number;
}

View File

@ -0,0 +1,25 @@
import PaginationResponse from "./paginationResponse";
import SolicitorSummary from "./solicitorSummary";
type Results = SuccessResults | ErrorResults | WaitingResults;
interface SuccessResults {
type : 'Success',
data : PaginationResponse<SolicitorSummary>
};
interface ErrorResults {
type : 'Error' | 'Warning',
message : string
};
interface WaitingResults {
type : 'Waiting'
};
const waitingResults : WaitingResults = {
type : 'Waiting'
};
export default Results;
export {waitingResults};

View File

@ -0,0 +1,30 @@
export default interface SolicitorSummary {
name : string,
shortDescription : string | null,
id : string,
rating : Rating
};
export interface Rating {
value : number,
maximum : number,
provider : string
}
export interface Location {
address : string,
phone : string,
ratings : Rating[]
}
export interface SolicitorInfo {
name : string,
shortDescription : string | null,
id : string,
phone? : string,
email? : string,
website? : string,
ratings : Rating[],
locations : Location[]
}

20
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowJs": true,
"checkJs": true,
/* Preact Config */
"jsx": "react-jsx",
"jsxImportSource": "preact",
"skipLibCheck": true,
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
},
"include": ["node_modules/vite/client.d.ts", "**/*"]
}

Some files were not shown because too many files have changed in this diff Show More